Skip to content

Commit 9cc6777

Browse files
Elon Muskclaude
andauthored
fix(cli): resolve an app-declared config plugin from the app, not the CLI (#10948)
* fix(cli): resolve an app-declared config plugin from the app, not the CLI (#10908) `plugins: [...]` in the served app's own `objectstack.config.ts` is the documented extension path, and its string entries are the most app-owned specifiers in `serve.ts` — yet they were loaded with a bare `import()`, which Node ESM resolves against the CLI's realpath. A plugin the APP declares was therefore only loadable where it happened to be hoisted somewhere the CLI could see it: green in a dev checkout, missing on a real distribution layout. Same class as cloud#1013 and #10645, on the surface users are told to use. `Serve.importConfigPlugin` now lets the DECLARATION pick the resolver, which is what #4719 says should decide. Handing every specifier to `importFromHost` would not have been a superset — measured, twice over: * a relative specifier would re-base from this file's directory to `@objectstack/types/dist/`, because the host importer's pass-through re-enters `import()` from inside that package; * an UNDECLARED bare name would too, and that one takes working deployments away: from an app that declares nothing, `@objectstack/plugin-auth` and `@objectstack/plugin-audit` resolve from the CLI and fail through the host importer under a pnpm-isolated layout. So only the declared case moves; the other two branches keep the exact resolution they had. Nothing changes about which plugins are ACCEPTED — the #4719 declaration gate is untouched and no undeclared package gains a way in. The missing-plugin diagnostic now nests the #4719 remedy, and is pinned as a chosen text rather than left to drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r * test(cli): pin that the app's copy wins over the CLI's own for a declared plugin (#10908) The first draft of this file could not tell the two apart: its fixture package was invisible to the CLI, so the declared branch and the undeclared fallback both converged on the app's copy and an ablation that disabled the declaration check stayed green. Declaring a package the CLI ALSO resolves (`chalk`) is what makes "which copy wins" observable, which is the resolution-policy question the card is actually about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r * docs(cli): point the config-plugin branches at the cards carrying their questions (#10908) Both non-declared branches exist because of a measurement, and each measurement turned into a card: #10943 (the host importer's fallback resolves from `@objectstack/types`, contradicting its own docblock) and #10944 (a relative `plugins:` entry resolves against the CLI's directory, so it cannot work). Naming them here is what stops the next reader re-deriving the branch or deleting it as redundant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e93fc6 commit 9cc6777

4 files changed

Lines changed: 337 additions & 11 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os serve` now resolves a `plugins: [...]` entry the served app **declares** from
6+
that app, instead of from the CLI (#10908).
7+
8+
`plugins: [...]` in the app's own `objectstack.config.ts` is the documented way
9+
to extend a deployment, but its string entries were loaded with a bare
10+
`import()`, which Node ESM resolves against the CLI's realpath. An app that
11+
wrote `plugins: ['@acme/my-plugin']` and declared `@acme/my-plugin` in its own
12+
`package.json` could therefore only be served where that package happened to be
13+
hoisted somewhere the CLI could see it — true in a dev checkout, absent on a
14+
real distribution layout. Same mechanism as the cluster and organizations loads
15+
fixed earlier.
16+
17+
Only the **declared** case moves. A specifier the app does not declare still
18+
resolves from the CLI exactly as before, and a path or `file://` URL keeps the
19+
base it always had, so no deployment loses a plugin it is loading today. Which
20+
plugins are *accepted* is unchanged — the declaration gate is untouched.
21+
22+
One user-facing message changes: when a declared plugin cannot be loaded, the
23+
`Failed to import plugin '<name>'` error now carries the declaration remedy
24+
("declare it in that app's `package.json`", or the install-problem text when the
25+
app declares it but it is not installed) instead of a bare `Cannot find package`.

packages/cli/src/commands/serve-cluster-host-resolution.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,11 +278,18 @@ const UNRESOLVABLE_BARE_IMPORTS: Record<string, string> = {
278278
// Serve.CAPABILITY_PROVIDERS — every `pkg` in that table is CLI-declared.
279279
'spec.pkg': 'Serve.CAPABILITY_PROVIDERS entries are all CLI-declared',
280280
'ex.pkg': 'CAPABILITY_PROVIDERS `extras` entries are all CLI-declared',
281-
// The app's own `plugins: [...]` config entries — an app-supplied specifier, so
282-
// this IS the class, but no source scan can classify it and host-anchoring it
283-
// changes a user-facing error message plus which copy of a CLI-declared plugin
284-
// wins. Filed as #10908 rather than widened here.
285-
plugin: 'app-supplied plugin name from objectstack.config.ts — see #10908',
281+
// The app's own `plugins: [...]` config entries, now routed through
282+
// `Serve.importConfigPlugin` (#10908). Two bare `import()` sites remain there,
283+
// both reached only AFTER the declaration has been consulted, and both are the
284+
// reason this list exists rather than a hole in it:
285+
// • the specifier is not a package name at all (path, `file://`, `node:`) —
286+
// nothing a package.json can declare;
287+
// • the served app does NOT declare it, so it must resolve from this CLI,
288+
// which is exactly the pre-existing behaviour #10908 promised to keep.
289+
// The DECLARED case — the only one this card moves — goes to `importFromHost`.
290+
// Pinned behaviourally, not by this comment, in
291+
// `serve-config-plugin-host-resolution.test.ts`.
292+
pluginSpecifier: 'post-declaration branches: a path/URL, or a package the app does not declare (#10908)',
286293
};
287294

288295
/**
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { pathToFileURL } from 'node:url';
5+
import { readFileSync } from 'node:fs';
6+
import { afterAll, describe, expect, it } from 'vitest';
7+
import Serve from './serve.js';
8+
9+
/**
10+
* `plugins: [...]` in the served app's own `objectstack.config.ts` is THE
11+
* documented way to extend a deployment, and its string entries are the most
12+
* app-owned specifiers in `serve.ts`. They were loaded with a bare `import()`,
13+
* which Node ESM resolves against the CLI's realpath — so a plugin the APP
14+
* declares could only be served where it happened to be hoisted somewhere the
15+
* CLI could see. Green in a dev checkout, absent on a real distribution layout
16+
* (#10908; the same mechanism as cloud#1013 and #10645).
17+
*
18+
* The repair moves ONLY the declared case. These tests pin all three branches,
19+
* because two of them exist to keep behaviour that a naive
20+
* `await importFromHost(specifier)` would have taken away — see
21+
* `Serve.importConfigPlugin` for the measurements.
22+
*/
23+
24+
const roots: string[] = [];
25+
afterAll(() => {
26+
for (const r of roots) rmSync(r, { recursive: true, force: true });
27+
});
28+
29+
/**
30+
* An app root with its own `package.json`, optionally DECLARING `pkgName` and
31+
* optionally carrying it in its own `node_modules`. Nothing is installed or
32+
* built: the package is two files in a temp dir, and its marker export is how a
33+
* test proves WHICH copy loaded.
34+
*/
35+
function makeApp(
36+
pkgName: string,
37+
opts: { declare: boolean; install: boolean; marker?: string },
38+
): string {
39+
const root = mkdtempSync(join(tmpdir(), 'os-cfg-plugin-'));
40+
roots.push(root);
41+
writeFileSync(
42+
join(root, 'package.json'),
43+
JSON.stringify({
44+
name: 'fixture-app',
45+
version: '1.0.0',
46+
type: 'module',
47+
...(opts.declare ? { dependencies: { [pkgName]: '1.0.0' } } : {}),
48+
}),
49+
);
50+
if (opts.install) {
51+
const dir = join(root, 'node_modules', ...pkgName.split('/'));
52+
mkdirSync(dir, { recursive: true });
53+
writeFileSync(
54+
join(dir, 'package.json'),
55+
JSON.stringify({ name: pkgName, version: '1.0.0', type: 'module', main: 'index.js' }),
56+
);
57+
writeFileSync(
58+
join(dir, 'index.js'),
59+
`export default { name: ${JSON.stringify(opts.marker ?? 'app-copy')} };\n`,
60+
);
61+
}
62+
return root;
63+
}
64+
65+
// A name no workspace package can satisfy, so a pass can never come from the
66+
// CLI's own node_modules by accident.
67+
const APP_ONLY = '@os-fixture/config-plugin-probe';
68+
69+
describe('os serve → an app-declared `plugins: [...]` package resolves from the APP (#10908)', () => {
70+
it('loads the copy the served app declares and carries — the defect, repaired', async () => {
71+
const root = makeApp(APP_ONLY, { declare: true, install: true, marker: 'app-copy' });
72+
73+
// The failing hop, reproduced first: this file resolves from `packages/cli`
74+
// exactly as `dist/commands/serve.js` does, and cannot see the app's package.
75+
const bare: string = APP_ONLY;
76+
await expect(import(bare)).rejects.toMatchObject({
77+
code: expect.stringMatching(/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND/),
78+
});
79+
80+
const mod = await Serve.importConfigPlugin(APP_ONLY, root);
81+
expect(mod.default).toEqual({ name: 'app-copy' });
82+
});
83+
84+
it("prefers the APP's copy over the CLI's own when BOTH can resolve the name", async () => {
85+
// The resolution-policy question the card names: which copy wins when the
86+
// CLI also ships the package. `chalk` is declared by packages/cli and
87+
// resolves from this file, so a fixture app that declares its OWN `chalk`
88+
// is the only way to tell the two apart — and the app's declaration is the
89+
// contract (#4719), so the app's copy must win.
90+
const root = makeApp('chalk', { declare: true, install: true, marker: 'app-owned-chalk' });
91+
92+
const mod = await Serve.importConfigPlugin('chalk', root);
93+
94+
expect(mod.default).toEqual({ name: 'app-owned-chalk' });
95+
// Not a tautology: the CLI's own resolution of the same name finds the real
96+
// package, which is what this line would have loaded before the fix.
97+
const cliCopy: any = await import('chalk');
98+
expect(cliCopy.default).not.toEqual({ name: 'app-owned-chalk' });
99+
});
100+
101+
it('a package the app DECLARES but never installed reports the INSTALL remedy, not an absence', async () => {
102+
const root = makeApp(APP_ONLY, { declare: true, install: false });
103+
104+
const err = await Serve.importConfigPlugin(APP_ONLY, root).catch((e: unknown) => e as Error);
105+
106+
expect(err).toBeInstanceOf(Error);
107+
expect(err.message).toContain(`Failed to import plugin '${APP_ONLY}':`);
108+
// The app asked for it, so re-reading the manifest is not the remedy.
109+
expect(err.message).toContain('DECLARES it');
110+
expect(err.message).toMatch(/INSTALL problem, not a declaration problem/);
111+
});
112+
});
113+
114+
/**
115+
* Triage ② on #10908: host-anchoring changes the user-facing text a missing
116+
* plugin produces — the wrapper now nests `createHostImporter`'s #4719 remedy.
117+
* That is a better diagnostic, but it is VISIBLE, so it is pinned here as a
118+
* chosen behaviour rather than left to drift.
119+
*/
120+
describe('os serve → the missing-plugin diagnostic is a chosen text (#10908 / #4719)', () => {
121+
it('names the plugin, then tells the author to DECLARE it in that app', async () => {
122+
const root = makeApp(APP_ONLY, { declare: false, install: false });
123+
124+
const err = await Serve.importConfigPlugin(APP_ONLY, root).catch((e: unknown) => e as Error);
125+
126+
expect(err).toBeInstanceOf(Error);
127+
// The wrapper `serve` has always put around a failed plugin load.
128+
expect(err.message).toContain(`Failed to import plugin '${APP_ONLY}':`);
129+
// …now carrying the #4719 remedy instead of a bare "Cannot find package".
130+
expect(err.message).toMatch(/Declare it in that app's package\.json/);
131+
expect(err.message).toContain(root);
132+
// The gate's own reasoning survives into what the user reads: being merely
133+
// reachable is refused ON PURPOSE, so nobody "fixes" this with NODE_PATH.
134+
expect(err.message).toMatch(/merely REACHABLE is not enough/);
135+
});
136+
});
137+
138+
/**
139+
* The two branches that exist so this card could not take working deployments
140+
* away. Both were MEASURED against `createHostImporter` before being written:
141+
* its pass-through and its undeclared fallback both re-enter `import()` from
142+
* inside `@objectstack/types`, which moves the resolution base.
143+
*/
144+
describe('os serve → the branches that must NOT move (#10908 supersedes nothing)', () => {
145+
it('keeps this CLI as the resolver for a package the app does not declare', async () => {
146+
// `chalk` is declared by packages/cli and by no fixture app. Today's bare
147+
// `import()` finds it; through the host importer's fallback — which resolves
148+
// from `@objectstack/types` — it does not. An app that writes
149+
// `plugins: ['@objectstack/plugin-auth']` without declaring it boots today,
150+
// and this is the assertion that says it still does.
151+
const root = makeApp(APP_ONLY, { declare: false, install: false });
152+
153+
const mod = await Serve.importConfigPlugin('chalk', root);
154+
expect(mod.default ?? mod).toBeTruthy();
155+
});
156+
157+
it('keeps a RELATIVE specifier anchored to serve.ts, not to @objectstack/types', async () => {
158+
const root = makeApp(APP_ONLY, { declare: false, install: false });
159+
const missing = './__no_such_config_plugin_10908__.js';
160+
161+
const err = await Serve.importConfigPlugin(missing, root).catch((e: unknown) => e as Error);
162+
163+
expect(err).toBeInstanceOf(Error);
164+
expect(err.message).toContain(`Failed to import plugin '${missing}':`);
165+
// The base is what this pins: the directory holding serve.ts. Routing this
166+
// spelling through the host importer would silently re-base it under
167+
// `@objectstack/types/dist/`, which is the regression this branch prevents.
168+
// Neither base is the served app's root — whether a relative entry SHOULD
169+
// resolve there is #10944, deliberately left open by this card.
170+
expect(err.message).toContain('commands');
171+
expect(err.message).not.toContain('types/dist');
172+
});
173+
174+
it('loads an absolute path and a file:// URL unchanged (base-independent spellings)', async () => {
175+
const root = makeApp(APP_ONLY, { declare: false, install: false });
176+
const file = join(root, 'local-plugin.js');
177+
writeFileSync(file, 'export default { name: "app-local-plugin" };\n');
178+
179+
for (const spelling of [file, pathToFileURL(file).href]) {
180+
const mod = await Serve.importConfigPlugin(spelling, root);
181+
expect(mod.default).toEqual({ name: 'app-local-plugin' });
182+
}
183+
});
184+
});
185+
186+
describe('os serve → the config-plugin load stays wired to the helper', () => {
187+
const SERVE_SOURCE = readFileSync(new URL('./serve.ts', import.meta.url), 'utf8');
188+
189+
it('the boot loop calls the helper and no longer bare-imports the entry', () => {
190+
expect(SERVE_SOURCE).toContain('await Serve.importConfigPlugin(plugin, hostRoot)');
191+
// The exact shape the card was filed against — it must not come back.
192+
expect(SERVE_SOURCE).not.toMatch(/const imported = await import\(plugin\)/);
193+
});
194+
195+
it('the declaration decides the resolver, so the gate keeps its say (#4719)', () => {
196+
// A helper that stopped consulting the declaration would still pass every
197+
// behavioural test above that uses a DECLARED fixture, so pin the wiring.
198+
const helper = SERVE_SOURCE.slice(SERVE_SOURCE.indexOf('static async importConfigPlugin'));
199+
expect(helper).toContain('isDeclaredByHost(pluginSpecifier, root)');
200+
expect(helper).toContain('importFromHost(pluginSpecifier, root)');
201+
});
202+
});

packages/cli/src/commands/serve.ts

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
createHostImporter,
5555
hostImportFailureKind,
5656
isDeclaredByHost,
57+
packageNameFromSpecifier,
5758
readHostDeclaration,
5859
type HostImporter,
5960
} from '@objectstack/types/node';
@@ -393,6 +394,98 @@ export default class Serve extends Command {
393394
return 'off';
394395
}
395396

397+
/**
398+
* Load one `plugins: [...]` entry of the served app's own config that is
399+
* written as a STRING (#10908).
400+
*
401+
* This is the most app-owned specifier in the whole file — it is supplied by
402+
* the app being served, and `plugins: [...]` is THE documented way to extend a
403+
* deployment. It used to be loaded with a bare `import()`, which Node ESM
404+
* resolves against the IMPORTER's realpath: this CLI's. So an app that writes
405+
* `plugins: ['@acme/my-plugin']` and DECLARES `@acme/my-plugin` in its own
406+
* package.json could only be served where that package happened to be hoisted
407+
* somewhere the CLI could see it — true in a dev checkout, false in a real
408+
* distribution layout. Same mechanism as cloud#1013 and #10645, but on the
409+
* surface users are explicitly told to use.
410+
*
411+
* ── Why this is three branches and not `await importFromHost(specifier)` ────
412+
*
413+
* The obvious repair is to hand every specifier to `importFromHost`. MEASURED,
414+
* that is NOT a superset of what this line does today — in two ways, both of
415+
* which would take working deployments away:
416+
*
417+
* 1. A RELATIVE specifier changes base. `createHostImporter` passes a
418+
* non-package specifier through to an `import()` that physically lives in
419+
* `@objectstack/types`, and ESM resolves a relative specifier against the
420+
* module CONTAINING the call — so `'./local-plugin.js'` would resolve
421+
* against `@objectstack/types/dist/` instead of this file's directory.
422+
* Neither base is the served app's root, so no relative spelling works
423+
* the way an author would expect either way; #10944 carries that
424+
* question, and this branch is why the answer stays open rather than
425+
* being decided by a silent re-base here.
426+
* 2. An UNDECLARED bare name changes base the same way, and this one bites.
427+
* `createHostImporter`'s fallback is documented as "the importing
428+
* package's own resolution", but the import it falls back to also lives
429+
* in `@objectstack/types`, which under a pnpm-isolated layout can see
430+
* only `@objectstack/types`'s own dependencies. Measured from an app that
431+
* declares nothing: `@objectstack/plugin-auth` and `@objectstack/plugin-
432+
* audit` resolve from THIS package and fail through the host importer.
433+
* An app that writes `plugins: ['@objectstack/plugin-auth']` without
434+
* declaring it — a spelling this repo's own fixtures use — boots today
435+
* and would stop booting. The helper's own docblock claims the opposite
436+
* ("falls back to the importing package's own resolution"); that text is
437+
* wrong, and #10943 carries the fix. Until it lands, a caller that needs
438+
* its own resolution has to ask the declaration itself, as below.
439+
*
440+
* So the declaration is what selects the resolver, exactly as #4719 says it
441+
* should, and each branch keeps the resolution it already had:
442+
*
443+
* • not a package name (path, `file://` URL, `node:` builtin) → unchanged;
444+
* nothing a package.json can declare, so the gate has no opinion.
445+
* • DECLARED by the served app → `importFromHost`: the app's own copy wins.
446+
* This is the repair — the whole card is this branch.
447+
* • UNDECLARED → this CLI's own resolution, byte-identical to the bare
448+
* `import()` that has always been here. No app loses a plugin it does not
449+
* declare but the CLI ships.
450+
*
451+
* Nothing about WHICH plugins are accepted changes: this only moves where a
452+
* declared one resolves FROM. The #4719 declaration gate is untouched, and no
453+
* undeclared package gains a way in that it did not already have.
454+
*
455+
* @param pluginSpecifier The string as the app wrote it in `plugins: [...]`.
456+
* @param hostRoot Root of the served app; defaults to the process CWD, the
457+
* same value `serve`'s boot path computes.
458+
*/
459+
static async importConfigPlugin(pluginSpecifier: string, hostRoot?: string): Promise<any> {
460+
const root = hostRoot ?? process.cwd();
461+
try {
462+
// `await` inside the `try` rather than a bare `return`: a returned promise
463+
// would settle OUTSIDE it and skip the diagnostic wrapper below.
464+
if (packageNameFromSpecifier(pluginSpecifier) === undefined) {
465+
return await import(/* webpackIgnore: true */ pluginSpecifier);
466+
}
467+
if (isDeclaredByHost(pluginSpecifier, root)) {
468+
return await importFromHost(pluginSpecifier, root);
469+
}
470+
try {
471+
return await import(/* webpackIgnore: true */ pluginSpecifier);
472+
} catch (cliError: unknown) {
473+
// Present but broken is a crash, not an absence — never reinterpret it.
474+
if (!Serve.isModuleNotFoundError(cliError)) throw cliError;
475+
// Undeclared AND unresolvable anywhere. Re-enter the host importer for
476+
// the failure alone: it owns the #4719 "declare it in that app's
477+
// package.json" remedy, and having one owner of that wording is why
478+
// this does not compose the message itself.
479+
return await importFromHost(pluginSpecifier, root);
480+
}
481+
} catch (importError: any) {
482+
// The wrapper lives with the load it describes, so the composed
483+
// user-facing string is testable rather than assembled at the call site
484+
// (triage on #10908 requires this text be CHOSEN, not drift).
485+
throw new Error(`Failed to import plugin '${pluginSpecifier}': ${importError.message}`);
486+
}
487+
}
488+
396489
/**
397490
* Tier-gated capability tokens → the tier each one opens when listed in
398491
* `requires`. These have no CAPABILITY_PROVIDERS entry — their loading is
@@ -2673,12 +2766,11 @@ export default class Serve extends Command {
26732766

26742767
// Resolve string references (package names)
26752768
if (typeof plugin === 'string') {
2676-
try {
2677-
const imported = await import(plugin);
2678-
pluginToLoad = imported.default || imported;
2679-
} catch (importError: any) {
2680-
throw new Error(`Failed to import plugin '${plugin}': ${importError.message}`);
2681-
}
2769+
// Host-anchored, NOT a bare `import()`: this specifier comes from
2770+
// the served app's own config, so what the app DECLARES about it is
2771+
// the contract (#10908). The helper carries the failure wrapper too.
2772+
const imported = await Serve.importConfigPlugin(plugin, hostRoot);
2773+
pluginToLoad = imported.default || imported;
26822774
}
26832775

26842776
// Wrap raw config objects (no init/start) into AppPlugin

0 commit comments

Comments
 (0)