Skip to content

Commit 1e38158

Browse files
authored
fix(cli,runtime): a named artifact that is missing fails loudly, and the ordering claims say what the kernel resolves (#4110 follow-up) (#4138)
One principle throughout: "the platform can boot with no application" (#4085) is a statement about CAPABILITY, and it does not license reading a missing NAMED input as "you meant an empty boot". - #4110 made an absent artifact non-fatal all the way down — right for the conventional `<cwd>/dist/objectstack.json` ("not compiled yet"), wrong for `OS_ARTIFACT_PATH` / `{ artifactPath }`, which skip the existence check by design. `OS_ARTIFACT_PATH=/nope os serve` reached "Server is ready" with the missing path named NOWHERE in its output. `createDefaultHostConfig` — the boot with no config, where the artifact IS the deployment — now rejects a named local artifact that does not exist, naming the path and which source named it. This restores an invariant `os start` already documents it depends on ("Defer to serve.ts which already prints a precise error"). - `serve`'s "config not found" refusal now says WHERE it looked (config path, artifact path, OS_ARTIFACT_PATH unset) and still refuses rather than inventing a zero-object platform, pointing at `os start` for a boot that is app-less on purpose. - That refusal was being TRUNCATED: `this.exit(1)` reaches oclif's `process.exit` without draining a piped stdout, so only the first two lines survived — the "where to look" part went missing. Both pre-flight refusals emit one write now. Caught by the e2e added here, not by review. - The ordering claims in `createStandaloneStack` (and the test that pinned an array index with a rationale the kernel does not implement) now describe what the kernel actually resolves: the connect happens in `init()`, and the dependency graph hoists ObjectQLPlugin ahead of the datasource plugin. The test pins the declared dependency instead, which deleting the array position cannot break. #4131 tracks making the AppPlugin end of that contract enforced. Docs: the artifact resolution-priority list read as one fall-through chain; a named artifact never participated in it.
1 parent d5c75e2 commit 1e38158

8 files changed

Lines changed: 303 additions & 16 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(cli,runtime): an artifact you NAMED and a boot input you don't have are different failures — say which (#4110 follow-up, #4131 step 1)
7+
8+
Three corrections, all from the same principle: a platform may boot with no
9+
application (#4085), and that says nothing about how a MISSING NAMED INPUT
10+
should be read.
11+
12+
- **A named-but-missing artifact boots empty and silently.** #4110 made an
13+
absent artifact non-fatal all the way down — right for the conventional
14+
`<cwd>/dist/objectstack.json`, which is just "not compiled yet". But
15+
`OS_ARTIFACT_PATH` / `{ artifactPath }` skip the existence check by design, so
16+
the tolerance reached them too: `OS_ARTIFACT_PATH=/nope os serve` printed
17+
"booting from artifact", reached `Server is ready`, and named the missing path
18+
NOWHERE in its output (serve's boot-quiet window drops the loader's calm
19+
line). `createDefaultHostConfig` — the boot with no config, where the artifact
20+
IS the deployment — now rejects a named local artifact that does not exist,
21+
naming both the path and which source named it. The loader keeps its
22+
tolerance, so the config-boot path #4085 fixed is untouched.
23+
24+
- **"Configuration file not found" never said where it looked.** The two things
25+
that actually happen are a typo'd filename and the wrong working directory,
26+
and the second is the common one. It now names the config path, the artifact
27+
path, and that `OS_ARTIFACT_PATH` is unset — and still refuses rather than
28+
inventing a zero-object platform, pointing at `objectstack start` for a boot
29+
that is app-less on purpose.
30+
31+
- **That refusal was being truncated.** `this.exit(1)` unwinds to oclif's
32+
`process.exit`, which does not drain a piped stdout, so a diagnostic split
33+
across several `console.log` calls loses its tail — measured: only the first
34+
two lines of the new message survived a pipe, i.e. exactly the part that says
35+
where to look went missing. Both of `serve`'s pre-flight refusals now emit one
36+
write. Caught by the e2e added here, not by review.
37+
38+
Also corrects the plugin-ordering claims in `createStandaloneStack` and in the
39+
test that pinned them: the comment said the datasource plugin's array position
40+
"MUST precede ObjectQLPlugin: its start() connects the default driver", and the
41+
test asserted that index with the same rationale. The connect happens in
42+
`init()`, and the kernel resolves order from the dependency graph — which hoists
43+
ObjectQLPlugin ahead of the datasource plugin (measured: 6 slots earlier), the
44+
reverse of what the slot reads as. The test now pins the declared dependency
45+
that actually orders the two inits, which deleting the array position cannot
46+
break and deleting the declaration does. #4131 tracks making the AppPlugin end
47+
of that contract enforced rather than conventional.

content/docs/deployment/cli.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,16 @@ os start
323323
**Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_PATH` > `<cwd>/dist/objectstack.json` > `<home>/dist/objectstack.json` > auto-compile from `objectstack.config.ts` (when present) > empty kernel.
324324
**Resolution priority (database):** `--database` > `OS_DATABASE_URL` > `DATABASE_URL` (legacy) > `file:<home>/data/objectstack.db`.
325325

326+
<Callout type="warn">
327+
A **named** artifact (`--artifact` or `OS_ARTIFACT_PATH`) does *not* participate
328+
in that fall-through: it is used as given, and a local path that does not exist
329+
**fails the boot** — naming the path and which of the two named it — instead of
330+
quietly continuing down the list. You asked for a specific artifact, so booting
331+
something else (or an empty kernel) would hide the typo behind a running server.
332+
The fall-through applies to the **conventional** locations only. Remote
333+
(`http(s)://`) sources cannot be checked up front and are validated when fetched.
334+
</Callout>
335+
326336
**What it boots:**
327337
- Reads the artifact's `manifest`, `objects`, `views`, `flows`, …
328338
- Auto-registers the platform services declared in `requires: [...]` (e.g. `ai`, `automation`, `analytics`, `auth`, `ui`). Declaring a **service** capability (`automation`, `analytics`, `ai`, `audit`, …) is a *requirement*: if its provider package isn't installed, boot **fails fast** with a clear error instead of silently starting without a capability you asked for. (`auth` and `ui` are tier-gated with their own opt-in rules — `auth`'s secret-gated skip is described below.)

packages/cli/src/commands/serve.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -474,11 +474,19 @@ export default class Serve extends Command {
474474
// Ignore — fall through and try the requested port.
475475
}
476476
} else if (!(await isPortAvailable(requestedPort))) {
477-
console.log('');
478-
printError(`Port ${requestedPort} is already in use.`);
479-
console.log(chalk.dim(' ObjectStack does not auto-select a different port in production mode:'));
480-
console.log(chalk.dim(' a drifted port silently breaks reverse-proxy, OAuth callback, and CORS config.'));
481-
console.log(chalk.dim(' Free the port, or pick another via PORT=<port> (or --port <port>).'));
477+
// One write, for the reason spelled out at the "Nothing to serve" exit
478+
// below: `this.exit(1)` reaches `process.exit` without draining a piped
479+
// stdout, so a multi-call diagnostic loses its tail. Same defect, same
480+
// shape — this one is fixed by construction (the e2e that measured the
481+
// truncation drives the other exit; reaching this one needs a busy port in
482+
// production mode).
483+
console.log(
484+
'\n'
485+
+ chalk.red(` ✗ Port ${requestedPort} is already in use.\n`)
486+
+ chalk.dim(' ObjectStack does not auto-select a different port in production mode:\n')
487+
+ chalk.dim(' a drifted port silently breaks reverse-proxy, OAuth callback, and CORS config.\n')
488+
+ chalk.dim(' Free the port, or pick another via PORT=<port> (or --port <port>).'),
489+
);
482490
this.exit(1);
483491
}
484492

@@ -516,10 +524,35 @@ export default class Serve extends Command {
516524
if (process.env.OS_BOOT_EMPTY === '1') {
517525
useEmptyBoot = true;
518526
} else {
519-
printError(`Configuration file not found: ${absolutePath}`);
520-
console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project,'));
521-
console.log(chalk.dim(' `objectstack start` to boot an empty kernel against your marketplace,'));
522-
console.log(chalk.dim(' or run `objectstack build` first / set OS_ARTIFACT_PATH.'));
527+
// Say WHERE it looked. "Not found" alone cannot distinguish the two
528+
// things that actually happen — a typo'd filename and the wrong cwd
529+
// (running from a monorepo root instead of the app folder) — and the
530+
// second is the common one, which listing the searched paths makes
531+
// self-evident. This stays an ERROR rather than degrading into an
532+
// empty boot: `os serve` was told to load something, and inventing a
533+
// zero-object platform instead would hide the mistake behind a
534+
// running server. Booting with no app at all is a real, supported
535+
// thing (`os serve` on a config with no metadata, or `os start`) —
536+
// but it is a stated intent, not a guess made on the user's behalf.
537+
// ONE write, deliberately. `this.exit(1)` unwinds to oclif's
538+
// `process.exit`, which does NOT wait for a piped stdout to drain — so
539+
// a diagnostic split across several `console.log` calls gets
540+
// truncated mid-message, and the reader loses exactly the part that
541+
// says where to look. (Measured: as separate calls, only the first two
542+
// lines survived a pipe.) An error whose tail can vanish is the #4012
543+
// shape all over again; assembling it into a single write keeps it
544+
// inside one pipe-buffer flush.
545+
console.log(
546+
chalk.red(' ✗ Nothing to serve — no config and no compiled artifact.') + '\n'
547+
+ chalk.dim(` Looked for a config at: ${absolutePath}\n`)
548+
+ chalk.dim(` Looked for an artifact at: ${path.resolve(process.cwd(), 'dist/objectstack.json')}\n`)
549+
+ chalk.dim(' OS_ARTIFACT_PATH is not set.\n')
550+
+ '\n'
551+
+ chalk.dim(' Hint: `objectstack init` scaffolds a new project;\n')
552+
+ chalk.dim(' `objectstack start` boots an app-less kernel against your marketplace;\n')
553+
+ chalk.dim(' `objectstack build` (or OS_ARTIFACT_PATH) supplies a compiled artifact.\n')
554+
+ chalk.dim(' Already have a project? Check your working directory.'),
555+
);
523556
this.exit(1);
524557
}
525558
}

packages/cli/test/helpers/serve-process.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ export interface ServeRun {
4040
export function runServe(
4141
cwd: string,
4242
args: string[],
43-
opts: { waitFor: RegExp; timeoutMs?: number; config?: string; env?: Record<string, string> },
43+
// `env` values may be `undefined` to UNSET a variable for the child (Node
44+
// omits undefined entries), which is how a test asserts behaviour that depends
45+
// on a variable being absent — `''` would not do it, since the resolvers this
46+
// exercises use `??` and an empty string is not nullish.
47+
opts: { waitFor: RegExp; timeoutMs?: number; config?: string; env?: Record<string, string | undefined> },
4448
): Promise<ServeRun> {
4549
return new Promise((resolveRun, rejectRun) => {
4650
const child = spawn(TSX, [CLI, 'serve', opts.config ?? 'objectstack.config.ts', ...args], {

packages/cli/test/serve-no-artifact.e2e.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,48 @@ describe('os serve — boots without a compiled artifact (#4085)', () => {
155155
240_000,
156156
);
157157

158+
// The other side of the same principle. "The platform boots with no app" is a
159+
// statement about CAPABILITY, and it does not license guessing that a missing
160+
// input meant "boot empty". `os serve` was told to load something; the two
161+
// things that actually happen here are a typo'd filename and the wrong working
162+
// directory, and inventing a zero-object platform would hide both behind a
163+
// running server — the exact failure class #4085 was. So it errors, and it says
164+
// where it looked, which is what makes "wrong cwd" self-evident.
165+
it(
166+
'refuses, and names where it looked, when there is no config and no artifact',
167+
async () => {
168+
const emptyDir = mkdtempSync(join(tmpdir(), 'os-nothing-to-serve-'));
169+
try {
170+
const { stdout, stderr } = await runServe(emptyDir, ['--port', randomPort()], {
171+
// It never boots, so wait on something that can only appear on the way
172+
// down; the harness resolves on exit regardless.
173+
waitFor: /Nothing to serve/,
174+
timeoutMs: 120_000,
175+
// An inherited OS_ARTIFACT_PATH would send this down the
176+
// artifact-fallback branch instead. Unset it for the child.
177+
env: { OS_ARTIFACT_PATH: undefined, OS_BOOT_EMPTY: undefined },
178+
});
179+
const out = stdout + stderr;
180+
const seen = `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`;
181+
182+
expect(out, `did not refuse${seen}`).toContain('Nothing to serve');
183+
// Both searched locations, by absolute path.
184+
expect(out, `config location not named${seen}`).toContain(
185+
join(emptyDir, 'objectstack.config.ts'),
186+
);
187+
expect(out, `artifact location not named${seen}`).toContain('dist/objectstack.json');
188+
// And it points at the command that DOES boot an app-less platform,
189+
// instead of silently becoming it.
190+
expect(out).toContain('objectstack start');
191+
// It must not have booted.
192+
expect(out).not.toContain('Server is ready');
193+
} finally {
194+
rmSync(emptyDir, { recursive: true, force: true });
195+
}
196+
},
197+
120_000,
198+
);
199+
158200
it(
159201
'serves on, and says why, when the config carries an app it cannot register',
160202
async () => {

packages/runtime/src/default-host.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,40 @@ export async function createDefaultHostConfig(
9292
);
9393
}
9494

95+
// A NAMED artifact that is not there is a broken instruction, and this is
96+
// the boot with no `objectstack.config.ts` — the artifact IS the whole
97+
// deployment, so there is nothing else to serve. Fail loudly.
98+
//
99+
// The distinction that matters is **named vs conventional**, not the errno.
100+
// `<cwd>/dist/objectstack.json` missing means "not compiled yet", which is a
101+
// healthy state a development platform must boot from (#4085) — and
102+
// `resolveDefaultArtifactPath` already returns `undefined` for it, so it
103+
// never reaches here. But `OS_ARTIFACT_PATH` / `{ artifactPath }` are
104+
// returned WITHOUT an existence check (validated lazily by the loader), and
105+
// since #4110 made an absent artifact non-fatal all the way down —
106+
// `loadArtifactBundle` logs and returns null, `MetadataPlugin` starts
107+
// empty — that laziness turned a typo'd path into a silent empty boot:
108+
// `OS_ARTIFACT_PATH=/nope os serve` printed "booting from artifact", then
109+
// "Server is ready", and named the missing path NOWHERE in its output
110+
// (serve's boot-quiet window drops the loader's calm line). Checked here so
111+
// the fix lands where the intent is known, leaving the loader's tolerance
112+
// intact for the config-boot path that needs it.
113+
const namedBy = standaloneOpts.artifactPath
114+
? '`artifactPath`'
115+
: (process.env.OS_ARTIFACT_PATH ? 'OS_ARTIFACT_PATH' : null);
116+
if (
117+
namedBy
118+
&& resolvedArtifact
119+
&& !isHttpUrl(resolvedArtifact)
120+
&& !existsSync(resolvedArtifact)
121+
) {
122+
throw new Error(
123+
`[createDefaultHostConfig] The artifact named by ${namedBy} does not exist: `
124+
+ `"${resolvedArtifact}". Run 'os compile' to build it, correct the path, `
125+
+ `or unset ${namedBy} to boot from <cwd>/dist/objectstack.json.`,
126+
);
127+
}
128+
95129
// Empty-boot path: synthesize a minimal artifact stub inside the
96130
// ObjectStack home directory so MetadataPlugin has a real file to
97131
// read (and to watch for marketplace installs that land later).

0 commit comments

Comments
 (0)