Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/serve-named-artifact-and-ordering-truth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@objectstack/cli': patch
'@objectstack/runtime': patch
---

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)

Three corrections, all from the same principle: a platform may boot with no
application (#4085), and that says nothing about how a MISSING NAMED INPUT
should be read.

- **A named-but-missing artifact boots empty and silently.** #4110 made an
absent artifact non-fatal all the way down — right for the conventional
`<cwd>/dist/objectstack.json`, which is just "not compiled yet". But
`OS_ARTIFACT_PATH` / `{ artifactPath }` skip the existence check by design, so
the tolerance reached them too: `OS_ARTIFACT_PATH=/nope os serve` printed
"booting from artifact", reached `Server is ready`, and named the missing path
NOWHERE in its output (serve's boot-quiet window drops the loader's calm
line). `createDefaultHostConfig` — the boot with no config, where the artifact
IS the deployment — now rejects a named local artifact that does not exist,
naming both the path and which source named it. The loader keeps its
tolerance, so the config-boot path #4085 fixed is untouched.

- **"Configuration file not found" never said where it looked.** The two things
that actually happen are a typo'd filename and the wrong working directory,
and the second is the common one. It now names the config path, the artifact
path, and that `OS_ARTIFACT_PATH` is unset — and still refuses rather than
inventing a zero-object platform, pointing at `objectstack start` for a boot
that is app-less on purpose.

- **That refusal was being truncated.** `this.exit(1)` unwinds to oclif's
`process.exit`, which does not drain a piped stdout, so a diagnostic split
across several `console.log` calls loses its tail — measured: only the first
two lines of the new message survived a pipe, i.e. exactly the part that says
where to look went missing. Both of `serve`'s pre-flight refusals now emit one
write. Caught by the e2e added here, not by review.

Also corrects the plugin-ordering claims in `createStandaloneStack` and in the
test that pinned them: the comment said the datasource plugin's array position
"MUST precede ObjectQLPlugin: its start() connects the default driver", and the
test asserted that index with the same rationale. The connect happens in
`init()`, and the kernel resolves order from the dependency graph — which hoists
ObjectQLPlugin ahead of the datasource plugin (measured: 6 slots earlier), the
reverse of what the slot reads as. The test now pins the declared dependency
that actually orders the two inits, which deleting the array position cannot
break and deleting the declaration does. #4131 tracks making the AppPlugin end
of that contract enforced rather than conventional.
10 changes: 10 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,16 @@ os start
**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.
**Resolution priority (database):** `--database` > `OS_DATABASE_URL` > `DATABASE_URL` (legacy) > `file:<home>/data/objectstack.db`.

<Callout type="warn">
A **named** artifact (`--artifact` or `OS_ARTIFACT_PATH`) does *not* participate
in that fall-through: it is used as given, and a local path that does not exist
**fails the boot** — naming the path and which of the two named it — instead of
quietly continuing down the list. You asked for a specific artifact, so booting
something else (or an empty kernel) would hide the typo behind a running server.
The fall-through applies to the **conventional** locations only. Remote
(`http(s)://`) sources cannot be checked up front and are validated when fetched.
</Callout>

**What it boots:**
- Reads the artifact's `manifest`, `objects`, `views`, `flows`, …
- 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.)
Expand Down
51 changes: 42 additions & 9 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,11 +474,19 @@ export default class Serve extends Command {
// Ignore — fall through and try the requested port.
}
} else if (!(await isPortAvailable(requestedPort))) {
console.log('');
printError(`Port ${requestedPort} is already in use.`);
console.log(chalk.dim(' ObjectStack does not auto-select a different port in production mode:'));
console.log(chalk.dim(' a drifted port silently breaks reverse-proxy, OAuth callback, and CORS config.'));
console.log(chalk.dim(' Free the port, or pick another via PORT=<port> (or --port <port>).'));
// One write, for the reason spelled out at the "Nothing to serve" exit
// below: `this.exit(1)` reaches `process.exit` without draining a piped
// stdout, so a multi-call diagnostic loses its tail. Same defect, same
// shape — this one is fixed by construction (the e2e that measured the
// truncation drives the other exit; reaching this one needs a busy port in
// production mode).
console.log(
'\n'
+ chalk.red(` ✗ Port ${requestedPort} is already in use.\n`)
+ chalk.dim(' ObjectStack does not auto-select a different port in production mode:\n')
+ chalk.dim(' a drifted port silently breaks reverse-proxy, OAuth callback, and CORS config.\n')
+ chalk.dim(' Free the port, or pick another via PORT=<port> (or --port <port>).'),
);
this.exit(1);
}

