Skip to content

Commit c0fb794

Browse files
fix(cli,runtime): the ownership refusal names the database for os dev -d, and the driver and token given by flag (#31581)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X2kM3DQTy5XByDHKN47ZrVA)_ domain:server#5 · os-project-manager · session_01FEkQ5mbAyTfp7gbjowRA48 Fixes #31547 Clause-②: yes (widening) — `@objectstack/runtime`'s published `OrganizationOwnershipBootGuardOptions` gains two optional members, `databaseDriver` and `databaseAuthTokenFromFlag`, and the refusal's two ceremony commands set `OS_DATABASE_DRIVER` / `OS_DATABASE_AUTH_TOKEN` in front when `--database-driver` / `--database-auth-token` chose them; `os dev`'s `--dev` commands carry `--database-url` when `-d` chose the database. What `os dev`, `os start`, `os serve` and `os migrate organization-ownership` accept or refuse is unchanged. Metadata-protocol: none ## 维护者速读 - **改了什么**: `os dev -d URL` 因组织归属被拒时,`--dev` 两条仪式命令现在带 `--database-url <被拒的库>`,经 #31546 的同一内部通道与守卫选项传递;`os start` / `os dev` 用 `--database-driver`、`--database-auth-token` 启动时,命令前面加 `OS_DATABASE_DRIVER=…` 与 `OS_DATABASE_AUTH_TOKEN="${OS_DATABASE_AUTH_TOKEN:?…}"`。token 从不进入守卫、从不打印,未导出时 shell 直接停下。`os dev` 对非 SQLite 库自己打印的那行改为指向守卫给出的两条命令,不再写一条不带库名的。 - **为什么改**: 仪式只认 `--database-url` 和环境变量 `OS_DATABASE_DRIVER` / `OS_DATABASE_AUTH_TOKEN`;这两个 flag 只把值放进子进程环境,照抄命令会规划项目默认库、换驱动或缺 token (#31547,来自 #31546 的两条验收备注)。 - **风险与回滚**: 只改拒绝文案与父子进程间两个内部环境变量;接受/拒绝条件不变。不带这两个 flag、不用 `os dev -d` 时打印内容与 #31546 一致 (CONTROL 钉住)。回滚即 revert 本 PR。 - **先看**: `packages/runtime/src/organization-ownership-boot-guard.ts` 的 `connectionAssignments` / `authTokenNote`;`packages/cli/src/utils/internal-ceremony-database-channel.ts` 的 `writeCeremonyConnectionChannel`;`dev.ts` 写通道的两处调用。 - **两分钟验证**: `cd packages/runtime && npx vitest run src/organization-ownership-boot-guard.test.ts`,预期 30 个用例全过 (在 main 上 3 个红)。 - **需要你做的**: 无,席位落地。 ## What changes 1. **`os dev -d` names the database, through the same channel.** `dev.ts` now writes `OS_INTERNAL_CEREMONY_DATABASE_URL` with `ceremonyDatabaseUrl(resolvedDb, { homeFlag: undefined })`, the function `os start` calls, from the resolution the boot serves. `-d` (and `--fresh`'s throwaway file) is `source: 'explicit'`, so it is named; `$OS_DATABASE_URL`, a declared datasource and the default file are not, because the ceremony's ladder reaches them. The `serve` child already hands the channel to the guard's `databaseUrl`, so the `--dev` commands read `os migrate organization-ownership --dev --database-url file:A.db …`, and a credential URL is read from `"${OS_DATABASE_URL:?…}"` as #31546 does. No second resolver. 2. **`os dev`'s own line points at those commands.** For a database it does not upgrade in place (not a local SQLite file), and after a refused upgrade plan, `os dev` printed "Run `os migrate organization-ownership --dev` …", a second spelling with no database. It now says "Run the two commands the boot guard's refusal above names, first the plan (review it), then the apply, and start os dev again." The refusal stays the one place that spells the commands. 3. **The driver and the token reach the ceremony.** First, the flags: `os migrate organization-ownership` accepts `--database-url` and no `--database-driver` / `--database-auth-token`. It reads both from the environment (`resolveExplicitDriver` reads `OS_DATABASE_DRIVER`, `resolveDatabaseAuthToken` reads `OS_DATABASE_AUTH_TOKEN`), and that is exactly where `os start` / `os dev` put the two flags' values for their child. So no flag is added; see Acceptance notes for the four-axis reading. The printed commands now set them in front: - `OS_DATABASE_DRIVER=KIND` when `--database-driver` chose the driver (the value is from the flag's allowlist and is printed as one shell word); - `OS_DATABASE_AUTH_TOKEN="${OS_DATABASE_AUTH_TOKEN:?export OS_DATABASE_AUTH_TOKEN as the database auth token this server was started with}"` when `--database-auth-token` supplied the token, plus a sentence saying the token is not printed. POSIX `${VAR:?msg}` stops the shell when the variable is unset or empty, as #31546's credential rule does for the URL. - Order: driver, token, then #31546's `OS_AUTH_SECRET="$(cat FILE)"`, then the command, `--dev` and `--database-url`. 4. **The token never reaches the guard.** New channel variables, same ownership rule as #31546's (set when the flag was given, deleted otherwise): `OS_INTERNAL_CEREMONY_DATABASE_DRIVER` carries the driver, `OS_INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG` carries `1`, never the token. `writeCeremonyConnectionChannel` is called by both `start.ts` and `dev.ts`; `serve.ts` spreads `readCeremonyConnectionChannel` into the guard's options (`databaseDriver`, `databaseAuthTokenFromFlag`). A driver or token the operator exported is already in their shell and is not repeated. 5. Changeset `31547-ownership-refusal-names-dev-database-and-connection.md`: `@objectstack/cli` and `@objectstack/runtime` patch (ruling A). ## Tests - `packages/runtime/src/organization-ownership-boot-guard.test.ts`: - ⭐ a `--database-driver` driver is set in front of both commands in every refusal state, before the exported secret, and before `--dev` on a development boot; - ⭐ a token from `--database-auth-token` is read as `${OS_DATABASE_AUTH_TOKEN:?…}` in both commands, with the note, alone or beside a driver; - ⭐ the printed plan command runs as written through `sh` with `os` stubbed: with the token exported, the stub sees the driver, the token and `--dev --database-url file:A.db`; with it unset, the shell exits non-zero before `os` runs and says what to export; - CONTROL: with neither option, the commands are as #31488 printed them (the #31488 cases are unchanged and green). - `packages/cli/src/utils/internal-ceremony-database-channel.test.ts`: ⭐ the driver is carried by value and the token only as `1` (the token's text appears nowhere in the env); the parent owns both variables; ⭐ `start.ts` and `dev.ts` both write both channels from their own resolution and flags. - `packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts`: ⭐ the non-SQLite line points at the refusal's commands and no longer spells a bare `--dev` command; the refused-plan line likewise (an edited assertion). - `packages/cli/test/dev-organization-ownership-stale-column.integration.test.ts` (built CLI, the existing `os dev --no-watch -d file:A.db` run, no new spawn): ⭐ the refusal the refused boot printed names `--dev --database-url file:A.db` in both commands. - `packages/cli/test/start-organization-ownership-refusal.integration.test.ts` (built CLI): a `-d file:A.db --database-driver sqlite --database-auth-token …` boot. ⭐ Both commands are `OS_DATABASE_DRIVER=sqlite OS_DATABASE_AUTH_TOKEN="${…:?…}" OS_AUTH_SECRET="$(cat H/auth-secret)" os migrate organization-ownership --database-url file:A.db …`, and the token is nowhere in the output. Run verbatim with the token exported, the plan is of `A.db`. ⭐ With it unset, the shell stops with the message and no plan file is written. It runs before the `-d` apply, so #31546's controls are judged on the same fixture as before. **Red on main** (`4d165acd5e` source, these test files, every package built from it): - guard: `Tests 3 failed | 27 passed (30)`; the three ⭐ #31547 cases fail and the CONTROL passes. - cli unit: `Tests 5 failed | 15 passed (20)` (`writeCeremonyConnectionChannel is not a function`; `start: expected … to match /writeCeremonyConnectionChannel…/`; both `os dev` lines `expected 'This development database is not a lo…' to contain 'the two commands the boot guard's re…'`). - end to end: `Tests 3 failed | 14 passed (17)`, all #31488 cases and CONTROLs green. `os dev -d file:A.db` printed `Run 'os migrate organization-ownership --dev --out PLAN' to write and review its plan, …`, and the plan run with the token unset wrote `plan-connection-unset.json`. The case "run verbatim with the token exported, the plan is of the refused database" is green on main too (main names `--database-url file:A.db` already), so it carries no ⭐. **Ablations** (`scripts/ablation-replace.mjs`, each restored with the blob equal to HEAD and `git diff HEAD` empty): - guard driver (`if (false && opts.databaseDriver) words +=`): `Tests 3 failed | 27 passed (30)`, `expected '|tok-31547|migrate organization-owner…' to be 'turso|tok-31547|migrate organization-…'`. - guard token (`if (false && opts.databaseAuthTokenFromFlag) words +=`): `Tests 2 failed | 28 passed (30)`; the run-as-written case reads `expected +0 not to be +0` (the shell no longer stops). - `os dev`'s hand-off, end to end through `dist/` (channel written as `undefined` behind `"ABL31547D".length < 0`; preflight: marker present in 1 built file): dev file `Tests 1 failed | 5 passed (6)`, the ⭐ #31547 case. - `os start`'s driver/token hand-off, end to end through `dist/` (`"ABL31547S".length > 0 ? { databaseDriver: undefined, databaseAuthToken: undefined } : …`; marker present in 1 built file): start file `Tests 2 failed | 9 passed (11)`, the two ⭐ cases. - Restore leg: cli rebuilt from HEAD, `ablation-dist-preflight --absent` for both markers: "marker absent from all 904 built files", "working tree clean against HEAD". ## Acceptance notes - **No `--database-driver` / `--database-auth-token` flag on the ceremony** (the order asked for this to be decided on the four axes). Business need, measured: the ceremony already reads `OS_DATABASE_DRIVER` and `OS_DATABASE_AUTH_TOKEN`, which is where both flags of `os start` / `os dev` land, so the printed commands can carry them without new surface. Long-term: one way in per input; a second spelling on the ceremony would need its own precedence rules against the variables. AI mistakes: a token passed as a flag sits in argv (`ps`, shell history) and invites printing it; the variable form lets the refusal never hold the value, and `${…:?…}` refuses loudly. Startup scope: no new capability is needed for the fix. Not added and not filed. - **A token flag without a libSQL database** (for example `--database-auth-token` beside a `postgres://` URL, where the stack ignores it) still makes the commands require `OS_DATABASE_AUTH_TOKEN`. The rule follows the flag, not the driver, so it stays one simple rule; the cost is one export the ceremony would not need. - **`os serve` run directly** has no database flags (it reads `OS_DATABASE_URL` / `OS_DATABASE_DRIVER` from the operator's environment), so the ceremony finds the same database. Nothing to do there. - The `os dev` end-to-end pin uses `-d file:A.db` (SQLite), because the existing run already boots it and a non-SQLite database needs a server in CI. The channel carries any URL the same way, and the non-SQLite text is pinned at unit level. ## Verification - Head `d2d02202`; origin/main `6ed533f6b6` merged before opening (it touched `packages/spec/src/api/error-code-ledger.zod.ts`, no file of this diff). The full `./packages/**` build ran after the merge. - Pins on the merged head, after that build: guard `Tests 30 passed (30)`, cli unit `Tests 20 passed (20)`, end to end `Tests 17 passed (17)`. - `pnpm typecheck` in `packages/runtime` and `packages/cli` (`tsc --noEmit` and `check:test-typecheck`): exit 0. eslint on the 11 touched `.ts` files: exit 0. - Gates from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 72 derived, 72 run, all exit 0 (`--ran` on the record with exit codes: 0 NOT-MEASURED, 0 UNRUN, a derived zero). One was red on the first pass and fixed in this branch: `check:test-source-alias` asked the edited `dev-organization-ownership-upgrade.test.ts` to load `@objectstack/runtime` and `@objectstack/service-datasource` at module top (the telemetry sibling's resolution reaches both through dynamic imports); after it, the gate, that test file (13 passed), `check:cross-package-test-inputs`, `check:cli-test-child-env`, `check:undeclared-dep-imports`, `check:type-check-coverage` and the cli typecheck all exit 0. Record: `/mnt/project-files/pm-orders/server/31547-gates-ran.txt`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the 11 touched `.ts` files: exit 0) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (`.changeset/31547-ownership-refusal-names-dev-database-and-connection.md`, `@objectstack/cli` + `@objectstack/runtime` patch) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the 维护者速读 block is the lane's required Chinese summary) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mbAyTfp7gbjowRA48 --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mbAyTfp7gbjowRA48)_ <!-- signed: domain:server#5 · os-project-manager · session_01FEkQ5mbAyTfp7gbjowRA48 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04127cc commit c0fb794

12 files changed

Lines changed: 378 additions & 21 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+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(cli,runtime): the organization-ownership refusal names the database for `os dev -d`, and the driver and token `os start` / `os dev` were given by flag
7+
8+
Clause-②: yes (widening) — `@objectstack/runtime`'s published `OrganizationOwnershipBootGuardOptions` gains two optional members, `databaseDriver` and `databaseAuthTokenFromFlag`, and the refusal's two ceremony commands set `OS_DATABASE_DRIVER` and `OS_DATABASE_AUTH_TOKEN` in front when `--database-driver` / `--database-auth-token` chose them; `os dev`'s `--dev` commands carry `--database-url` when `-d` chose the database. What `os dev`, `os start`, `os serve` and `os migrate organization-ownership` accept or refuse is unchanged.
9+
Metadata-protocol: none
10+
11+
`os dev -d URL` served the database `-d` chose, but when the organization-ownership boot guard refused it, the refusal printed `os migrate organization-ownership --dev --out PLAN` (and the `--apply` command) with no database, and `os dev`'s own line for a database it does not upgrade in place (anything that is not a local SQLite file) repeated that bare spelling. Run as printed, the ceremony planned the project's default database instead. `os dev` now hands its `serve` child the database it resolved through the same internal channel `os start` uses (#31488), so both commands read `os migrate organization-ownership --dev --database-url URL …`, a URL with a credential read from `"${OS_DATABASE_URL:?…}"` as before. `os dev`'s own line now points at the two commands the refusal names instead of spelling a bare one.
12+
13+
`os start` and `os dev` put `--database-driver` and `--database-auth-token` in the child's environment only (`OS_DATABASE_DRIVER`, `OS_DATABASE_AUTH_TOKEN`), and the ceremony reads those two variables (it has no flag for either), so the printed commands opened the database with another driver or without its token. They now set both in front of the command: `OS_DATABASE_DRIVER=turso OS_DATABASE_AUTH_TOKEN="${OS_DATABASE_AUTH_TOKEN:?…}" os migrate organization-ownership …`. The token is never printed and never reaches the guard: the command re-exports the shell's own `OS_DATABASE_AUTH_TOKEN`, the refusal says to export it, and the shell stops with that message when it is unset. A driver or token the operator exported themselves is in their shell already and is not repeated.

‎packages/cli/src/commands/dev.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ import { claudeMcpAddLine, MCP_ENDPOINT_PATH } from './mcp/connect.js';
4242
// #12620 and #12662 protected against.
4343
import { describePortSource, parseRequestedPort, formatInvalidPortNotice, portFlagEnv } from '../utils/port-contract.js';
4444
import { applyCryptoKeyFileChannel } from '../utils/internal-crypto-key-file-channel.js';
45+
import {
46+
ceremonyDatabaseUrl,
47+
writeCeremonyConnectionChannel,
48+
writeCeremonyDatabaseChannel,
49+
} from '../utils/internal-ceremony-database-channel.js';
4550
import { startDevDeployKey, type DeployKeyHandle } from '../utils/dev-deploy-key.js';
4651
// The ONE dev-TLS contract, shared with the `serve` child this command spawns
4752
// (#16804). ⛔ Nothing about certificates is declared in this file, and ⛔ no
@@ -841,6 +846,17 @@ export default class Dev extends Command {
841846
...(flags['auth-secret'] ? { OS_AUTH_SECRET: flags['auth-secret'] } : {}),
842847
};
843848
applyFreshHomeChannel(localEnv, freshHome);
849+
// [#31547] The database `-d` (or `--fresh`) chose is one the ceremony the
850+
// ownership refusal names does not find on its own, and the driver and
851+
// token the two flags above put here are not in the developer's shell:
852+
// the child names them in both `--dev` commands, read from the
853+
// resolution above (the one this boot serves), never re-resolved. Same
854+
// channel as `os start` (#31488). `os dev` has no `--home`.
855+
writeCeremonyDatabaseChannel(localEnv, ceremonyDatabaseUrl(resolvedDb, { homeFlag: undefined }));
856+
writeCeremonyConnectionChannel(localEnv, {
857+
databaseDriver: flags['database-driver'],
858+
databaseAuthToken: flags['database-auth-token'],
859+
});
844860
// The data key's file: the ONE answer `os start` takes too (#29934), so a
845861
// secret saved here reads back under `os start` over the same project —
846862
// and the file an earlier `os start` kept in the project is still read.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { mountsAiServicePlugin, resolveAiServiceLoad, resolveOptionalPluginLoad
1414
import { registerInstalledPlugins } from '../utils/installed-plugins-boot.js';
1515
import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js';
1616
import { readAuthSecretFileChannel } from '../utils/internal-auth-secret-channel.js';
17-
import { readCeremonyDatabaseChannel } from '../utils/internal-ceremony-database-channel.js';
17+
import { readCeremonyConnectionChannel, readCeremonyDatabaseChannel } from '../utils/internal-ceremony-database-channel.js';
1818
import { compileForDevBoot, devCompilesAtBoot } from '../utils/dev-compile-at-boot.js';
1919
import { makeDirectorySync } from '../utils/make-directory.js';
2020
// The precedence's last rung — whether the cwd config takes part — decided by
@@ -4218,12 +4218,14 @@ export default class Serve extends Command {
42184218
// persisted in its home names that file, so the ceremony runs with the
42194219
// secret this server composes its security plugin with. A boot on a
42204220
// database the ceremony would not find by default (`os start -d` or
4221-
// `--home`, #31488) names it, so the commands plan this database.
4221+
// `--home`, #31488; `os dev -d`, #31547) names it, so the commands plan
4222+
// this database, with the driver and token its parent's flags chose.
42224223
const ceremonyDatabase = readCeremonyDatabaseChannel(process.env);
42234224
await kernel.use(new OrganizationOwnershipBootGuardPlugin({
42244225
dev: isDev,
42254226
authSecretFile: readAuthSecretFileChannel(process.env),
42264227
...(ceremonyDatabase ? { databaseUrl: ceremonyDatabase } : {}),
4228+
...readCeremonyConnectionChannel(process.env),
42274229
}));
42284230
trackPlugin('OrganizationOwnershipBootGuard');
42294231
}

‎packages/cli/src/commands/start.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import {
2323
import { ServeRestartCoordinator } from '../utils/dev-restart.js';
2424
import { readEnvWithDeprecation } from '@objectstack/types';
2525
import { writeAuthSecretFileChannel } from '../utils/internal-auth-secret-channel.js';
26-
import { ceremonyDatabaseUrl, writeCeremonyDatabaseChannel } from '../utils/internal-ceremony-database-channel.js';
26+
import {
27+
ceremonyDatabaseUrl,
28+
writeCeremonyConnectionChannel,
29+
writeCeremonyDatabaseChannel,
30+
} from '../utils/internal-ceremony-database-channel.js';
2731
import { applyCryptoKeyFileChannel } from '../utils/internal-crypto-key-file-channel.js';
2832
import { resolveStartHome } from '../utils/start-home.js';
2933
// The ONE port contract, shared with `dev` and with the `serve` child this
@@ -507,6 +511,12 @@ export default class Start extends Command {
507511
// child names it in both commands, read from the resolution above — the
508512
// one this boot serves — never re-resolved.
509513
writeCeremonyDatabaseChannel(localEnv, ceremonyDatabaseUrl(resolvedDb, { homeFlag: flags.home }));
514+
// [#31547] The driver and the token the two flags above put in the child's
515+
// environment only: the commands set them in front, the token by reference.
516+
writeCeremonyConnectionChannel(localEnv, {
517+
databaseDriver: flags['database-driver'],
518+
databaseAuthToken: flags['database-auth-token'],
519+
});
510520
// NODE_ENV is only forced to production when the user has not set it.
511521
// Allows `NODE_ENV=development objectstack start` to work for debugging.
512522
//

‎packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1414
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
1515
import { tmpdir } from 'node:os';
1616
import { join } from 'node:path';
17+
// The telemetry sibling's resolution reaches both through dynamic imports: pay their load at collection.
18+
import '@objectstack/runtime';
19+
import '@objectstack/service-datasource';
1720
import {
1821
isOrganizationOwnershipRequiredMessage,
1922
ORGANIZATION_OWNERSHIP_REQUIRED_MESSAGE,
@@ -105,8 +108,10 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
105108
expect(result.lines[0]).toBe('Refused (sys_mystery): sys_mystery carries organization_id …');
106109
expect(result.lines.at(-1)).toContain('nothing was changed');
107110
expect(result.lines.at(-1)).toContain(BACKUP());
108-
// The ceremony is open (18.x step 6): the way on is running it by hand.
109-
expect(result.lines.at(-1)).toContain('os migrate organization-ownership --dev');
111+
// The ceremony is open (18.x step 6): the way on is running it by hand, by
112+
// the commands the guard's refusal printed (#31547: they name the database).
113+
expect(result.lines.at(-1)).toContain("the two commands the boot guard's refusal above names");
114+
expect(result.lines.at(-1)).not.toContain('os migrate organization-ownership --dev');
110115
expect(result.lines.at(-1)).not.toContain('17.x');
111116
});
112117

@@ -140,12 +145,16 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
140145
expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`);
141146
});
142147

143-
it('a database that is not a local SQLite file is not upgraded in place: the ceremony is named instead', () => {
148+
it('⭐ #31547: a database that is not a local SQLite file is not upgraded in place: the refusal\'s commands are pointed at, never respelled bare', () => {
144149
const result = upgradeDevelopmentDatabase(input(fakeRun({}), { sqliteFile: null, databaseUrl: 'postgres://db/app' }));
145150
expect(result.ok).toBe(false);
146151
expect(calls).toEqual([]);
147152
expect(existsSync(join(dir, 'backups'))).toBe(false);
148-
expect(result.lines[0]).toContain('os migrate organization-ownership --dev');
153+
// The guard's refusal names this database (`--database-url`), its driver and
154+
// token; a bare `os migrate organization-ownership --dev` would plan the
155+
// project's default database instead.
156+
expect(result.lines[0]).toContain("the two commands the boot guard's refusal above names");
157+
expect(result.lines[0]).not.toContain('os migrate organization-ownership --dev');
149158
expect(result.lines[0]).not.toContain('17.x');
150159
});
151160

‎packages/cli/src/utils/dev-organization-ownership-upgrade.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,15 @@ function discardedStamps(planFile: string): Array<{ object: string; rows: number
227227
*/
228228
export function upgradeDevelopmentDatabase(input: DevelopmentUpgradeInput): DevelopmentUpgradeResult {
229229
// The way on, when os dev does not upgrade the database itself: the ceremony,
230-
// run by hand (18.x step 6 opened it).
230+
// run by hand (18.x step 6 opened it). [#31547] By the two commands the
231+
// guard's refusal printed just above, never a second spelling of them here:
232+
// those name the database this boot opened (`-d`), with its driver and
233+
// token, and keep a credential out of the output; a bare
234+
// `os migrate organization-ownership --dev` plans the project's default
235+
// database instead.
231236
const manual =
232-
'Run `os migrate organization-ownership --dev` (review its plan), then the same command with --apply ' +
233-
'--plan-file FILE --backup-taken, and start os dev again.';
237+
"Run the two commands the boot guard's refusal above names, first the plan (review it), then the apply, " +
238+
'and start os dev again.';
234239
if (!input.sqliteFile) {
235240
return {
236241
ok: false,

‎packages/cli/src/utils/internal-ceremony-database-channel.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@
1919
* - the channel is owned by the parent: set when named, deleted otherwise.
2020
*/
2121

22+
import { readFileSync } from 'node:fs';
2223
import { describe, expect, it } from 'vitest';
2324
import {
25+
INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV,
26+
INTERNAL_CEREMONY_DATABASE_DRIVER_ENV,
2427
INTERNAL_CEREMONY_DATABASE_URL_ENV,
2528
ceremonyDatabaseUrl,
29+
readCeremonyConnectionChannel,
2630
readCeremonyDatabaseChannel,
31+
writeCeremonyConnectionChannel,
2732
writeCeremonyDatabaseChannel,
2833
} from './internal-ceremony-database-channel.js';
2934

@@ -63,3 +68,38 @@ describe('the ceremony-database channel to the serve child', () => {
6368
expect(readCeremonyDatabaseChannel({ [INTERNAL_CEREMONY_DATABASE_URL_ENV]: '' })).toBeUndefined();
6469
});
6570
});
71+
72+
describe('#31547 — the driver and the token flag, carried beside the URL', () => {
73+
it('⭐ --database-driver is carried by value, --database-auth-token only as "the flag gave it": its value is never written', () => {
74+
const env: NodeJS.ProcessEnv = {};
75+
writeCeremonyConnectionChannel(env, { databaseDriver: 'turso', databaseAuthToken: 'tok-s3cret-31547' });
76+
expect(env[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV]).toBe('turso');
77+
expect(env[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV]).toBe('1');
78+
expect(JSON.stringify(env)).not.toContain('s3cret');
79+
// Shaped as the guard's options, so `serve` spreads it as it is.
80+
expect(readCeremonyConnectionChannel(env)).toEqual({ databaseDriver: 'turso', databaseAuthTokenFromFlag: true });
81+
});
82+
83+
it('is owned by the parent: no flag, no variable — an inherited copy never speaks', () => {
84+
const env: NodeJS.ProcessEnv = {
85+
[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV]: 'pg',
86+
[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV]: '1',
87+
};
88+
writeCeremonyConnectionChannel(env, { databaseDriver: undefined, databaseAuthToken: undefined });
89+
expect(INTERNAL_CEREMONY_DATABASE_DRIVER_ENV in env).toBe(false);
90+
expect(INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV in env).toBe(false);
91+
expect(readCeremonyConnectionChannel(env)).toEqual({});
92+
// An empty flag value put nothing in the child's environment either (`start` / `dev` test truthiness).
93+
writeCeremonyConnectionChannel(env, { databaseDriver: '', databaseAuthToken: '' });
94+
expect(readCeremonyConnectionChannel(env)).toEqual({});
95+
});
96+
97+
it('⭐ `os dev` writes both channels from its own resolution, as `os start` does: one hand-off, no second resolver', () => {
98+
for (const command of ['start', 'dev']) {
99+
const source = readFileSync(new URL(`../commands/${command}.ts`, import.meta.url), 'utf8');
100+
expect(source, command).toMatch(/writeCeremonyDatabaseChannel\(localEnv, ceremonyDatabaseUrl\(resolvedDb, \{ homeFlag: /);
101+
expect(source, command).toMatch(/writeCeremonyConnectionChannel\(localEnv, \{\s*databaseDriver: flags\['database-driver'\],\s*databaseAuthToken: flags\['database-auth-token'\],/);
102+
}
103+
});
104+
});
105+

‎packages/cli/src/utils/internal-ceremony-database-channel.ts‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
/**
44
* The CLI's INTERNAL parent-to-child channel for the database `os start`'s
5-
* ownership refusal must name (#31488).
5+
* and `os dev`'s ownership refusal must name (#31488, #31547).
66
*
77
* `os start` resolves its database on the shared ladder
88
* (`resolveProjectDatabaseUrl`, `@objectstack/runtime`) with two inputs no
@@ -20,15 +20,27 @@
2020
* not listed in `content/docs/deployment/environment-variables.mdx`. The
2121
* parent OWNS the variable in the child env: set when there is a database to
2222
* name, deleted otherwise, so an inherited copy never speaks for this run.
23+
*
24+
* `os dev` writes the same channel from its own resolution (#31547): its `-d`
25+
* flag (or `--fresh`'s throwaway file) is just as invisible to the ceremony,
26+
* and its refusal names the ceremony with `--dev`, which resolves the same
27+
* ladder. Beside the URL, both commands carry what their `--database-driver`
28+
* and `--database-auth-token` flags put in the child's environment only
29+
* ({@link writeCeremonyConnectionChannel}): the driver by value, the token
30+
* only as the fact that the flag supplied it.
2331
*/
2432

2533
import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime';
2634

2735
export const INTERNAL_CEREMONY_DATABASE_URL_ENV = 'OS_INTERNAL_CEREMONY_DATABASE_URL';
36+
/** The `--database-driver` value the parent handed the child (#31547). */
37+
export const INTERNAL_CEREMONY_DATABASE_DRIVER_ENV = 'OS_INTERNAL_CEREMONY_DATABASE_DRIVER';
38+
/** `1` when `--database-auth-token` supplied the child's token (#31547); never the token itself. */
39+
export const INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV = 'OS_INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG';
2840

2941
/**
30-
* The database the refusal must name: `os start`'s resolved URL when its `-d`
31-
* flag chose it, or when the file of a `--home` home is the database (the
42+
* The database the refusal must name: the parent's resolved URL when its `-d`
43+
* flag chose it (`os dev`'s `--fresh` file too), or when the file of a `--home` home is the database (the
3244
* unified default or a legacy file under that home). `undefined` when the
3345
* ceremony's own ladder reaches the same database: `$OS_DATABASE_URL`, the
3446
* project's declared datasource, or the default file of the project's
@@ -55,3 +67,36 @@ export function readCeremonyDatabaseChannel(env: NodeJS.ProcessEnv): string | un
5567
const url = env[INTERNAL_CEREMONY_DATABASE_URL_ENV];
5668
return url ? url : undefined;
5769
}
70+
71+
/**
72+
* Write what the ceremony needs, beside the URL, to open the database the
73+
* child opens (#31547): the `--database-driver` value, and whether
74+
* `--database-auth-token` supplied the token. Each is set exactly when the
75+
* parent put the flag's value in the child's environment
76+
* (`OS_DATABASE_DRIVER` / `OS_DATABASE_AUTH_TOKEN`), and deleted otherwise: a
77+
* driver or token the operator exported is in the shell that runs the
78+
* commands already. The token's value is never written here.
79+
*/
80+
export function writeCeremonyConnectionChannel(
81+
env: NodeJS.ProcessEnv,
82+
flags: { databaseDriver: string | undefined; databaseAuthToken: string | undefined },
83+
): void {
84+
if (flags.databaseDriver) env[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV] = flags.databaseDriver;
85+
else delete env[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV];
86+
if (flags.databaseAuthToken) env[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV] = '1';
87+
else delete env[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV];
88+
}
89+
90+
/**
91+
* Read it in the `serve` child, shaped as the guard's options
92+
* (`databaseDriver`, `databaseAuthTokenFromFlag`): only the members the parent set.
93+
*/
94+
export function readCeremonyConnectionChannel(
95+
env: NodeJS.ProcessEnv,
96+
): { databaseDriver?: string; databaseAuthTokenFromFlag?: true } {
97+
const driver = env[INTERNAL_CEREMONY_DATABASE_DRIVER_ENV];
98+
return {
99+
...(driver ? { databaseDriver: driver } : {}),
100+
...(env[INTERNAL_CEREMONY_AUTH_TOKEN_FROM_FLAG_ENV] === '1' ? { databaseAuthTokenFromFlag: true as const } : {}),
101+
};
102+
}

‎packages/cli/test/dev-organization-ownership-stale-column.integration.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030
* 6446501014: the plan's column-drop fate accounts for the column, so the
3131
* upgrade proceeds, loudly);
3232
* - the two columns are gone, and the marker the guard reads is verified;
33-
* - the copy taken before the upgrade is kept, with the columns still in it.
33+
* - the copy taken before the upgrade is kept, with the columns still in it;
34+
* - ⭐ #31547: the guard's refusal the refused boot printed names the database
35+
* `-d` chose in both `--dev` commands (`--database-url file:A.db`): run by
36+
* hand, as a database os dev does not upgrade in place needs them, they
37+
* plan that database and not the project's default.
3438
*
3539
* The card's reported stop (「The upgrade did not run to a report」 with no
3640
* cause) did not reproduce on this fixture, nor on an authentic HotCRM
@@ -248,6 +252,17 @@ describe('#26615 — `os dev` starts a development database with a stale organiz
248252
expect(output).toMatch(UPGRADED_READY);
249253
});
250254

255+
it('⭐ #31547: the refusal the refused boot printed names the database `-d` chose, in both --dev commands', () => {
256+
const output = upgraded();
257+
const named = 'os migrate organization-ownership --dev --database-url file:A.db';
258+
expect(output, output.slice(-6000)).toContain(
259+
`Run '${named} --out PLAN' to write and review its plan, take a backup, then run ` +
260+
`'${named} --apply --plan-file PLAN --backup-taken'`,
261+
);
262+
// No driver or token flag was given: nothing is set in front of the command.
263+
expect(output).not.toContain('OS_DATABASE_DRIVER=');
264+
});
265+
251266
it('the stamped value the drop discards is said, with its count, before the apply runs', () => {
252267
const output = upgraded();
253268
const said = output.indexOf('sys_secret: the plan drops its retired organization_id column, discarding 1 stamped value(s)');

0 commit comments

Comments
 (0)