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
12 changes: 12 additions & 0 deletions .changeset/31338-deploy-confirm-global-uniques.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/cli': patch
---

fix(cli): `os deploy` to a self-hosted runtime takes `--confirm-global-uniques`, the retry its installation-wide uniques stop prints

Clause-②: yes (widening) — `os deploy` gains an accepted flag, `--confirm-global-uniques`, widening a published CLI command's accept set
Metadata-protocol: none

- **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.
- **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.
- **What does not change.** `os package install --confirm-global-uniques` and the two upgrade confirmations behave as before.
13 changes: 8 additions & 5 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3241,6 +3241,7 @@ is its production, so a deploy is always a deliberate act.
| `--wait-timeout <seconds>` | — | How long to wait for the environment to serve the new version (default `600`) |
| `-s, --server <url>` | `OS_CLOUD_URL` | Control-plane URL. Default `https://cloud.objectos.ai`, or the URL recorded by `os cloud login` |
| `-t, --token <key>` | `OS_CLOUD_API_KEY` | Bearer token; `$OS_TOKEN` and `~/.objectstack/cloud.json` are the fallbacks |
| `--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 |
| `--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 |
| `--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 |

Expand All @@ -3263,11 +3264,13 @@ answers are the ones printed.
build is printed; nothing is installed. A runtime that refuses it, or does not
serve install-local, stops the deploy with exit `1`. A runtime that predates the
dry run ignores it and installs: the command says so and exits `1`.
3. **Install** — with `--confirm-access-upgrade` and `--confirm-upgrade` passed
through. The endpoint's refusals (an access upgrade to confirm, a schema
upgrade to confirm, a package the running kernel cannot register, …) stop the
deploy with exit `1`. An upgrade refusal prints its retry as
`os deploy --confirm-access-upgrade <token>` for access findings or
3. **Install** — with `--confirm-global-uniques`, `--confirm-access-upgrade` and
`--confirm-upgrade` passed through. The endpoint's refusals (installation-wide
uniques to confirm on an `isolated` runtime, an access upgrade to confirm, a
schema upgrade to confirm, a package the running kernel cannot register, …)
stop the deploy with exit `1`, and the retry they print names `os deploy`:
`os deploy --confirm-global-uniques` for installation-wide uniques,
`os deploy --confirm-access-upgrade <token>` for access findings, or
`os deploy --confirm-upgrade <token>` for schema findings.
4. **Live** — install-local registers the package in the running kernel before it
answers, so the answer is the verdict: `hotLoaded` prints `Live: … now serves …`
Expand Down
48 changes: 33 additions & 15 deletions packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@
* version against this build, printed; nothing is installed. A runtime
* that refuses it, or does not serve install-local, stops the deploy here
* with its sentence and exit 1;
* 3. the install, with `--confirm-access-upgrade` and `--confirm-upgrade`
* passed through; the door's refusals (an access upgrade or a schema
* upgrade to confirm, …) stop the deploy with exit 1;
* 3. the install, with `--confirm-global-uniques`, `--confirm-access-upgrade`
* and `--confirm-upgrade` passed through; the door's refusals
* (installation-wide uniques, an access upgrade or a schema upgrade to
* confirm, …) stop the deploy with exit 1, and their retry names
* `os deploy`, so every confirmation the install prints is one this
* command declares;
* 4. the answer — install-local registers the package in the live kernel
* before it answers, so the answer IS "live": `hotLoaded` ⇒ 0; a package
* the running kernel could not load ⇒ 1, naming the restart it waits for.
Expand Down Expand Up @@ -93,6 +96,18 @@ export const DEPLOY_ARTIFACT = 'dist/objectstack.json';
/** How often the wait asks the control plane. */
export const DEPLOY_POLL_INTERVAL_MS = 3_000;

/** The install confirmations this command passes through to a self-hosted runtime's install. */
const CONFIRMATION_FLAGS = ['confirm-global-uniques', 'confirm-access-upgrade', 'confirm-upgrade'] as const;

/** The given confirmations as `os package install` argv: a switch as itself, a token after its flag. */
function confirmationArgv(flags: { [K in (typeof CONFIRMATION_FLAGS)[number]]?: string | boolean }): string[] {
return CONFIRMATION_FLAGS.flatMap((flag) => {
const value = flags[flag];
if (value === undefined || value === false) return [];
return value === true ? [`--${flag}`] : [`--${flag}`, value];
});
}

export default class Deploy extends Command {
static override description =
'Deploy this app to your ObjectOS Cloud environment (build it, publish a dev version, install it, and wait until '
Expand All @@ -102,6 +117,7 @@ export default class Deploy extends Command {
'$ os deploy',
'$ os deploy --env env_abc123 # your account reaches several environments',
'$ os deploy --no-wait # return once the install is recorded',
'$ os deploy --confirm-global-uniques # self-hosted: confirm installation-wide uniques',
'$ os deploy --confirm-access-upgrade <finding-set> # self-hosted: confirm the findings the runtime printed',
'$ os deploy --confirm-upgrade <finding-set> # self-hosted: confirm the schema findings the runtime printed',
];
Expand Down Expand Up @@ -137,6 +153,15 @@ export default class Deploy extends Command {
description: 'API key for ObjectOS Cloud (bearer token; defaults to OS_CLOUD_API_KEY / OS_TOKEN, then `os cloud login`)',
env: 'OS_CLOUD_API_KEY',
}),
// The installation-wide uniques' confirmation, passed through like the
// two upgrade confirmations below: the embedded install's
// UNIQUE_SCOPE_CONFIRMATION_REQUIRED stop prints
// `os deploy --confirm-global-uniques` as its retry.
'confirm-global-uniques': Flags.boolean({
description:
"Self-hosted runtime target: confirm this app's installation-wide (`unique: 'global'`) constraints are "
+ "genuinely platform-wide when the runtime's refusal asks (as `os package install --confirm-global-uniques`)",
}),
'confirm-access-upgrade': Flags.string({
description:
'Self-hosted runtime target: confirm the access findings of this upgrade with the finding-set token the '
Expand All @@ -163,11 +188,11 @@ export default class Deploy extends Command {
// cloud environment was named.
const runtime = flags.env ? undefined : await activeRuntimeTarget();
if (runtime) {
await this.deployToRuntime(runtime, flags['confirm-access-upgrade'], flags['confirm-upgrade']);
await this.deployToRuntime(runtime, confirmationArgv(flags));
return;
}
for (const flag of ['confirm-access-upgrade', 'confirm-upgrade'] as const) {
if (flags[flag] === undefined) continue;
for (const flag of CONFIRMATION_FLAGS) {
if (flags[flag] === undefined || flags[flag] === false) continue;
printError(
`\`--${flag}\` confirms a self-hosted runtime's refusal; the deploy target is ObjectOS Cloud. `
+ 'Drop the flag, or record the runtime with `os environments use-runtime <url> --token <key>`.',
Expand Down Expand Up @@ -298,11 +323,7 @@ export default class Deploy extends Command {
* The self-hosted half (the header's "A self-hosted runtime target"):
* build → dry run (plan printed) → install → the `hotLoaded` answer.
*/
private async deployToRuntime(
runtime: RuntimeTarget,
confirmAccessUpgrade: string | undefined,
confirmUpgrade: string | undefined,
): Promise<void> {
private async deployToRuntime(runtime: RuntimeTarget, confirmations: string[]): Promise<void> {
printStep(`Deploying to the self-hosted runtime ${runtime.url} (os environments use-runtime)`);

printStep('Building...');
Expand Down Expand Up @@ -334,10 +355,7 @@ export default class Deploy extends Command {
}

printStep(`Installing ${plan.manifestId} ${plan.version} into ${runtime.url}...`);
const outcome = await install([
...(confirmAccessUpgrade !== undefined ? ['--confirm-access-upgrade', confirmAccessUpgrade] : []),
...(confirmUpgrade !== undefined ? ['--confirm-upgrade', confirmUpgrade] : []),
]);
const outcome = await install(confirmations);
if (outcome?.kind !== 'installed') {
printError(`${runtime.url} did not answer the install as a managed package install, so this cannot say ${plan.manifestId} is live (see above).`);
this.exit(1);
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/test/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock('../src/utils/deploy-build.js', () => ({ compileForDeploy: (p: string) =
const { default: Deploy, reportVerdict } = await import('../src/commands/deploy.js');
const { waitUntilServed, interpretServedReport } = await import('../src/utils/served-version.js');
const { devVersion } = await import('../src/utils/dev-version.js');
const { default: PackageInstall } = await import('../src/commands/package/install.js');

const CLOUD_PLANE = 'http://cloud.test';
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;
Expand Down Expand Up @@ -592,6 +593,26 @@ describe('os deploy — a self-hosted runtime as the active target (os environme
expect(sent[1].body.confirmSchemaUpgrade).toBe('schema-upgrade:abc');
});

it("the installation-wide uniques stop's retry names os deploy --confirm-global-uniques, and the confirmation passes through", async () => {
const uniquesRefusal = refused(409, 'UNIQUE_SCOPE_CONFIRMATION_REQUIRED', "acme_lead.email is unique: 'global'");
let sent = stubRuntime({ install: uniquesRefusal });
expect(await deploy()).toBe(1);
expect(sent).toHaveLength(2);
expect(text()).toContain('os deploy --confirm-global-uniques');

vi.unstubAllGlobals();
sent = stubRuntime({});
expect(await deploy(['--confirm-global-uniques']), text()).toBeUndefined();
expect(sent[0].body.confirmGlobalUniques).toBeUndefined();
expect(sent[1].body.confirmGlobalUniques).toBe(true);
});

it('declares every confirmation flag the embedded install prints as its retry', () => {
const confirmations = Object.keys(PackageInstall.flags).filter((name) => name.startsWith('confirm-'));
expect(confirmations).toContain('confirm-global-uniques');
for (const name of confirmations) expect(Object.keys(Deploy.flags), name).toContain(name);
});

it('a package the running kernel could not load is not called live: exit 1', async () => {
stubRuntime({ install: ok({ ...INSTALLED, hotLoaded: false, hotLoadError: 'boom' }) });

Expand Down Expand Up @@ -626,4 +647,14 @@ describe('os deploy — a self-hosted runtime as the active target (os environme
expect(sent).toHaveLength(0);
expect(compileForDeploy).not.toHaveBeenCalled();
});

it('--confirm-global-uniques is refused when the target is ObjectOS Cloud, before anything is sent', async () => {
await writeFile(join(home, '.objectstack', 'cloud.json'), JSON.stringify({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }));
const sent = stubRuntime({});

expect(await deploy(['--confirm-global-uniques'])).toBe(1);
expect(sent).toHaveLength(0);
expect(compileForDeploy).not.toHaveBeenCalled();
expect(text()).toContain('`--confirm-global-uniques` confirms a self-hosted runtime');
});
});
Loading