Skip to content

Commit 405a78e

Browse files
fix(cli): os deploy takes --confirm-global-uniques on a self-hosted runtime (#31369)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XK6xDkLoiBC4HTcoqUryg5E)_ Fixes #31338 Clause-②: yes — `os deploy` gains an accepted flag (`--confirm-global-uniques`), widening a published CLI command's accept set; the seat's own review of the declaration is owed before landing Metadata-protocol: none ## 维护者速读 - 改了什么:`os deploy` 新增 `--confirm-global-uniques` 开关,在自托管运行时(`os environments use-runtime`)路径上和已有的两个升级确认(`--confirm-access-upgrade`、`--confirm-upgrade`)走同一处转交给内嵌的 `os package install`;目标是 ObjectOS Cloud 时照两个升级确认的做法,在构建和发送前拒绝。文档 `cli.mdx` 补了这一行。 - 为什么改:自托管部署遇到全局唯一约束停止(`UNIQUE_SCOPE_CONFIRMATION_REQUIRED`)时,CLI 打印的重试命令是 `os deploy --confirm-global-uniques`,但 `os deploy` 不认这个参数,照抄(尤其是 agent 照抄)会被当未知参数拒绝,部署路径也无法传这个确认。 - 风险与回滚:只加一个可选开关,不传时请求体与以前逐字节相同;`os package install` 和两个升级确认不变;`@objectstack/cli` patch;回滚即 revert。 - 先看:`packages/cli/src/commands/deploy.ts` 的 `CONFIRMATION_FLAGS` / `confirmationArgv`(唯一转交处)和 Cloud 拒绝循环;`packages/cli/test/deploy.test.ts` 新增三条用例。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run --project unit test/deploy.test.ts`,预期 34/34 通过;其中「declares every confirmation flag the embedded install prints」一条在 `os package install` 将来新增确认开关而 `os deploy` 没跟上时会变红。 - 需要你做的:无,席位落地。 ## What changes On a self-hosted runtime target, `os deploy` runs `os package install` in-process with `retryCommand = 'os deploy'`, so every stop that install prints names `os deploy` as the retry. The installation-wide uniques stop (`UNIQUE_SCOPE_CONFIRMATION_REQUIRED`, ADR-0120 D5e) printed `os deploy --confirm-global-uniques`, a flag `os deploy` did not declare: oclif refused it as an unknown flag (exit 2), and the deploy path had no way to pass the confirmation. - **`packages/cli/src/commands/deploy.ts`.** Declares `--confirm-global-uniques` (a boolean, help text naming `os package install --confirm-global-uniques`, as the two upgrade confirmations name theirs). The three confirmations are now one list, `CONFIRMATION_FLAGS`, and one function, `confirmationArgv`, turns the given ones into install argv. `deployToRuntime` takes that argv instead of two token parameters, so there is one forwarding seam and no second copy of the install command's handling. As before, the confirmations go to the install only; the dry run sends none (install-local answers `dryRun` before its uniques gate, `marketplace-install-local-plugin.ts` `answerUpgradeDryRun` precedes the `GLOBAL_UNIQUE_CONFIRMATION_REQUIRED` stop). - **ObjectOS Cloud target.** The flag means nothing there, so it is refused before anything is built or sent, exactly as `--confirm-access-upgrade` and `--confirm-upgrade` already are (same loop, same sentence, exit 1). - **`content/docs/deployment/cli.mdx`.** The `os deploy` flag table gains the `--confirm-global-uniques` row; § To a self-hosted runtime step 3 names the three confirmations passed through, the uniques stop, and that the printed retry names `os deploy`. - **Changeset** `.changeset/31338-deploy-confirm-global-uniques.md`: `@objectstack/cli` patch. `install.ts` is untouched: the seam did not need sharing. ## Tests `packages/cli/test/deploy.test.ts`, in the use-runtime block, three new cases: 1. The uniques stop's retry names `os deploy --confirm-global-uniques`, and `os deploy --confirm-global-uniques` reaches the install with `confirmGlobalUniques: true` (and the dry run without it). 2. Every `confirm-*` flag `os package install` declares is declared by `os deploy` (the class guard: a future install confirmation that `os deploy` does not take reds here). 3. `--confirm-global-uniques` is refused on an ObjectOS Cloud target before anything is built or sent. CONTROL: the existing access-upgrade and schema-upgrade cases and their Cloud refusals pass unchanged; `test/package-install-dry-run.test.ts` passes unchanged. <details><summary>Red on main (cases added, source at origin/main e735425)</summary> ```text × the installation-wide uniques stop's retry names os deploy --confirm-global-uniques, and the confirmation passes through × declares every confirmation flag the embedded install prints as its retry × --confirm-global-uniques is refused when the target is ObjectOS Cloud, before anything is sent …or edit the app's metadata to `unique: 'organization'` and rebuild.: expected 2 to be undefined AssertionError: confirm-global-uniques: expected [ 'env', 'wait', 'wait-timeout', …(4) ] to include 'confirm-global-uniques' AssertionError: expected 2 to be 1 // Object.is equality Tests 3 failed | 31 passed (34) ``` Exit 2 is oclif's unknown-flag refusal: the printed retry was refused. </details> <details><summary>Ablations (scripts/ablation-replace.mjs, restore proven blob == HEAD 2b59a9c and git diff HEAD empty)</summary> The test imports `../src/commands/deploy.js` directly, so no dist leg applies. A. Cloud refusal skips the new flag (`|| flag === 'confirm-global-uniques'` in the refusal loop's `continue`): ```text × --confirm-global-uniques is refused when the target is ObjectOS Cloud, before anything is sent AssertionError: expected [ { …(4) } ] to have a length of +0 but got 1 Tests 1 failed | 33 passed (34) ``` B. The forwarding drops the new flag (`|| flag === 'confirm-global-uniques'` in `confirmationArgv`'s empty return): ```text × the installation-wide uniques stop's retry names os deploy --confirm-global-uniques, and the confirmation passes through AssertionError: expected undefined to be true // Object.is equality Tests 1 failed | 33 passed (34) ``` </details> ## Acceptance notes - **Clause-②: yes (widening).** `os deploy`'s accept set grows by one flag; nothing it accepted before is refused, and with the flag absent the install body is byte-identical to before. - **PR #31335 and `cli.mdx`.** #31335 (skills lane, open) edits `cli.mdx` § `os dev` (around L169-L174) only; this PR edits § `os deploy`'s flag table and § To a self-hosted runtime step 3 (around L3245, L3267). No overlapping hunk, so the doc line stays in this PR; whichever lands second merges cleanly. - **Not edited here.** The platform skill's use-runtime exit-1 paragraph (`skills/objectstack-platform/references/operations.md`, added by #31335) is the skills lane's; the line it needs is in the report's `out_of_scope_findings`. The `os deploy` flag table still has no `--confirm-upgrade` row (#31335's "Other sites found" already names it); left alone. ## Verification All local readings are on head `7e77a2ba` (the merge of `origin/main` `d0512c2f` into fix commit `cecb4075`; the merge brought no `packages/cli` change). - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 96 commands against merge base `e73542555` (4 paths, +83/-18). All 96 ran after a full `turbo run build --filter='./packages/**'` (81/81 tasks): **96 × exit 0**. `--ran` reconciliation: "96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. Per-command record: project file `pm-orders/server/31338-gates-ran.txt`. - **cli unit tier.** `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` under the verify lock: `VERDICT command-exit 0`, Test Files 401 passed (401), Tests 5671 passed (5671). Started on `cecb4075`; `packages/cli` is byte-identical on `7e77a2ba`. The integration tier is declared to CI (the diff touches no integration test, `bin/` or spawn helper). - **cli typecheck.** `NODE_OPTIONS=--max-old-space-size=6144 pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit && check:test-typecheck`): `VERDICT command-exit 0`, test layer OK. - **eslint** on `packages/cli/src/commands/deploy.ts` and `packages/cli/test/deploy.test.ts`: exit 0. - **Commit trailers.** `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0. - Repo-wide `pnpm lint`, `test:repo` and the other CI-owned families: CI on this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5o7rUkfGZf2hPcQqZJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96b5dab commit 405a78e

4 files changed

Lines changed: 84 additions & 20 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os deploy` to a self-hosted runtime takes `--confirm-global-uniques`, the retry its installation-wide uniques stop prints
6+
7+
Clause-②: yes (widening) — `os deploy` gains an accepted flag, `--confirm-global-uniques`, widening a published CLI command's accept set
8+
Metadata-protocol: none
9+
10+
- **What was wrong.** With a self-hosted runtime as the deploy target (`os environments use-runtime`), `os deploy` installs through `os package install` run in the same process. When an `isolated`-posture runtime stops the install on the app's installation-wide (`unique: 'global'`) constraints (`UNIQUE_SCOPE_CONFIRMATION_REQUIRED`), the retry it prints is `os deploy --confirm-global-uniques`, but `os deploy` did not declare that flag: following the printed advice was refused as an unknown flag, and the deploy had no way to pass the confirmation.
11+
- **What changes.** `os deploy` declares `--confirm-global-uniques` and passes it through to the install, as it already does `--confirm-access-upgrade` and `--confirm-upgrade`: all three go through one seam, to the install only (the dry run sends none, as before). Named while the target is ObjectOS Cloud, it is refused before anything is built or sent, like the two upgrade confirmations.
12+
- **What does not change.** `os package install --confirm-global-uniques` and the two upgrade confirmations behave as before.

‎content/docs/deployment/cli.mdx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3241,6 +3241,7 @@ is its production, so a deploy is always a deliberate act.
32413241
| `--wait-timeout <seconds>` | — | How long to wait for the environment to serve the new version (default `600`) |
32423242
| `-s, --server <url>` | `OS_CLOUD_URL` | Control-plane URL. Default `https://cloud.objectos.ai`, or the URL recorded by `os cloud login` |
32433243
| `-t, --token <key>` | `OS_CLOUD_API_KEY` | Bearer token; `$OS_TOKEN` and `~/.objectstack/cloud.json` are the fallbacks |
3244+
| `--confirm-global-uniques` | — | Self-hosted target only: confirm the app's installation-wide `unique` constraints are genuinely platform-wide when the runtime's refusal asks (as [`os package install`](#os-package-install)). Refused when the target is ObjectOS Cloud |
32443245
| `--confirm-access-upgrade <token>` | — | Self-hosted target only: confirm the upgrade's access findings with the finding-set token the runtime's refusal printed (as [`os package install`](#os-package-install)). Refused when the target is ObjectOS Cloud |
32453246
| `--confirm-upgrade <token>` | — | Self-hosted target only: confirm the upgrade's schema findings (a suspected rename, a type change that is not safe) with the finding-set token the runtime's refusal printed (as [`os package install`](#os-package-install)). Refused when the target is ObjectOS Cloud |
32463247
@@ -3263,11 +3264,13 @@ answers are the ones printed.
32633264
build is printed; nothing is installed. A runtime that refuses it, or does not
32643265
serve install-local, stops the deploy with exit `1`. A runtime that predates the
32653266
dry run ignores it and installs: the command says so and exits `1`.
3266-
3. **Install** — with `--confirm-access-upgrade` and `--confirm-upgrade` passed
3267-
through. The endpoint's refusals (an access upgrade to confirm, a schema
3268-
upgrade to confirm, a package the running kernel cannot register, …) stop the
3269-
deploy with exit `1`. An upgrade refusal prints its retry as
3270-
`os deploy --confirm-access-upgrade <token>` for access findings or
3267+
3. **Install** — with `--confirm-global-uniques`, `--confirm-access-upgrade` and
3268+
`--confirm-upgrade` passed through. The endpoint's refusals (installation-wide
3269+
uniques to confirm on an `isolated` runtime, an access upgrade to confirm, a
3270+
schema upgrade to confirm, a package the running kernel cannot register, …)
3271+
stop the deploy with exit `1`, and the retry they print names `os deploy`:
3272+
`os deploy --confirm-global-uniques` for installation-wide uniques,
3273+
`os deploy --confirm-access-upgrade <token>` for access findings, or
32713274
`os deploy --confirm-upgrade <token>` for schema findings.
32723275
4. **Live** — install-local registers the package in the running kernel before it
32733276
answers, so the answer is the verdict: `hotLoaded` prints `Live: … now serves …`

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

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,12 @@
6161
* version against this build, printed; nothing is installed. A runtime
6262
* that refuses it, or does not serve install-local, stops the deploy here
6363
* with its sentence and exit 1;
64-
* 3. the install, with `--confirm-access-upgrade` and `--confirm-upgrade`
65-
* passed through; the door's refusals (an access upgrade or a schema
66-
* upgrade to confirm, …) stop the deploy with exit 1;
64+
* 3. the install, with `--confirm-global-uniques`, `--confirm-access-upgrade`
65+
* and `--confirm-upgrade` passed through; the door's refusals
66+
* (installation-wide uniques, an access upgrade or a schema upgrade to
67+
* confirm, …) stop the deploy with exit 1, and their retry names
68+
* `os deploy`, so every confirmation the install prints is one this
69+
* command declares;
6770
* 4. the answer — install-local registers the package in the live kernel
6871
* before it answers, so the answer IS "live": `hotLoaded` ⇒ 0; a package
6972
* the running kernel could not load ⇒ 1, naming the restart it waits for.
@@ -93,6 +96,18 @@ export const DEPLOY_ARTIFACT = 'dist/objectstack.json';
9396
/** How often the wait asks the control plane. */
9497
export const DEPLOY_POLL_INTERVAL_MS = 3_000;
9598

99+
/** The install confirmations this command passes through to a self-hosted runtime's install. */
100+
const CONFIRMATION_FLAGS = ['confirm-global-uniques', 'confirm-access-upgrade', 'confirm-upgrade'] as const;
101+
102+
/** The given confirmations as `os package install` argv: a switch as itself, a token after its flag. */
103+
function confirmationArgv(flags: { [K in (typeof CONFIRMATION_FLAGS)[number]]?: string | boolean }): string[] {
104+
return CONFIRMATION_FLAGS.flatMap((flag) => {
105+
const value = flags[flag];
106+
if (value === undefined || value === false) return [];
107+
return value === true ? [`--${flag}`] : [`--${flag}`, value];
108+
});
109+
}
110+
96111
export default class Deploy extends Command {
97112
static override description =
98113
'Deploy this app to your ObjectOS Cloud environment (build it, publish a dev version, install it, and wait until '
@@ -102,6 +117,7 @@ export default class Deploy extends Command {
102117
'$ os deploy',
103118
'$ os deploy --env env_abc123 # your account reaches several environments',
104119
'$ os deploy --no-wait # return once the install is recorded',
120+
'$ os deploy --confirm-global-uniques # self-hosted: confirm installation-wide uniques',
105121
'$ os deploy --confirm-access-upgrade <finding-set> # self-hosted: confirm the findings the runtime printed',
106122
'$ os deploy --confirm-upgrade <finding-set> # self-hosted: confirm the schema findings the runtime printed',
107123
];
@@ -137,6 +153,15 @@ export default class Deploy extends Command {
137153
description: 'API key for ObjectOS Cloud (bearer token; defaults to OS_CLOUD_API_KEY / OS_TOKEN, then `os cloud login`)',
138154
env: 'OS_CLOUD_API_KEY',
139155
}),
156+
// The installation-wide uniques' confirmation, passed through like the
157+
// two upgrade confirmations below: the embedded install's
158+
// UNIQUE_SCOPE_CONFIRMATION_REQUIRED stop prints
159+
// `os deploy --confirm-global-uniques` as its retry.
160+
'confirm-global-uniques': Flags.boolean({
161+
description:
162+
"Self-hosted runtime target: confirm this app's installation-wide (`unique: 'global'`) constraints are "
163+
+ "genuinely platform-wide when the runtime's refusal asks (as `os package install --confirm-global-uniques`)",
164+
}),
140165
'confirm-access-upgrade': Flags.string({
141166
description:
142167
'Self-hosted runtime target: confirm the access findings of this upgrade with the finding-set token the '
@@ -163,11 +188,11 @@ export default class Deploy extends Command {
163188
// cloud environment was named.
164189
const runtime = flags.env ? undefined : await activeRuntimeTarget();
165190
if (runtime) {
166-
await this.deployToRuntime(runtime, flags['confirm-access-upgrade'], flags['confirm-upgrade']);
191+
await this.deployToRuntime(runtime, confirmationArgv(flags));
167192
return;
168193
}
169-
for (const flag of ['confirm-access-upgrade', 'confirm-upgrade'] as const) {
170-
if (flags[flag] === undefined) continue;
194+
for (const flag of CONFIRMATION_FLAGS) {
195+
if (flags[flag] === undefined || flags[flag] === false) continue;
171196
printError(
172197
`\`--${flag}\` confirms a self-hosted runtime's refusal; the deploy target is ObjectOS Cloud. `
173198
+ 'Drop the flag, or record the runtime with `os environments use-runtime <url> --token <key>`.',
@@ -298,11 +323,7 @@ export default class Deploy extends Command {
298323
* The self-hosted half (the header's "A self-hosted runtime target"):
299324
* build → dry run (plan printed) → install → the `hotLoaded` answer.
300325
*/
301-
private async deployToRuntime(
302-
runtime: RuntimeTarget,
303-
confirmAccessUpgrade: string | undefined,
304-
confirmUpgrade: string | undefined,
305-
): Promise<void> {
326+
private async deployToRuntime(runtime: RuntimeTarget, confirmations: string[]): Promise<void> {
306327
printStep(`Deploying to the self-hosted runtime ${runtime.url} (os environments use-runtime)`);
307328

308329
printStep('Building...');
@@ -334,10 +355,7 @@ export default class Deploy extends Command {
334355
}
335356

336357
printStep(`Installing ${plan.manifestId} ${plan.version} into ${runtime.url}...`);
337-
const outcome = await install([
338-
...(confirmAccessUpgrade !== undefined ? ['--confirm-access-upgrade', confirmAccessUpgrade] : []),
339-
...(confirmUpgrade !== undefined ? ['--confirm-upgrade', confirmUpgrade] : []),
340-
]);
358+
const outcome = await install(confirmations);
341359
if (outcome?.kind !== 'installed') {
342360
printError(`${runtime.url} did not answer the install as a managed package install, so this cannot say ${plan.manifestId} is live (see above).`);
343361
this.exit(1);

‎packages/cli/test/deploy.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ vi.mock('../src/utils/deploy-build.js', () => ({ compileForDeploy: (p: string) =
2828
const { default: Deploy, reportVerdict } = await import('../src/commands/deploy.js');
2929
const { waitUntilServed, interpretServedReport } = await import('../src/utils/served-version.js');
3030
const { devVersion } = await import('../src/utils/dev-version.js');
31+
const { default: PackageInstall } = await import('../src/commands/package/install.js');
3132

3233
const CLOUD_PLANE = 'http://cloud.test';
3334
const MANAGED_ENV = ['OS_CLOUD_URL', 'OS_CLOUD_API_KEY', 'OS_TOKEN', 'OS_ENVIRONMENT_ID', 'OS_ORG_ID', 'OS_PACKAGE_MANIFEST_ID', 'OS_CLOUD_TIMEOUT_MS'] as const;
@@ -592,6 +593,26 @@ describe('os deploy — a self-hosted runtime as the active target (os environme
592593
expect(sent[1].body.confirmSchemaUpgrade).toBe('schema-upgrade:abc');
593594
});
594595

596+
it("the installation-wide uniques stop's retry names os deploy --confirm-global-uniques, and the confirmation passes through", async () => {
597+
const uniquesRefusal = refused(409, 'UNIQUE_SCOPE_CONFIRMATION_REQUIRED', "acme_lead.email is unique: 'global'");
598+
let sent = stubRuntime({ install: uniquesRefusal });
599+
expect(await deploy()).toBe(1);
600+
expect(sent).toHaveLength(2);
601+
expect(text()).toContain('os deploy --confirm-global-uniques');
602+
603+
vi.unstubAllGlobals();
604+
sent = stubRuntime({});
605+
expect(await deploy(['--confirm-global-uniques']), text()).toBeUndefined();
606+
expect(sent[0].body.confirmGlobalUniques).toBeUndefined();
607+
expect(sent[1].body.confirmGlobalUniques).toBe(true);
608+
});
609+
610+
it('declares every confirmation flag the embedded install prints as its retry', () => {
611+
const confirmations = Object.keys(PackageInstall.flags).filter((name) => name.startsWith('confirm-'));
612+
expect(confirmations).toContain('confirm-global-uniques');
613+
for (const name of confirmations) expect(Object.keys(Deploy.flags), name).toContain(name);
614+
});
615+
595616
it('a package the running kernel could not load is not called live: exit 1', async () => {
596617
stubRuntime({ install: ok({ ...INSTALLED, hotLoaded: false, hotLoadError: 'boom' }) });
597618

@@ -626,4 +647,14 @@ describe('os deploy — a self-hosted runtime as the active target (os environme
626647
expect(sent).toHaveLength(0);
627648
expect(compileForDeploy).not.toHaveBeenCalled();
628649
});
650+
651+
it('--confirm-global-uniques is refused when the target is ObjectOS Cloud, before anything is sent', async () => {
652+
await writeFile(join(home, '.objectstack', 'cloud.json'), JSON.stringify({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }));
653+
const sent = stubRuntime({});
654+
655+
expect(await deploy(['--confirm-global-uniques'])).toBe(1);
656+
expect(sent).toHaveLength(0);
657+
expect(compileForDeploy).not.toHaveBeenCalled();
658+
expect(text()).toContain('`--confirm-global-uniques` confirms a self-hosted runtime');
659+
});
629660
});

0 commit comments

Comments
 (0)