Skip to content

Commit 0ec4268

Browse files
fix(cli): os migrate reads the project .env files as os serve does, and names its database source (#22581) (#22644)
Fixes #22581 Clause-②: no `os migrate` reads the environment files the serving commands already read, with the process environment keeping precedence, and says which database it opened and why. No flag, authorable key, export or error code is added or removed; the `--json` documents gain one output field, `databaseSource`. ## What changes - **The load.** `bootSchemaStack` (`packages/cli/src/utils/schema-migrate.ts`) loads the project's `.env*` files before anything resolves a database or a secret. - `loadProjectEnvFiles(projectRoot, { dev })` is `dotenvFlow.config({ node_env, path, silent: true })`, the call `serve` / `start` / `dev` make. - It uses `os serve`'s mode rule: `development` with `--dev`, else `doctorNodeEnv()` (`NODE_ENV`, else `production`). - `dev` is set only when the boot composes as `os serve --dev`, through `serveFlags.dev`. Today that is `os migrate security-catalog-overlays --dev`. - dotenv-flow never overrides a variable the process already has, so an exported `OS_DATABASE_URL` keeps its precedence over `.env`. - One load per project root **and mode** per process. A `--dev` load and a plain one of the same root never share a record. - **The probe.** The SQLite occupancy probe (`migrate-occupancy-gate.ts`) resolves the target **before** the boot, so it calls the same load first, with the boot's own `dev`. Without that, the probe checked the default file while the boot migrated the `.env` database (ablation 2 below). - **The source, printed.** `os migrate plan` and `apply` print the database with its source in both faces. - The human face reads `Database: from-dotenv.db (OS_DATABASE_URL from .env)`. - `--json` gains `databaseSource`: `{ kind: flag | process-env | env-file | config-datasource | default, variable?, file?, datasource? }`. - `apply` carries `database` and `databaseSource` on every payload after the boot, including `in_sync`, `nothing_safe_to_apply` and `confirmation_required`. - **The reader.** The source is named with the reader `os doctor` reports with: `readDotenvFiles` and `provenanceOf`, imported from `commands/doctor.ts`, which is not edited. There is no second parser, precedence rule or file list. - **Docs.** `content/docs/deployment/cli.mdx`, in the `os migrate` section, gains "Which database, and who named it". It covers the env files as `os serve` / `os start` read them, `--dev`'s development set, that an exported variable wins, and the `Database:` line and `databaseSource`. ## Readings: before (`main` 83b8b80) and after (this branch) Both measured with the built CLI on a neutral fixture: a config-only app with one object, whose `.env` sets `OS_DATABASE_URL=file:from-dotenv.db`. Names and sources only. | Reading | Before | After | |---|---|---| | (a) database reported by `os migrate plan --json` | `.objectstack/data/objectstack.db` (the default) | `from-dotenv.db`, `databaseSource: {kind: env-file, variable: OS_DATABASE_URL, file: .env}` | | (a) database opened by `os serve` (`OS_MIGRATE_AND_EXIT=1`) | `from-dotenv.db` | not re-run: `serve.ts` is not in this diff | | (b) `requires: ['auth']`, `OS_AUTH_SECRET` only in `.env`: does the plan compose the auth family? | no: note "Did not compose the auth family … no OS_AUTH_SECRET"; 34 managed tables; no `sys_account` | yes: note "Composed the auth family …"; 75 managed tables; `sys_account` pending | | (c) `OS_DATABASE_URL=file:from-shell.db` exported, with the same `.env` | `os serve` opens `from-shell.db`; plan reports `from-shell.db` | plan reports `from-shell.db`, `{kind: process-env, variable: OS_DATABASE_URL}` | | `--database-url file:from-flag.db` | not measured | `from-flag.db`, `{kind: flag}` | | no `.env` | not measured | `.objectstack/data/objectstack.db`, `{kind: default}` | | `.env` database held open by another process: `apply --json` | not measured on `main`; see ablation 2 | `database_busy`, `database: from-dotenv.db`, `signal: file_open` | | `.env.development` holds `OS_AUTH_SECRET` and `OS_DATABASE_URL`: `security-catalog-overlays --json --dev` | not measured | database `from-dev-env.db`; the auth gate's secret is the file's, not the development fallback (pin) | | the same, without `--dev` | not measured | the default database; `OS_AUTH_SECRET` unset (pin) | ## Mechanism hypotheses, measured - **H1 (one resolution point): partly falsified.** - Every `os migrate` subcommand (14) boots through `bootSchemaStack`. So do four other one-shot commands. - Eight migrate commands also resolve the target in the occupancy probe, **before** the boot: `plan`, `apply`, `meta`, `multi-value-columns`, `audit-metadata-bodies`, `files-to-references`, `security-catalog-overlays` and `summary-nulls`. So the probe calls the same memoized load. - Every `--database-url` flag binds `env: 'OS_DATABASE_URL'` at parse time. That binding runs before the load and sees only the shell, which is what keeps the shell's precedence over `.env`. - Loading in the boot changes four commands the direction does not name. `os meta resync`, `os secret rewrap`, `os secret orphans` and `os storage orphans` now read `.env` too: the same defect class, one boot recipe. - **H2: confirmed.** The loads are at `serve.ts:2149-2152`, `start.ts:168-171` and `dev.ts:330`. The one-shot load is the same call with `serve`'s rule, which is `start`'s rule unless the boot composes as `serve --dev`. - **H3: confirmed.** `readDotenvFiles`, `provenanceOf` and `doctorNodeEnv` are imported from `doctor.ts`, which is not edited. No gate fired on the import from a util into a command module; `data-migration-plugins.ts` already imports `commands/serve.js`. - **H4: confirmed.** See reading (b). `resolveAuthSecret` reads `process.env` at call time, so it needs no further change. ## File surface beyond the claim (accepted by the seat) - `packages/cli/src/utils/migrate-occupancy-gate.ts`: the probe's load, with the boot's `dev` (H1). - `packages/cli/src/utils/schema-migration-plugins.ts`, with its pin in `schema-migration-plugins.test.ts`. The no-secret note said the secret could be in "a .env file, which `os migrate` does not read". This change makes that false, so the note now names both homes of the secret. - `packages/cli/src/commands/migrate/security-catalog-overlays.ts`: - `--dev` reads the development `.env*` files, in both the probe and the boot; - the docblock and the `--dev` flag description say so. - `content/docs/deployment/cli.mdx`: the `os migrate` section only. - `.changeset/22506-migrate-composes-boot.md` is **not** changed. This PR's changeset supersedes, in the same release, the two phrasings of that note that this change makes false: "`os migrate` reads no `.env` file", and "exported" in its remedy. ## Tests (at 7f1f25f) - **Pins:** - `src/commands/migrate/plan.reads-env-files.integration.test.ts`, 6 pins: - a `.env` that names a database: plan and apply report it; - a secret kept in `.env` keeps the auth family in the plan; - control: an exported variable wins; - the occupancy probe checks the `.env` database; - `--dev` sees a secret and a database kept only in `.env.development`; - control: without `--dev`, it does not. - `src/utils/schema-migrate.database-source.test.ts`, 5 pins: - the env-rung variable is held equal to `resolveProjectDatabaseUrl` over all 64 combinations of the three variables, 31 of which answer on that rung; - every rung's name; - a `--dev` load and a plain one of one root never share a record. Both files: 11 passed. - **Typecheck:** `pnpm --filter @objectstack/cli typecheck` exits 0 (tsc, then `check:test-typecheck` OK). - **CLI unit layer** (`vitest run --project unit`): 278 of 278 files, 4110 tests passed. - **CLI integration**, the lane's filters (35 files): 326 passed, 2 skipped, 0 failed. - **Derived gates:** `dispatch-gates --commands` with no paths gives 94 commands. The docs families joined with `cli.mdx`. Reconciled with `--ran`; every one exits 0, `check-empty-changeset --base origin/main` included. `check:type-check-debt` and `check:dual-build-cjs-loads` ran last. - **Lint, as a proven narrowing:** - eslint on the 11 changed files: 9 linted, 0 errors, 0 warnings. The changeset and `cli.mdx` are outside eslint's population ("no matching configuration"). - Untouched files cannot change verdict: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), and this diff changes none of the baselines it reads. - The full `pnpm lint` is declared to CI. - **Dogfood:** no dogfood test drives `os migrate`. `git grep` over `packages/qa/dogfood` for the boot or the command entry: 0 hits. Control in `packages/cli/src`: a hit. ## Ablation (one-shot, restored, proved by blob hash) 1. Remove the load (`dotenvFlow.config(...)` in `loadProjectEnvFiles`), at af43fc4. 3 of 4 pins go red: the plan reports `.objectstack/data/objectstack.db`, the auth family is not composed, and apply is not refused. The control stays green. 2. Remove only the probe's load, at af43fc4. Only the occupancy pin goes red: apply reaches `confirmation_required` on the `.env` database while another connection holds it. 3. Ignore `--dev` in the mode, at 7f1f25f. Two pins go red: the `--dev` pin (the default database, not `from-dev-env.db`) and the record-per-mode pin. The without-`--dev` control stays green. After each restore: blob equals HEAD, and `git diff HEAD` is empty. The pins import the subject from `src/` by relative path, so no `dist/` rebuild sat between mutation and run. ## Acceptance notes - Two modes of one project root in one process happen only when a test drives several commands in-process. A real run is one command, and that command's probe and boot are handed the same `dev`. The records are separate, and such a test restores the environment between the commands, as the pins do. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02d9f69 commit 0ec4268

11 files changed

Lines changed: 656 additions & 28 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate` reads the project's `.env` files as `os serve` does, and names the database it opened and who named it (#22581)
6+
7+
`os serve`, `os start` and `os dev` load the project's `.env*` files before they read any variable. `os migrate` read only the process environment. So a project keeping `OS_DATABASE_URL` in `.env` served one database and migrated another: `os migrate plan` reported `.objectstack/data/objectstack.db` while `os serve` opened the `.env` database. An `OS_AUTH_SECRET` kept in `.env` left the auth family (`sys_user`, `sys_account`, …) out of the plan.
8+
9+
Every command that boots the one-shot schema stack now loads the same files first, through the same `dotenv-flow` call, with `os serve`'s mode rule: `NODE_ENV`, else `production`, and `development` for `os migrate security-catalog-overlays --dev`, which composes as `os serve --dev`. That covers every `os migrate` subcommand, and also `os meta resync`, `os secret rewrap`, `os secret orphans` and `os storage orphans`. A variable exported in the shell still wins over `.env`, as it does for `os serve`. The SQLite occupancy check that `os migrate apply` and the other write commands run before booting reads the same files, so it checks the database the command is about to migrate.
10+
11+
This supersedes two phrasings in this release's `os migrate plan` / `apply` auth-family entry: `os migrate` now does read the project's `.env` files, and the deployment's `OS_AUTH_SECRET` composes the auth family whether it is exported or kept in `.env`.
12+
13+
`os migrate plan` and `os migrate apply` print the database with its source: `--database-url`, a variable from the process environment, a variable from a named `.env` file, the config's default datasource, or the default. In `--json` the source is the new `databaseSource` field (`{ kind: 'flag' | 'process-env' | 'env-file' | 'config-datasource' | 'default', variable?, file?, datasource? }`). `apply` now carries `database` and `databaseSource` on every payload after the boot, including `in_sync`, `nothing_safe_to_apply` and `confirmation_required`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,27 @@ os migrate plan --json # Machine-readable output
866866
os migrate unmapped-columns --object contact --json # A retired field's stored values, keyed by record id (read-only)
867867
```
868868
869+
#### Which database, and who named it
870+
871+
Every `os migrate` command reads the project's `.env*` files the way `os serve` and
872+
`os start` read them, before it resolves the database or an auth secret: dotenv-flow's
873+
cascade of `.env`, `.env.local`, `.env.<NODE_ENV>` and `.env.<NODE_ENV>.local`, with
874+
`NODE_ENV` defaulting to `production`. `os migrate security-catalog-overlays --dev`
875+
reads the `development` set, as `os serve --dev` does. A variable exported in the
876+
shell wins over the same variable in a `.env` file, and `--database-url` wins over
877+
both. So the database a migration targets is the one the deployment serves.
878+
879+
`os migrate plan` and `os migrate apply` say which database they opened and who named
880+
it:
881+
882+
```text
883+
ℹ Database: data/app.db (OS_DATABASE_URL from .env)
884+
```
885+
886+
Under `--json` the same answer is `database` plus `databaseSource`, whose `kind` is
887+
`flag`, `process-env`, `env-file` (with its `variable` and `file`),
888+
`config-datasource` (with its `datasource`) or `default`.
889+
869890
#### Nothing is applied before you confirm
870891
871892
Both commands boot your app to read its metadata. That boot writes no row and no

‎packages/cli/src/commands/migrate/apply.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '../../utils/format.js';
1818
import {
1919
bootSchemaStack,
20+
describeDatabaseSource,
2021
renderPlan,
2122
renderPendingSchemaWork,
2223
summarize,
@@ -198,14 +199,21 @@ export default class MigrateApply extends Command {
198199
return;
199200
}
200201

202+
// [#22581] The database this run reconciles and who named it, on every
203+
// payload below — the in-sync and refusal paths included, since "nothing
204+
// to apply" is only as good as the database it was read from.
205+
const target = { database: stack.dbLabel, databaseSource: stack.dbSource };
206+
201207
// What the object set was composed from (#12938) — printed BEFORE the
202208
// in-sync early return below, not with the plan. "Already in sync" over a
203209
// set that is a fraction of the target's tables is precisely the reading
204210
// this card exists to stop, so the account of what was composed has to
205-
// reach the operator on that path too.
211+
// reach the operator on that path too. [#22581] The database line rides
212+
// above it for the same reason.
206213
if (!flags.json) {
214+
printInfo(`Database: ${chalk.white(stack.dbLabel)} ${chalk.dim(`(${describeDatabaseSource(stack.dbSource)})`)}`);
207215
for (const note of stack.composition.notes) console.log(chalk.dim(` ${note}`));
208-
if (stack.composition.notes.length > 0) console.log('');
216+
console.log('');
209217
}
210218

211219
const drift = await stack.driver.detectManagedDrift();
@@ -234,7 +242,7 @@ export default class MigrateApply extends Command {
234242
if (drift.length === 0 && pending.length === 0) {
235243
if (flags.json) {
236244
await emitJson(
237-
{ applied: [], skipped: [], created: [], message: unexamined > 0 ? 'in_sync_partial' : 'in_sync', ...compositionPayload },
245+
{ ...target, applied: [], skipped: [], created: [], message: unexamined > 0 ? 'in_sync_partial' : 'in_sync', ...compositionPayload },
238246
0,
239247
{ compact: true },
240248
);
@@ -258,8 +266,6 @@ export default class MigrateApply extends Command {
258266
const deferred = drift.filter((d) => d.category === 'destructive' && !allowDestructive);
259267

260268
if (!flags.json) {
261-
printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
262-
console.log('');
263269
await renderPendingSchemaWork(pending);
264270
renderPlan(drift);
265271
if (pending.length > 0) printInfo(await summarizePendingSchemaWork(pending));
@@ -312,7 +318,7 @@ export default class MigrateApply extends Command {
312318
// consumer must be able to read "no DDL" off the document too, not
313319
// only off stderr.
314320
await emitJson({
315-
database: stack.dbLabel,
321+
...target,
316322
created: [],
317323
applied: [],
318324
skipped: drift,
@@ -365,7 +371,7 @@ export default class MigrateApply extends Command {
365371
const detail = e?.message ?? String(e);
366372
if (flags.json) {
367373
await emitJson({
368-
database: stack.dbLabel, created: [], applied: [], skipped: drift, pending,
374+
...target, created: [], applied: [], skipped: drift, pending,
369375
message: 'refused_account_issuer_preflight_unreadable', detail,
370376
}, 0, { compact: true });
371377
return;
@@ -379,7 +385,7 @@ export default class MigrateApply extends Command {
379385
if (!preflight.ok) {
380386
if (flags.json) {
381387
await emitJson({
382-
database: stack.dbLabel, created: [], applied: [], skipped: drift, pending,
388+
...target, created: [], applied: [], skipped: drift, pending,
383389
message: 'refused_account_issuer_collisions', preflight,
384390
}, 0, { compact: true });
385391
return;
@@ -404,15 +410,15 @@ export default class MigrateApply extends Command {
404410

405411
const totalIntended = intended.length + pending.length;
406412
if (totalIntended === 0) {
407-
if (flags.json) { await emitJson({ applied: [], skipped: deferred, created: [], message: 'nothing_safe_to_apply' }, 0, { compact: true }); return; }
413+
if (flags.json) { await emitJson({ ...target, applied: [], skipped: deferred, created: [], message: 'nothing_safe_to_apply' }, 0, { compact: true }); return; }
408414
printWarning('No changes to apply without --allow-destructive.');
409415
return;
410416
}
411417

412418
// Confirmation gate. Nothing above this line has touched the database.
413419
if (!flags.yes) {
414420
if (flags.json || !process.stdin.isTTY) {
415-
if (flags.json) { await emitJson({ applied: [], skipped: drift, pending, message: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); return; }
421+
if (flags.json) { await emitJson({ ...target, applied: [], skipped: drift, pending, message: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); return; }
416422
printWarning('Confirmation required. Re-run with --yes to apply, or use "os migrate plan" to preview.');
417423
return;
418424
}
@@ -429,7 +435,7 @@ export default class MigrateApply extends Command {
429435

430436
if (flags.json) {
431437
await emitJson({
432-
database: stack.dbLabel,
438+
...target,
433439
created,
434440
applied,
435441
skipped,
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
4+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5+
import { tmpdir } from 'node:os';
6+
import { dirname, join, resolve } from 'node:path';
7+
import { fileURLToPath } from 'node:url';
8+
import Database from 'better-sqlite3';
9+
import { resolveAuthSecret } from '@objectstack/core';
10+
import { isExitSignal } from '../../utils/format.js';
11+
import MigratePlan from './plan.js';
12+
import MigrateApply from './apply.js';
13+
import MigrateSecurityCatalogOverlays from './security-catalog-overlays.js';
14+
15+
// [#10126] Pay the first transform of the dist-resolved workspace deps the
16+
// commands reach through dynamic `import()`s at MODULE LOAD, not inside a case.
17+
import '@objectstack/runtime';
18+
import '@objectstack/objectql';
19+
import '@objectstack/plugin-auth';
20+
import '@objectstack/plugin-security';
21+
22+
/**
23+
* [#22581] `os migrate` reads the `.env*` files `os serve` / `start` / `dev`
24+
* read, with the process environment keeping its precedence, and says which
25+
* database it opened and who named it.
26+
*
27+
* Measured on `main` before the fix: a project whose `.env` set
28+
* `OS_DATABASE_URL` served that database and planned
29+
* `.objectstack/data/objectstack.db`; an `OS_AUTH_SECRET` kept in `.env` left
30+
* the auth family out of the plan.
31+
*
32+
* `--dev` (`os migrate security-catalog-overlays`, which composes as `os serve
33+
* --dev`) reads the development files, as `serve --dev` does.
34+
*
35+
* Driven in-process through each command's own `run()`, from inside the
36+
* fixture (`chdir`), with `NODE_ENV=production`: `bin/run-dev.js` pins
37+
* `NODE_ENV=development`, which is a development boot, and a development boot
38+
* composes the auth family on its fallback secret with or without `.env`.
39+
*
40+
* The load writes `.env` values into `process.env`, as it does in a real run;
41+
* `afterEach` puts every variable a fixture can set back, so no case reads the
42+
* one before it.
43+
*/
44+
45+
const HERE = dirname(fileURLToPath(import.meta.url));
46+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
47+
const CASE_TIMEOUT_MS = 240_000;
48+
const SECRET = 'os22581-fixture-secret-at-least-32-characters-long';
49+
50+
/** Every variable that would point a boot elsewhere, or that a fixture's `.env` sets. */
51+
const ENV_KEYS = [
52+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME',
53+
'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ARTIFACT_PATH', 'NODE_ENV',
54+
] as const;
55+
56+
type Invoke = (argv: string[]) => Promise<unknown>;
57+
const invoke = (command: { run: (argv: string[], opts: { root: string }) => Promise<unknown> }): Invoke =>
58+
(argv) => command.run(argv, { root: CLI_ROOT });
59+
60+
/** Run a command with stdout captured, its exits trapped, and its JSON document parsed. */
61+
async function runJson(command: Invoke, argv: string[]): Promise<{ payload: any; exitCode: number }> {
62+
const savedExit = process.exitCode;
63+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
64+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
65+
if (cb) cb();
66+
return true;
67+
}) as typeof process.stdout.write;
68+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
69+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
70+
vi.spyOn(console, 'log').mockImplementation(() => {});
71+
vi.spyOn(console, 'warn').mockImplementation(() => {});
72+
vi.spyOn(console, 'error').mockImplementation(() => {});
73+
let processExit: number | undefined;
74+
vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
75+
processExit = code ?? 0;
76+
throw new Error(`__PROCESS_EXIT__:${processExit}`);
77+
}) as never);
78+
let thrownExit: number | undefined;
79+
try {
80+
try {
81+
await command(argv);
82+
} catch (error) {
83+
const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__');
84+
if (!trapped && !isExitSignal(error)) throw error;
85+
if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit;
86+
}
87+
const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim();
88+
let payload: any;
89+
try {
90+
payload = JSON.parse(out);
91+
} catch {
92+
payload = { unparsed: out.slice(-400) };
93+
}
94+
return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 };
95+
} finally {
96+
process.exitCode = savedExit;
97+
vi.restoreAllMocks();
98+
}
99+
}
100+
101+
const dirs: string[] = [];
102+
const savedEnv: Record<string, string | undefined> = {};
103+
let savedCwd: string;
104+
105+
/** A config-only project with the given `.env` lines. */
106+
function project(dotenv: string[], requires: string[] = []): string {
107+
const dir = mkdtempSync(join(tmpdir(), 'os-22581-env-'));
108+
dirs.push(dir);
109+
mkdirSync(join(dir, 'dist'), { recursive: true });
110+
writeFileSync(
111+
join(dir, 'objectstack.config.ts'),
112+
[
113+
'export default {',
114+
" manifest: { id: 'com.example.os22581env', name: 'env', version: '0.0.0', type: 'app' },",
115+
` requires: ${JSON.stringify(requires)},`,
116+
" objects: [{ name: 'os22581_env_thing', fields: { title: { type: 'text' } } }],",
117+
'};',
118+
'',
119+
].join('\n'),
120+
);
121+
writeFileSync(join(dir, '.env'), `${dotenv.join('\n')}\n`);
122+
return dir;
123+
}
124+
125+
/** Enter the fixture as the operator's shell would: this environment, that cwd. */
126+
function enter(dir: string, exported: Record<string, string> = {}): void {
127+
process.env.NODE_ENV = 'production';
128+
process.env.OS_TELEMETRY_DB = '0';
129+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
130+
Object.assign(process.env, exported);
131+
process.chdir(dir);
132+
}
133+
134+
beforeAll(() => {
135+
savedCwd = process.cwd();
136+
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
137+
});
138+
139+
beforeEach(() => {
140+
for (const key of ENV_KEYS) delete process.env[key];
141+
});
142+
143+
afterEach(() => {
144+
process.chdir(savedCwd);
145+
for (const key of ENV_KEYS) {
146+
const value = savedEnv[key];
147+
if (value === undefined) delete process.env[key];
148+
else process.env[key] = value;
149+
}
150+
});
151+
152+
afterAll(() => {
153+
for (const d of dirs) {
154+
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
155+
}
156+
});
157+
158+
describe('os migrate reads the .env files the serving commands read (#22581)', () => {
159+
it('a .env naming a database: plan and apply report that database, and that .env named it', async () => {
160+
const dir = project(['OS_DATABASE_URL=file:from-dotenv.db']);
161+
enter(dir);
162+
const expectedSource = { kind: 'env-file', variable: 'OS_DATABASE_URL', file: '.env' };
163+
164+
const plan = await runJson(invoke(MigratePlan), ['--json']);
165+
expect(plan.exitCode, JSON.stringify(plan.payload).slice(0, 400)).toBe(0);
166+
expect(plan.payload.database).toBe('from-dotenv.db');
167+
expect(plan.payload.databaseSource).toEqual(expectedSource);
168+
169+
// No --yes: apply stops at its confirmation gate, after naming its target.
170+
const apply = await runJson(invoke(MigrateApply), ['--json']);
171+
expect(apply.payload.message, JSON.stringify(apply.payload).slice(0, 400)).toBe('confirmation_required');
172+
expect(apply.payload.database).toBe('from-dotenv.db');
173+
expect(apply.payload.databaseSource).toEqual(expectedSource);
174+
// …and neither boot opened the default database the serving boot never would.
175+
expect(existsSync(join(dir, '.objectstack', 'data', 'objectstack.db'))).toBe(false);
176+
}, CASE_TIMEOUT_MS);
177+
178+
it('an OS_AUTH_SECRET kept in .env keeps the auth family in the plan', async () => {
179+
const dir = project([`OS_AUTH_SECRET=${SECRET}`], ['auth']);
180+
enter(dir);
181+
182+
const plan = await runJson(invoke(MigratePlan), ['--json']);
183+
expect(plan.exitCode, JSON.stringify(plan.payload).slice(0, 400)).toBe(0);
184+
expect((plan.payload.composition?.notes ?? []).join(' ')).toContain('Composed the auth family');
185+
expect((plan.payload.pending ?? []).map((p: any) => p.table)).toContain('sys_account');
186+
expect(plan.payload.databaseSource).toEqual({ kind: 'default' });
187+
}, CASE_TIMEOUT_MS);
188+
189+
it('control: an exported OS_DATABASE_URL wins over .env, and is named as exported', async () => {
190+
const dir = project(['OS_DATABASE_URL=file:from-dotenv.db']);
191+
enter(dir, { OS_DATABASE_URL: 'file:from-shell.db' });
192+
193+
const plan = await runJson(invoke(MigratePlan), ['--json']);
194+
expect(plan.exitCode, JSON.stringify(plan.payload).slice(0, 400)).toBe(0);
195+
expect(plan.payload.database).toBe('from-shell.db');
196+
expect(plan.payload.databaseSource).toEqual({ kind: 'process-env', variable: 'OS_DATABASE_URL' });
197+
}, CASE_TIMEOUT_MS);
198+
199+
it('apply\'s occupancy gate probes the database .env names, not the default', async () => {
200+
const dir = project(['OS_DATABASE_URL=file:from-dotenv.db']);
201+
const file = join(dir, 'from-dotenv.db');
202+
const seed = new Database(file);
203+
seed.pragma('journal_mode = wal');
204+
seed.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)');
205+
seed.close();
206+
// A server between requests: attached, idle — what `os serve` leaves open.
207+
const held = new Database(file);
208+
held.prepare('SELECT * FROM t').all();
209+
try {
210+
enter(dir);
211+
const apply = await runJson(invoke(MigrateApply), ['--json']);
212+
expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 400)).toBe('database_busy');
213+
expect(apply.payload.database).toBe('from-dotenv.db');
214+
expect(apply.exitCode).toBe(1);
215+
} finally {
216+
held.close();
217+
}
218+
}, CASE_TIMEOUT_MS);
219+
220+
it('--dev reads the development .env files, as os serve --dev does: a secret only in .env.development is seen', async () => {
221+
const dir = project([]);
222+
writeFileSync(join(dir, '.env.development'), [`OS_AUTH_SECRET=${SECRET}`, 'OS_DATABASE_URL=file:from-dev-env.db', ''].join('\n'));
223+
enter(dir);
224+
225+
const run = await runJson(invoke(MigrateSecurityCatalogOverlays), ['--json', '--dev']);
226+
expect(run.payload.error, JSON.stringify(run.payload).slice(0, 400)).toBeUndefined();
227+
expect(run.payload.database).toBe('from-dev-env.db');
228+
// The secret the auth gate reads on this development boot is the file's,
229+
// not the development fallback it would read with no secret at all.
230+
expect(resolveAuthSecret({ isDev: true })).toBe(SECRET);
231+
}, CASE_TIMEOUT_MS);
232+
233+
it('control: without --dev, the development .env files are not read', async () => {
234+
const dir = project([]);
235+
writeFileSync(join(dir, '.env.development'), [`OS_AUTH_SECRET=${SECRET}`, 'OS_DATABASE_URL=file:from-dev-env.db', ''].join('\n'));
236+
enter(dir);
237+
238+
const run = await runJson(invoke(MigrateSecurityCatalogOverlays), ['--json']);
239+
expect(run.payload.error, JSON.stringify(run.payload).slice(0, 400)).toBeUndefined();
240+
expect(run.payload.database).toBe(join(dir, '.objectstack', 'data', 'objectstack.db'));
241+
expect(process.env.OS_AUTH_SECRET).toBeUndefined();
242+
}, CASE_TIMEOUT_MS);
243+
});

0 commit comments

Comments
 (0)