Skip to content

Commit e598b1c

Browse files
Elon Muskclaude
andauthored
fix(cli): refuse a relative plugins: [...] entry, naming the two spellings that work (#10944) (#11154)
A string entry that is not a bare package name was handed straight to `import()`, which ESM resolves against `serve.ts` itself — the installed CLI's directory. The served app's root never entered the resolution, so a relative path could never address anything the app owns; measured, `'..'` even loaded this package's own command barrel. The boot loop caught the failure, printed one red line naming a path inside the CLI, and served the app without the plugin. Ruled at triage on #10944: refuse the spelling at load, naming the two that do resolve from the app (a declared bare package name, an absolute/`file://` path). No accepted set widens — the spelling has never loaded an app's file. Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 26dea14 commit e598b1c

4 files changed

Lines changed: 401 additions & 8 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
`os serve` refuses a relative `plugins: [...]` entry, naming the two spellings
6+
that do work (#10944)
7+
8+
A string entry in the served app's own `objectstack.config.ts` that is not a
9+
bare package name was handed straight to `import()`, which ESM resolves against
10+
the file containing the call — `@objectstack/cli/dist/commands/serve.js`. The
11+
served app's root never entered the resolution, so a relative path could not
12+
address anything the app owns. Measured on `origin/main`, from a fixture app
13+
that really did carry `local-plugin.js` beside its `package.json`:
14+
15+
```
16+
'./local-plugin.js' -> Cannot find module '<cli>/src/commands/local-plugin.js'
17+
imported from '<cli>/src/commands/serve.ts'
18+
'../local-plugin.js' -> Cannot find module '<cli>/src/local-plugin.js'
19+
'..' -> LOADED — the CLI's own command barrel
20+
(CompileCommand, ValidateCommand, ServeCommand, …)
21+
```
22+
23+
The app's own file was never seen in any of them. The third row is the same
24+
fact stated as a positive: a relative entry can load a module, but only ever
25+
one belonging to the CLI. The boot loop then caught the failure, printed one
26+
red `✗ Failed to load plugin:` line naming a path inside the CLI's install
27+
directory, and served the app **without** the plugin — a deployment that looks
28+
healthy while quietly missing the extension its config declared.
29+
30+
Such an entry is now refused before any import is attempted, with a message
31+
that names both spellings that resolve from the app:
32+
33+
```
34+
Refused the plugin entry './local-plugin.js' in `plugins: [...]`: a RELATIVE path there is
35+
resolved against the CLI's own installation directory, never against your app — so
36+
it can never load a file from your project. This spelling has never worked; it used
37+
to fail with a "Cannot find module" naming a path inside the CLI's install directory.
38+
39+
Use one of the two spellings that resolve from your app:
40+
41+
1. a package name your app DECLARES in its own package.json:
42+
plugins: ['@mycompany/crm'] (then: pnpm add @mycompany/crm)
43+
44+
2. an absolute path, or a file:// URL the config computes for itself:
45+
plugins: [new URL('./local-plugin.js', import.meta.url).href]
46+
```
47+
48+
**What changes for a config that carries such an entry.** The plugin was never
49+
loaded before and is not loaded now, and the boot still continues past the
50+
failed entry exactly as it did — the observable difference at boot is the text
51+
of the one red line. The entry has *never* worked, but it failed **quietly
52+
enough to be missed**: an author who read `Cannot find module …/@objectstack/
53+
cli/dist/commands/local-plugin.js` had no way to tell that the spelling itself
54+
was the problem, so a config could carry a dead `plugins:` entry indefinitely
55+
while the deployment appeared fine. That is the silence this closes.
56+
57+
`minor` rather than `patch` because one shape does change what it loads:
58+
`plugins: ['.']` / `plugins: ['..']` resolved into the CLI's own package and
59+
could register the CLI's command barrel as a plugin. Those are now refused.
60+
Nothing in `content/docs`, `examples/` or the test suite writes any relative
61+
`plugins:` spelling, so no documented usage moves.
62+
63+
Scope is deliberately narrow, and every other shape that reaches this line was
64+
measured rather than assumed. Untouched: bare package names (declared or not,
65+
scoped or not — including a bare `local-plugin.js`, which ESM reads as a
66+
package name and which keeps its existing "declare it in that app's
67+
package.json" answer), absolute POSIX paths, `file://` URLs, Windows drive
68+
paths (`C:\app\plugin.js`), `node:` builtins and `data:` URLs. All of those are
69+
base-independent — they resolve to the same module whoever imports them — which
70+
is exactly why they are none of this rule's business.
71+
72+
Resolving a relative entry against the **served app's root** instead is a
73+
capability addition with no measured pull today; it stays a maintainer
74+
decision, and this refusal is the collection point for such a request.

‎packages/cli/src/commands/serve-config-plugin-host-resolution.test.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,21 +154,32 @@ describe('os serve → the branches that must NOT move (#10908 supersedes nothin
154154
expect(mod.default ?? mod).toBeTruthy();
155155
});
156156

157-
it('keeps a RELATIVE specifier anchored to serve.ts, not to @objectstack/types', async () => {
157+
// ⚠️ REPLACED, not reworded (#10944). This slot used to pin that a RELATIVE
158+
// specifier stayed anchored to serve.ts rather than being re-based under
159+
// `@objectstack/types/dist/` — i.e. it pinned the exact branch #10944 has
160+
// since removed. #10944 ruled that neither base is the served app's root, so
161+
// the spelling is now refused at load instead of resolved anywhere; keeping
162+
// the old assertion would have pinned a resolution that no longer runs.
163+
// The refusal, the two spellings that do work, and the full shape matrix live
164+
// in `serve-config-plugin-relative-refusal.test.ts`. What remains here is the
165+
// half that is still this file's business: the refusal must not have widened
166+
// to the base-independent spellings this branch exists to protect.
167+
it('refuses a RELATIVE specifier without touching the base-independent ones (#10944)', async () => {
158168
const root = makeApp(APP_ONLY, { declare: false, install: false });
159169
const missing = './__no_such_config_plugin_10908__.js';
160170

161171
const err = await Serve.importConfigPlugin(missing, root).catch((e: unknown) => e as Error);
162172

163173
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');
174+
expect(err.message).toBe(Serve.relativePluginSpecifierRefusal(missing));
175+
// The refusal replaces a resolution attempt, so no base is reported at all.
171176
expect(err.message).not.toContain('types/dist');
177+
// …and an absolute path — the spelling the refusal points at — is
178+
// untouched, which is what makes the line above a narrowing and not a ban.
179+
const file = join(root, 'still-loads.js');
180+
writeFileSync(file, 'export default { name: "still-loads" };\n');
181+
const mod: any = await Serve.importConfigPlugin(file, root);
182+
expect(mod.default).toEqual({ name: 'still-loads' });
172183
});
173184

174185
it('loads an absolute path and a file:// URL unchanged (base-independent spellings)', async () => {
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A RELATIVE `plugins: [...]` entry is refused at load, naming the two
5+
* spellings that work (#10944).
6+
*
7+
* ## The defect, measured before the fix
8+
*
9+
* `plugins: [...]` in the served app's own `objectstack.config.ts` is THE
10+
* documented way to extend a deployment. A string entry that is not a bare
11+
* package name was handed straight to `import()` — and ESM resolves a relative
12+
* specifier against the file CONTAINING the call, which is
13+
* `@objectstack/cli/dist/commands/serve.js`. The served app's root never
14+
* entered the resolution at all.
15+
*
16+
* Measured on `origin/main` from a fixture app that really did carry
17+
* `local-plugin.js` next to its `package.json`:
18+
*
19+
* './local-plugin.js' -> Cannot find module '<cli>/src/commands/local-plugin.js'
20+
* imported from '<cli>/src/commands/serve.ts'
21+
* '../local-plugin.js' -> Cannot find module '<cli>/src/local-plugin.js'
22+
* '..' -> LOADED — this package's own command barrel
23+
* (CompileCommand, ValidateCommand, ServeCommand, …)
24+
*
25+
* The app's own file was never seen in any of them, and the last row is the
26+
* whole point stated as a positive: a relative entry CAN load a module — it can
27+
* only ever load one belonging to the CLI. The boot loop then catches the
28+
* failure, prints one red line naming a path inside the CLI's install
29+
* directory, and serves the app WITHOUT the plugin, so the deployment looks
30+
* healthy while quietly missing the extension its config declared.
31+
*
32+
* ## Why refusal and not app-root resolution
33+
*
34+
* Ruled at triage on #10944. Refusing expands no accepted set — the spelling
35+
* has never loaded an app's file — and converts a diagnostic about the CLI's
36+
* internals into an answer the author can act on. Resolving relative entries
37+
* against the served app's root is a capability addition with no measured pull
38+
* (nothing in `content/docs`, `examples/` or the test suite writes the
39+
* spelling); it stays a maintainer decision, and this refusal is where such a
40+
* request would come from.
41+
*
42+
* ## What the tests below are shaped to catch
43+
*
44+
* ⚠️ A suite that only asserts "the relative entry is refused" passes just as
45+
* green on an implementation that refuses `plugins: [...]` ENTIRELY. So the
46+
* headline test asserts the refusal and BOTH working spellings in one body —
47+
* remove the narrowing from `Serve.isRelativePluginSpecifier` and that test
48+
* reddens on the loads, not on the refusal.
49+
*/
50+
51+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
52+
import { tmpdir } from 'node:os';
53+
import { join } from 'node:path';
54+
import { pathToFileURL } from 'node:url';
55+
import { afterAll, describe, expect, it } from 'vitest';
56+
import Serve from './serve.js';
57+
58+
const roots: string[] = [];
59+
afterAll(() => {
60+
for (const r of roots) rmSync(r, { recursive: true, force: true });
61+
});
62+
63+
/** A name no workspace package can satisfy, so nothing passes by accident. */
64+
const APP_ONLY = '@os-fixture/relative-refusal-probe';
65+
66+
/**
67+
* An app root that carries `local-plugin.js` NEXT TO its `package.json` — the
68+
* exact layout an author writing `plugins: ['./local-plugin.js']` has in mind —
69+
* and, when asked, declares and installs `APP_ONLY` as well.
70+
*/
71+
function makeApp(opts: { declare: boolean } = { declare: false }): string {
72+
const root = mkdtempSync(join(tmpdir(), 'os-10944-'));
73+
roots.push(root);
74+
writeFileSync(
75+
join(root, 'package.json'),
76+
JSON.stringify({
77+
name: 'fixture-app',
78+
version: '1.0.0',
79+
type: 'module',
80+
...(opts.declare ? { dependencies: { [APP_ONLY]: '1.0.0' } } : {}),
81+
}),
82+
);
83+
writeFileSync(join(root, 'local-plugin.js'), 'export default { name: "app-local-plugin" };\n');
84+
if (opts.declare) {
85+
const dir = join(root, 'node_modules', ...APP_ONLY.split('/'));
86+
mkdirSync(dir, { recursive: true });
87+
writeFileSync(
88+
join(dir, 'package.json'),
89+
JSON.stringify({ name: APP_ONLY, version: '1.0.0', type: 'module', main: 'index.js' }),
90+
);
91+
writeFileSync(join(dir, 'index.js'), 'export default { name: "declared-package" };\n');
92+
}
93+
return root;
94+
}
95+
96+
describe('os serve → a relative `plugins: [...]` entry is refused (#10944)', () => {
97+
it('refuses the relative entry AND keeps both working spellings loading — one body, on purpose', async () => {
98+
const root = makeApp({ declare: true });
99+
const absolute = join(root, 'local-plugin.js');
100+
101+
// (1) The shape the card was filed against. The file really is there, in
102+
// the app root, and it is still refused — because the app root was never
103+
// the base and this spelling cannot be made to mean it.
104+
const err = await Serve.importConfigPlugin('./local-plugin.js', root).catch(
105+
(e: unknown) => e as Error,
106+
);
107+
expect(err).toBeInstanceOf(Error);
108+
expect(err.message).toBe(Serve.relativePluginSpecifierRefusal('./local-plugin.js'));
109+
110+
// (2) A DECLARED bare package name — spelling (a) the refusal names.
111+
const declared: any = await Serve.importConfigPlugin(APP_ONLY, root);
112+
expect(declared.default).toEqual({ name: 'declared-package' });
113+
114+
// (3) An absolute path and a `file://` URL — spelling (b) the refusal
115+
// names. Both are base-independent, and both load the app's OWN file: the
116+
// very module (1) refused to reach by the relative spelling.
117+
for (const spelling of [absolute, pathToFileURL(absolute).href]) {
118+
const mod: any = await Serve.importConfigPlugin(spelling, root);
119+
expect(mod.default, spelling).toEqual({ name: 'app-local-plugin' });
120+
}
121+
});
122+
123+
it('names spelling (a) — a bare package name the app DECLARES — in the message', () => {
124+
const message = Serve.relativePluginSpecifierRefusal('./local-plugin.js');
125+
expect(message).toContain('DECLARES in its own package.json');
126+
expect(message).toContain("plugins: ['@mycompany/crm']");
127+
});
128+
129+
it('names spelling (b) — an absolute path or `file://` URL — in the message', () => {
130+
const message = Serve.relativePluginSpecifierRefusal('./local-plugin.js');
131+
expect(message).toContain('absolute path');
132+
expect(message).toContain('file://');
133+
// The form an author writing an app-local plugin file actually wants: the
134+
// CONFIG computes the absolute URL from its own location. Echoing the
135+
// author's own specifier makes the line copy-pasteable.
136+
expect(message).toContain("new URL('./local-plugin.js', import.meta.url).href");
137+
});
138+
139+
it('says WHY, and does not promise app-root resolution is coming', () => {
140+
const message = Serve.relativePluginSpecifierRefusal('./local-plugin.js');
141+
// The consequence, concretely — this is the half that lets an author stop
142+
// hunting for a missing file.
143+
expect(message).toMatch(/CLI's own installation directory/);
144+
expect(message).toMatch(/never against your app/);
145+
// …and it does not claim an import was attempted, because none was.
146+
expect(message).not.toContain('Failed to import plugin');
147+
// Option A stays a maintainer decision; the message must not pre-announce
148+
// it (triage on #10944 is explicit about this).
149+
expect(message).not.toMatch(/coming soon|will be supported|in a future release/i);
150+
});
151+
152+
it('classifies every specifier shape that reaches this line — measured, not assumed', async () => {
153+
// ⚠️ REFUSED and ALLOWED below are the boundary of this card. Each entry
154+
// was run through `Serve.importConfigPlugin` on `origin/main` first; the
155+
// ALLOWED ones all resolve to the SAME module regardless of which file
156+
// imports them, which is exactly why they are none of this rule's business.
157+
const REFUSED = [
158+
'./local-plugin.js', // the filed shape
159+
'../local-plugin.js', // climbing out of the CLI's commands dir
160+
'./nested/plugin.js',
161+
'.', // resolves to the CLI's own commands directory
162+
'..', // MEASURED: loaded the CLI's own command barrel
163+
'.\\local-plugin.js', // Node's URL resolution normalises `\` to `/`
164+
'..\\local-plugin.js',
165+
'./',
166+
];
167+
const ALLOWED = [
168+
'/abs/local-plugin.js', // absolute POSIX path
169+
'file:///abs/local-plugin.js', // file:// URL
170+
'C:\\app\\local-plugin.js', // Windows drive path — absolute
171+
'C:/app/local-plugin.js',
172+
'node:path', // builtin; MEASURED: loads
173+
'data:text/javascript,export default 1', // MEASURED: loads
174+
'@mycompany/crm', // scoped bare package name
175+
'chalk', // unscoped bare package name
176+
// NOT relative under ESM: a bare specifier, even one that looks like a
177+
// filename. It keeps the #4719 "declare it in that app's package.json"
178+
// answer, which is the right one for a bare name — deliberately left
179+
// alone rather than folded into this refusal.
180+
'local-plugin.js',
181+
'.hidden-not-relative', // `.` not followed by a separator
182+
];
183+
184+
for (const s of REFUSED) {
185+
expect(Serve.isRelativePluginSpecifier(s), `REFUSED: ${JSON.stringify(s)}`).toBe(true);
186+
}
187+
for (const s of ALLOWED) {
188+
expect(Serve.isRelativePluginSpecifier(s), `ALLOWED: ${JSON.stringify(s)}`).toBe(false);
189+
}
190+
191+
// The predicate is what the loader consults, so prove the two agree rather
192+
// than trusting the wiring: every REFUSED shape throws the refusal text,
193+
// and no ALLOWED shape ever does.
194+
const root = makeApp();
195+
for (const s of REFUSED) {
196+
const err = await Serve.importConfigPlugin(s, root).catch((e: unknown) => e as Error);
197+
expect(err, s).toBeInstanceOf(Error);
198+
expect(err.message, s).toBe(Serve.relativePluginSpecifierRefusal(s));
199+
}
200+
for (const s of ALLOWED) {
201+
// Most of these cannot resolve from a temp fixture, and that is fine —
202+
// what must never happen is the REFUSAL text.
203+
const outcome = await Serve.importConfigPlugin(s, root).catch((e: unknown) => e as Error);
204+
const message = outcome instanceof Error ? outcome.message : '';
205+
expect(message, s).not.toContain('Refused the plugin entry');
206+
}
207+
});
208+
});
209+
210+
describe('os serve → the refusal is wired into the load, not just exported (#10944)', () => {
211+
it('the boot loop still routes every string entry through the refusing helper', async () => {
212+
const { readFileSync } = await import('node:fs');
213+
const source = readFileSync(new URL('./serve.ts', import.meta.url), 'utf8');
214+
// Without this the behavioural tests above could all pass while the boot
215+
// loop bare-imported the entry and never reached the helper.
216+
expect(source).toContain('await Serve.importConfigPlugin(plugin, hostRoot)');
217+
const helper = source.slice(source.indexOf('static async importConfigPlugin'));
218+
expect(helper).toContain('Serve.isRelativePluginSpecifier(pluginSpecifier)');
219+
expect(helper).toContain('Serve.relativePluginSpecifierRefusal(pluginSpecifier)');
220+
});
221+
});

0 commit comments

Comments
 (0)