Expand Down Expand Up @@ -516,10 +524,35 @@ export default class Serve extends Command {
if (process.env.OS_BOOT_EMPTY === '1') {
useEmptyBoot = true;
} else {
printError(`Configuration file not found: ${absolutePath}`);
console.log(chalk.dim(' Hint: Run `objectstack init` to create a new project,'));
console.log(chalk.dim(' `objectstack start` to boot an empty kernel against your marketplace,'));
console.log(chalk.dim(' or run `objectstack build` first / set OS_ARTIFACT_PATH.'));
// Say WHERE it looked. "Not found" alone cannot distinguish the two
// things that actually happen — a typo'd filename and the wrong cwd
// (running from a monorepo root instead of the app folder) — and the
// second is the common one, which listing the searched paths makes
// self-evident. This stays an ERROR rather than degrading into an
// empty boot: `os serve` was told to load something, and inventing a
// zero-object platform instead would hide the mistake behind a
// running server. Booting with no app at all is a real, supported
// thing (`os serve` on a config with no metadata, or `os start`) —
// but it is a stated intent, not a guess made on the user's behalf.
// ONE write, deliberately. `this.exit(1)` unwinds to oclif's
// `process.exit`, which does NOT wait for a piped stdout to drain — so
// a diagnostic split across several `console.log` calls gets
// truncated mid-message, and the reader loses exactly the part that
// says where to look. (Measured: as separate calls, only the first two
// lines survived a pipe.) An error whose tail can vanish is the #4012
// shape all over again; assembling it into a single write keeps it
// inside one pipe-buffer flush.
console.log(
chalk.red(' ✗ Nothing to serve — no config and no compiled artifact.') + '\n'
+ chalk.dim(` Looked for a config at: ${absolutePath}\n`)
+ chalk.dim(` Looked for an artifact at: ${path.resolve(process.cwd(), 'dist/objectstack.json')}\n`)
+ chalk.dim(' OS_ARTIFACT_PATH is not set.\n')
+ '\n'
+ chalk.dim(' Hint: `objectstack init` scaffolds a new project;\n')
+ chalk.dim(' `objectstack start` boots an app-less kernel against your marketplace;\n')
+ chalk.dim(' `objectstack build` (or OS_ARTIFACT_PATH) supplies a compiled artifact.\n')
+ chalk.dim(' Already have a project? Check your working directory.'),
);
this.exit(1);
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/test/helpers/serve-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ export interface ServeRun {
export function runServe(
cwd: string,
args: string[],
opts: { waitFor: RegExp; timeoutMs?: number; config?: string; env?: Record<string, string> },
// `env` values may be `undefined` to UNSET a variable for the child (Node
// omits undefined entries), which is how a test asserts behaviour that depends
// on a variable being absent — `''` would not do it, since the resolvers this
// exercises use `??` and an empty string is not nullish.
opts: { waitFor: RegExp; timeoutMs?: number; config?: string; env?: Record<string, string | undefined> },
): Promise<ServeRun> {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(TSX, [CLI, 'serve', opts.config ?? 'objectstack.config.ts', ...args], {
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/test/serve-no-artifact.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,48 @@ describe('os serve — boots without a compiled artifact (#4085)', () => {
240_000,
);

// The other side of the same principle. "The platform boots with no app" is a
// statement about CAPABILITY, and it does not license guessing that a missing
// input meant "boot empty". `os serve` was told to load something; the two
// things that actually happen here are a typo'd filename and the wrong working
// directory, and inventing a zero-object platform would hide both behind a
// running server — the exact failure class #4085 was. So it errors, and it says
// where it looked, which is what makes "wrong cwd" self-evident.
it(
'refuses, and names where it looked, when there is no config and no artifact',
async () => {
const emptyDir = mkdtempSync(join(tmpdir(), 'os-nothing-to-serve-'));
try {
const { stdout, stderr } = await runServe(emptyDir, ['--port', randomPort()], {
// It never boots, so wait on something that can only appear on the way
// down; the harness resolves on exit regardless.
waitFor: /Nothing to serve/,
timeoutMs: 120_000,
// An inherited OS_ARTIFACT_PATH would send this down the
// artifact-fallback branch instead. Unset it for the child.
env: { OS_ARTIFACT_PATH: undefined, OS_BOOT_EMPTY: undefined },
});
const out = stdout + stderr;
const seen = `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`;

expect(out, `did not refuse${seen}`).toContain('Nothing to serve');
// Both searched locations, by absolute path.
expect(out, `config location not named${seen}`).toContain(
join(emptyDir, 'objectstack.config.ts'),
);
expect(out, `artifact location not named${seen}`).toContain('dist/objectstack.json');
// And it points at the command that DOES boot an app-less platform,
// instead of silently becoming it.
expect(out).toContain('objectstack start');
// It must not have booted.
expect(out).not.toContain('Server is ready');
} finally {
rmSync(emptyDir, { recursive: true, force: true });
}
},
120_000,
);

it(
'serves on, and says why, when the config carries an app it cannot register',
async () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/runtime/src/default-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,40 @@ export async function createDefaultHostConfig(
);
}

// A NAMED artifact that is not there is a broken instruction, and this is
// the boot with no `objectstack.config.ts` — the artifact IS the whole
// deployment, so there is nothing else to serve. Fail loudly.
//
// The distinction that matters is **named vs conventional**, not the errno.
// `<cwd>/dist/objectstack.json` missing means "not compiled yet", which is a
// healthy state a development platform must boot from (#4085) — and
// `resolveDefaultArtifactPath` already returns `undefined` for it, so it
// never reaches here. But `OS_ARTIFACT_PATH` / `{ artifactPath }` are
// returned WITHOUT an existence check (validated lazily by the loader), and
// since #4110 made an absent artifact non-fatal all the way down —
// `loadArtifactBundle` logs and returns null, `MetadataPlugin` starts
// empty — that laziness turned a typo'd path into a silent empty boot:
// `OS_ARTIFACT_PATH=/nope os serve` printed "booting from artifact", then
// "Server is ready", and named the missing path NOWHERE in its output
// (serve's boot-quiet window drops the loader's calm line). Checked here so
// the fix lands where the intent is known, leaving the loader's tolerance
// intact for the config-boot path that needs it.
const namedBy = standaloneOpts.artifactPath
? '`artifactPath`'
: (process.env.OS_ARTIFACT_PATH ? 'OS_ARTIFACT_PATH' : null);
if (
namedBy
&& resolvedArtifact
&& !isHttpUrl(resolvedArtifact)
&& !existsSync(resolvedArtifact)
) {
throw new Error(
`[createDefaultHostConfig] The artifact named by ${namedBy} does not exist: `
+ `"${resolvedArtifact}". Run 'os compile' to build it, correct the path, `
+ `or unset ${namedBy} to boot from <cwd>/dist/objectstack.json.`,
);
}

// Empty-boot path: synthesize a minimal artifact stub inside the
// ObjectStack home directory so MetadataPlugin has a real file to
// read (and to watch for marketplace installs that land later).
Expand Down
Loading
Loading