Skip to content

Commit d5fd948

Browse files
feat(cli): os deploy and the os dev d key deploy to a self-hosted runtime remembered by os environments use-runtime (#31137)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiUfC8G7ZWNaCdELoQcXkaCC)_ Fixes #30888 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:新增 `os environments use-runtime 地址 --token 密钥`,记住一个私有部署的运行时(Docker 社区版或企业版)并设为部署目标;之后 `os deploy` 和 `os dev` 里按 `d` 不带任何参数就部署到它:先构建,再请运行时做一次预演(打印升级计划,含 `renamedFrom` 声明的改名),然后把构建产物直接装进去,按运行时的 `hotLoaded` 回答报告是否已生效。`os package install` 不写 `-u`/`-t` 时也用它。 - **为什么改**:私有部署现在每次都要手敲地址和令牌,默认地址 3000 还和官方镜像的 8080 对不上;维护者要求私有部署的发布流程和云端一样顺手(epic cloud#3652)。记住的目标让默认端口问题不再需要改默认值。 - **风险与回滚**:只动 CLI。地址和密钥存在 `~/.objectstack/cloud.json`(0600)里独立的一段,云端登录/登出都保留它,密钥只发给它自己的地址。Q3 的「默认拒绝」由 install-local 自己判(#30884 第二个 PR 尚未合入),本 PR 只负责把它的拒绝原样打印并以非零退出。回滚 = revert 本 PR。 - **先看**:`packages/cli/src/utils/cloud-config.ts`(两半存储,登录/登出不丢目标)、`packages/cli/src/commands/deploy.ts` 的 `deployToRuntime`、`packages/cli/src/utils/runtime-target.ts` 的 `runtimeInstallTarget`(密钥只发给同一地址)。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project unit test/deploy.test.ts test/environments-use-runtime.test.ts`,预期全部通过,其中「a self-hosted runtime as the active target」一组 8 条。 - **需要你做的**:无,席位落地。 ## What changes **`os environments use-runtime URL --token KEY`** (new) records a self-hosted runtime and an API key it issued, and makes it the deploy target. The name is a verb beside `os environments switch`, and "runtime" is the word `os package install` already uses for this server (`OS_RUNTIME_URL` / `OS_RUNTIME_TOKEN`). Before writing, the key is tried with one authenticated `GET /api/v1/marketplace/install-local`. A rejected key (401/403), a runtime that does not serve the door (404), or one that cannot be reached is refused and nothing is recorded; `--no-verify` records anyway. `--forget` drops the record. `os environments switch ID`, when it records into `cloud.json`, makes the cloud the target again and keeps the runtime remembered. Whichever ran last wins. **Storage** (card item 1: "the same protected file the cloud token uses"). `~/.objectstack/cloud.json` (0600) gains a deploy-target half, `runtimeTarget: { url, token, recordedAt }` and `deployTarget: 'cloud' | 'runtime'`. It is never read by the cloud credential path. `writeCloudConfig` carries it across every cloud write, so `os cloud login` keeps it. `deleteCloudConfig` keeps it, so `os cloud logout` clears only the session. A file that holds only the runtime half reads as "not logged in" to the cloud. Only `writeDeployTargetRecord` changes the half. `login.ts` and `logout.ts` are untouched. **`os deploy`** — when a runtime is the active target and `--env` is not given (card items 2–4): 1. It builds with `compileForDeploy`, as the cloud path does. 2. It asks install-local for a dry run (`dryRun: true`, from #30884 PR 1) and prints the upgrade plan with the same renderer as `os package install --dry-run`. Declared `renamedFrom` renames are listed. 3. It installs the artifact inline. There is no catalog publish and no `-dev` version. `--confirm-access-upgrade FINDING_SET` passes through to the install. The flag is refused when the target is the cloud. 4. It reads the answer. install-local registers the package before it answers, so `hotLoaded` means live, with exit 0. A package the kernel could not load gives exit 1. Steps 2–3 run `os package install` in-process, as the cloud path runs `os package publish`. Every refusal is printed once, in one place, and the retry lines name `os deploy`. If the dry run is refused, nothing more is sent. If the runtime answers 404 (a runtime booted from a composed artifact, or one without the door), the deploy names the cause and exits 1. If the runtime predates the dry run, the deploy says so and exits 1. **The `d` key** names the remembered runtime in its offer line and runs the same `os deploy` with no cloud login prompt. **`os package install`** without `-u` / `$OS_RUNTIME_URL` uses the remembered runtime. Without `-t` / `--email` it uses that runtime's key, but only when the URL is the runtime's own (`isSameControlPlane`). The `url` flag loses its oclif `default` so that an absent flag can be told from a typed one. With nothing remembered, the URL is still `http://localhost:3000`. The 404 sentence now names the composed-artifact case. **Item 5 (default port).** The remembered target makes it moot, so the default stays `http://localhost:3000`. ## Tests - `test/deploy.test.ts`, new top-level describe "a self-hosted runtime as the active target" (8). Covers: no-flag deploy (dry run, then inline install, plan printed, key sent, no `-dev` suffix, no cloud call); a dry-run refusal stops before the install; 404 named; runtime predating the dry run; access-upgrade refusal with retry `os deploy --confirm-access-upgrade …`, and the token passed through on rerun; `hotLoaded: false` gives exit 1; a failed build installs nothing; the flag refused on the cloud target. - `test/environments-use-runtime.test.ts` (new, 10). Covers: record after probe (0600, cloud session kept, cloud reads never see the key, still "not logged in" without a cloud login); 401 and 404 refused with nothing recorded; `--no-verify`; URL validation; `--forget`. For `cloud.json`: login keeps the runtime target, logout keeps it (and deletes the file when nothing else is left), and `switch` activates the cloud. - `test/package-install-url-token-flags.test.ts`, new describe (5). Covers: fallback to the remembered runtime and its key; `-u` to the same runtime keeps the key; `-u` to another server never gets the key; explicit `-t` wins; `--email` never mixes the two; nothing remembered falls back to localhost:3000. - `test/dev-deploy-key.test.ts`, new describe (2). Covers: the runtime offer line, and `d` deploying with no login; the wiring in `startDevDeployKey`. - Ablation (one-shot): `isSameControlPlane(stored.url, url)` was replaced with `true` in `runtime-target.ts`. The test "-u naming another server never receives the remembered key" went red (1 failed / 13 passed), and the file was restored to its HEAD blob `900b6fe7`. ## Acceptance notes - **Q3 is install-local's verdict, and its refusal half has not landed.** On `origin/main`, install-local has the dry run (#30884 PR 1, #30983) but not the Q3 refusal, `confirmSchemaUpgrade` or `--confirm-upgrade` (#30884 PR 2, back in the queue per handoff 6688284693). This PR prints the plan and stops on any refusal the door answers, from the dry run or the install. It adds no client-side copy of the verdict and no `--confirm-upgrade` flag, because the door has no body key for it to pass through. Until PR 2 lands, a deploy whose plan lists a suspected rename installs just as `os package install` does today, with the plan printed first. When PR 2 lands, its `--confirm-upgrade` should be forwarded by `os deploy` the same way `--confirm-access-upgrade` is. - **Cloud-bound runtime through the catalog** (card item 2, second half): not done. A follow-up. - **The composed-artifact refusal shape** is inferred from the card ("declines" install-local), not measured. The cloud repo is not readable from this session. A 404 gets the sentence above, and any other refusal is printed with its code and message. - `os environments create --activate` does not switch the deploy target back to the cloud; only `switch` does. - Docs (`content/docs/deployment/cli.mdx` for `os deploy`, the environments table and `os package install`) and the platform skill's `operations.md` row are not updated here. Both are outside the claim's file surface and are listed for the seat. ## Verification <details><summary>Local gates on head b57c03a (merge base 5f00374; origin/main merged once, already up to date)</summary> ``` pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 :: exit 0 (397 files / 5603 tests passed) pnpm --filter @objectstack/cli typecheck :: exit 0 (tsc + check:test-typecheck, NODE_OPTIONS=--max-old-space-size=8192) eslint packages/cli --no-inline-config :: exit 0 node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack :: exit 0 (62 families) node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list :: exit 0 (62 derived, 62 run, 0 NOT-MEASURED) ``` First passes that exited 3 for a missing prerequisite were rebuilt and rerun (the exit-0 lines above): `check-plugin-teardown-shape --self-test` (shallow clone, so the pinned control commit was fetched), `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity` (built output missing, so those packages were built), and `check:type-check-debt` (lock queue timeout, rerun with a slot). The CLI `integration` tier is left to CI: the diff touches no spawn entry and no integration-tier file. </details> <details><summary>Manual check on the Community image</summary> NOT RUN: docker is not available in this container. Steps: `docker run -p 8080:8080 ghcr.io/objectstack-ai/objectstack` (`os start`); create an API key in Setup → API Keys; in a fresh scaffold run `os environments use-runtime http://localhost:8080 --token KEY`, then `os dev`, press `d`, edit an object label, press `d` again, and confirm that `GET http://localhost:8080/api/v1/meta/object/NAME` serves the second label. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cbe20ab commit d5fd948

12 files changed

Lines changed: 1103 additions & 45 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os deploy` and the `d` key in `os dev` deploy to a self-hosted runtime (Docker Community or Enterprise) that `os environments use-runtime` remembered; `os package install` falls back to it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
**`os environments use-runtime <url> --token <key>`** (new) remembers a self-hosted runtime and an API key it issued, and makes it the deploy target. The key is tried on the runtime's install-local door first (`--no-verify` skips that); a rejected key, a runtime that does not serve install-local, or one that cannot be reached is refused and nothing is recorded. Both are stored in `~/.objectstack/cloud.json` (0600) as their own record beside the cloud session: `os cloud login` and `os cloud logout` keep it, and the key is only ever sent to that url. `os environments switch <id>` makes ObjectOS Cloud the deploy target again (the runtime stays remembered); `os environments use-runtime --forget` drops it.
11+
12+
**`os deploy`** with a runtime as the active target (and no `--env`) builds the app, asks the runtime's install-local dry run and prints the upgrade plan (declared `renamedFrom` renames included), then installs the build inline — no catalog publish and no `-dev` version — and reports it live from the runtime's answer. A refusal from the dry run or the install stops the deploy with exit 1 and the runtime's sentence; a runtime that does not serve install-local (one booted from a composed artifact) or that predates the dry run is named, with what to do, exit 1. `--confirm-access-upgrade <finding-set>` passes the access-upgrade confirmation through (and is refused when the target is ObjectOS Cloud). **The `d` key** names the remembered runtime and deploys there without asking for a cloud login.
13+
14+
**`os package install`** without `-u` / `$OS_RUNTIME_URL` installs into the remembered runtime, and without `-t` / `--email` uses its key — only when the url is that runtime's. With nothing remembered the default stays `http://localhost:3000`. Its 404 answer now names the composed-artifact case and what to do.

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

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,31 @@
4545
*
4646
* ⛔ Not the retired `os publish` id (see `package/publish.ts`'s header), on
4747
* purpose: that id is documented as removed and must stay unresolvable.
48+
*
49+
* ## A self-hosted runtime target
50+
*
51+
* When `os environments use-runtime` made a self-hosted runtime (the Docker
52+
* image, Community or Enterprise) the active deploy target, the same command
53+
* with no flags deploys THERE instead (`--env` still names a cloud environment
54+
* and deploys to it). There is no catalog and no version to mint: the build is
55+
* sent inline to the runtime's install-local door — `os package install` itself,
56+
* run in this process with the remembered url and key, so its refusals and
57+
* answers stay in ONE place:
58+
*
59+
* 1. build (`compileForDeploy`), as above;
60+
* 2. the dry run — the runtime's package upgrade plan of the installed
61+
* version against this build, printed; nothing is installed. A runtime
62+
* that refuses it, or does not serve install-local, stops the deploy here
63+
* with its sentence and exit 1;
64+
* 3. the install, with `--confirm-access-upgrade` passed through; the door's
65+
* refusals (an access upgrade to confirm, …) stop the deploy with exit 1;
66+
* 4. the answer — install-local registers the package in the live kernel
67+
* before it answers, so the answer IS "live": `hotLoaded` ⇒ 0; a package
68+
* the running kernel could not load ⇒ 1, naming the restart it waits for.
69+
*
70+
* Whether an upgrade is refused for a rename or a type change (epic
71+
* cloud#3652 Q3) is install-local's verdict, not this command's: a refusal it
72+
* answers is printed and stops the deploy like any other.
4873
*/
4974

5075
import { readFile } from 'node:fs/promises';
@@ -56,7 +81,10 @@ import { describeEnvironment, resolveInstallTarget } from '../utils/install-targ
5681
import { compileForDeploy } from '../utils/deploy-build.js';
5782
import { devVersion } from '../utils/dev-version.js';
5883
import { fetchEnvironmentOrigin, waitUntilServed, wakeEnvironment, type ServedVersionVerdict } from '../utils/served-version.js';
84+
import { activeRuntimeTarget } from '../utils/runtime-target.js';
85+
import type { RuntimeTarget } from '../utils/cloud-config.js';
5986
import PackagePublish from './package/publish.js';
87+
import PackageInstall from './package/install.js';
6088

6189
/** The artifact the build writes and the publish reads — `os build`'s default output. */
6290
export const DEPLOY_ARTIFACT = 'dist/objectstack.json';
@@ -66,12 +94,14 @@ export const DEPLOY_POLL_INTERVAL_MS = 3_000;
6694

6795
export default class Deploy extends Command {
6896
static override description =
69-
'Deploy this app to your ObjectOS Cloud environment: build it, publish a dev version, install it, and wait until it is live';
97+
'Deploy this app to your ObjectOS Cloud environment (build it, publish a dev version, install it, and wait until '
98+
+ 'it is live), or to the self-hosted runtime `os environments use-runtime` made the active target';
7099

71100
static override examples = [
72101
'$ os deploy',
73102
'$ os deploy --env env_abc123 # your account reaches several environments',
74103
'$ os deploy --no-wait # return once the install is recorded',
104+
'$ os deploy --confirm-access-upgrade <finding-set> # self-hosted: confirm the findings the runtime printed',
75105
];
76106

77107
static override flags = {
@@ -105,13 +135,34 @@ export default class Deploy extends Command {
105135
description: 'API key for ObjectOS Cloud (bearer token; defaults to OS_CLOUD_API_KEY / OS_TOKEN, then `os cloud login`)',
106136
env: 'OS_CLOUD_API_KEY',
107137
}),
138+
'confirm-access-upgrade': Flags.string({
139+
description:
140+
'Self-hosted runtime target: confirm the access findings of this upgrade with the finding-set token the '
141+
+ "runtime's refusal printed (as `os package install --confirm-access-upgrade`)",
142+
}),
108143
};
109144

110145
async run(): Promise<void> {
111146
const { flags } = await this.parse(Deploy);
112147
printHeader('Deploy');
113148

114149
try {
150+
// 0. Target: the self-hosted runtime when it is the active one and no
151+
// cloud environment was named.
152+
const runtime = flags.env ? undefined : await activeRuntimeTarget();
153+
if (runtime) {
154+
await this.deployToRuntime(runtime, flags['confirm-access-upgrade']);
155+
return;
156+
}
157+
if (flags['confirm-access-upgrade'] !== undefined) {
158+
printError(
159+
'`--confirm-access-upgrade` confirms a self-hosted runtime\'s refusal; the deploy target is ObjectOS Cloud. '
160+
+ 'Drop the flag, or record the runtime with `os environments use-runtime <url> --token <key>`.',
161+
);
162+
this.exit(1);
163+
return;
164+
}
165+
115166
// 1. Credential.
116167
const { token, baseUrl } = await resolveCloudCredential({ token: flags.token, server: flags.server });
117168
if (!token) {
@@ -229,6 +280,59 @@ export default class Deploy extends Command {
229280
this.exit(1);
230281
}
231282
}
283+
284+
/**
285+
* The self-hosted half (the header's "A self-hosted runtime target"):
286+
* build → dry run (plan printed) → install → the `hotLoaded` answer.
287+
*/
288+
private async deployToRuntime(runtime: RuntimeTarget, confirmAccessUpgrade: string | undefined): Promise<void> {
289+
printStep(`Deploying to the self-hosted runtime ${runtime.url} (os environments use-runtime)`);
290+
291+
printStep('Building...');
292+
const artifactPath = resolvePath(process.cwd(), DEPLOY_ARTIFACT);
293+
if (compileForDeploy(artifactPath) !== 0) {
294+
printError('The build failed — fix the errors above, then run `os deploy` again. Nothing was installed.');
295+
this.exit(1);
296+
return;
297+
}
298+
299+
const install = async (extra: string[]) => {
300+
const command = new PackageInstall(
301+
[artifactPath, '--url', runtime.url, '--token', runtime.token, ...extra],
302+
this.config,
303+
);
304+
command.retryCommand = 'os deploy';
305+
command.embedded = true;
306+
await command.run();
307+
return command.outcome;
308+
};
309+
310+
console.log('');
311+
printStep(`Asking ${runtime.url} for the upgrade plan (dry run — nothing is installed)...`);
312+
const plan = await install(['--dry-run']);
313+
if (plan?.kind !== 'dry-run') {
314+
printError(`${runtime.url} did not answer the dry run with a plan, so nothing more was sent. Upgrade the runtime, then run \`os deploy\` again.`);
315+
this.exit(1);
316+
return;
317+
}
318+
319+
printStep(`Installing ${plan.manifestId} ${plan.version} into ${runtime.url}...`);
320+
const outcome = await install(confirmAccessUpgrade !== undefined ? ['--confirm-access-upgrade', confirmAccessUpgrade] : []);
321+
if (outcome?.kind !== 'installed') {
322+
printError(`${runtime.url} did not answer the install as a managed package install, so this cannot say ${plan.manifestId} is live (see above).`);
323+
this.exit(1);
324+
return;
325+
}
326+
if (!outcome.hotLoaded) {
327+
printError(
328+
`${runtime.url} recorded ${outcome.manifestId} ${outcome.version}, but its running kernel could not load it, so `
329+
+ 'it is not live: it loads when the runtime restarts. Fix the cause above, then run `os deploy` again.',
330+
);
331+
this.exit(1);
332+
return;
333+
}
334+
printSuccess(`Live: ${runtime.url} now serves ${outcome.manifestId} ${outcome.version}.`);
335+
}
232336
}
233337

234338
/** Print the wait's verdict; return the exit code it means. */

‎packages/cli/src/commands/environments/switch.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { printError } from '../../utils/format.js';
55
import { createControlPlaneApiClient, requireControlPlaneAuth } from '../../utils/api-client.js';
66
import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js';
77
import { recordCloudActiveEnvironmentId } from '../../utils/active-environment.js';
8+
import { activateCloudDeployTarget } from '../../utils/runtime-target.js';
89

910
/**
1011
* `os environments switch <id>` — set the active environment for this CLI session.
@@ -24,6 +25,11 @@ import { recordCloudActiveEnvironmentId } from '../../utils/active-environment.j
2425
* uuid. Two files, two servers, an active environment for each — the id is
2526
* never carried across, because it would not resolve on the other side. The
2627
* gate lives in `utils/active-environment.ts`.
28+
*
29+
* Recorded there, it also makes ObjectOS Cloud the deploy target again when
30+
* `os environments use-runtime` had made a self-hosted runtime the target: the
31+
* two commands are the two ways to pick where `os deploy` goes, and the one
32+
* that ran last wins. The runtime stays remembered.
2733
*/
2834
export default class EnvironmentsSwitch extends Command {
2935
static override description = 'Activate an environment for subsequent CLI calls';
@@ -70,6 +76,7 @@ export default class EnvironmentsSwitch extends Command {
7076
// runtime store below (a user who only ran `os cloud login` has no
7177
// `credentials.json` at all).
7278
const recordedForCloud = await recordCloudActiveEnvironmentId(environment.id, baseUrl);
79+
const leftRuntime = recordedForCloud ? await activateCloudDeployTarget() : undefined;
7380

7481
// Runtime store: the copy `createApiClient` reads, so the `data` / `meta`
7582
// families keep targeting the environment you just switched to. Skipped
@@ -88,6 +95,12 @@ export default class EnvironmentsSwitch extends Command {
8895
if (recordedForCloud) {
8996
console.log(' (also recorded in cloud.json — `os package publish --install` will use it)');
9097
}
98+
if (leftRuntime !== undefined) {
99+
console.log(
100+
` Deploy target: this environment again, not the self-hosted runtime${leftRuntime ? ` ${leftRuntime}` : ''} `
101+
+ '(still remembered; `os environments use-runtime <url>` to go back)',
102+
);
103+
}
91104
if (!recordedForCloud && !cfg) {
92105
console.log(' ⚠ no local credential store to record it in — run `os login` or `os cloud login`');
93106
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Args, Command, Flags } from '@oclif/core';
4+
import { isExitSignal, printError, printStep, printSuccess } from '../../utils/format.js';
5+
import { forgetRuntimeTarget, recordRuntimeTarget } from '../../utils/runtime-target.js';
6+
import { getCloudCredentialsPath } from '../../utils/cloud-config.js';
7+
8+
/**
9+
* `os environments use-runtime <url> --token <key>` — remember a SELF-HOSTED
10+
* runtime (ObjectStack's Docker image, Community or Enterprise) as the deploy
11+
* target, beside `os environments switch`, which picks an ObjectOS Cloud
12+
* environment. Whichever of the two ran last is the target `os deploy` and
13+
* `os dev`'s `d` key use; `os package install` falls back to this runtime when
14+
* `-u` / `-t` are not given.
15+
*
16+
* The url and an API key the runtime issued are stored in
17+
* `~/.objectstack/cloud.json` (0600), the file the cloud token lives in, as
18+
* their own record (`utils/cloud-config.ts` states the split; storage and
19+
* resolution are `utils/runtime-target.ts`). The key is sent only to this url.
20+
*
21+
* Before anything is written the key is tried on the runtime's install-local
22+
* door (`GET /api/v1/marketplace/install-local`, which answers only an
23+
* authenticated caller): a key the runtime rejects, a runtime that does not
24+
* serve the door, or one that cannot be reached is refused, naming why —
25+
* `--no-verify` records it anyway (a container that is not up yet).
26+
*
27+
* `--forget` drops the record; the cloud is the deploy target again.
28+
*/
29+
export default class EnvironmentsUseRuntime extends Command {
30+
static override description =
31+
'Remember a self-hosted runtime (url + API key) as the deploy target for `os deploy`, the `d` key in `os dev`, '
32+
+ 'and `os package install`';
33+
34+
static override examples = [
35+
'$ os environments use-runtime http://localhost:8080 --token osk_…',
36+
'$ OS_RUNTIME_TOKEN=osk_… os environments use-runtime https://objectos.example.com',
37+
'$ os environments use-runtime --forget # deploy to ObjectOS Cloud again',
38+
];
39+
40+
static override args = {
41+
url: Args.string({ description: 'Base URL of the self-hosted runtime (e.g. http://localhost:8080)' }),
42+
};
43+
44+
static override flags = {
45+
token: Flags.string({
46+
char: 't',
47+
description: 'An API key the runtime issued (sent only to this runtime)',
48+
env: 'OS_RUNTIME_TOKEN',
49+
}),
50+
verify: Flags.boolean({
51+
description: 'Try the key on the runtime before recording it (default: on). Disable with --no-verify.',
52+
default: true,
53+
allowNo: true,
54+
}),
55+
forget: Flags.boolean({
56+
description: 'Forget the remembered runtime; ObjectOS Cloud is the deploy target again',
57+
default: false,
58+
}),
59+
};
60+
61+
async run(): Promise<void> {
62+
const { args, flags } = await this.parse(EnvironmentsUseRuntime);
63+
64+
try {
65+
if (flags.forget) {
66+
if (args.url) {
67+
printError('Pass either a runtime url or --forget, not both.');
68+
this.exit(1);
69+
return;
70+
}
71+
const forgotten = await forgetRuntimeTarget();
72+
console.log(forgotten
73+
? `\n✓ Forgot the self-hosted runtime ${forgotten.url}. \`os deploy\` deploys to ObjectOS Cloud again.\n`
74+
: '\n✓ No self-hosted runtime was remembered. `os deploy` deploys to ObjectOS Cloud.\n');
75+
return;
76+
}
77+
78+
const url = normalizeRuntimeUrl(args.url);
79+
if (!url.ok) {
80+
printError(url.message);
81+
this.exit(1);
82+
return;
83+
}
84+
if (!flags.token) {
85+
printError(
86+
'A self-hosted runtime needs an API key it issued: pass --token <key> (or set $OS_RUNTIME_TOKEN). '
87+
+ 'Create one in the runtime\'s Setup → API Keys.',
88+
);
89+
this.exit(1);
90+
return;
91+
}
92+
93+
if (flags.verify) {
94+
printStep(`Trying the key on ${url.value}...`);
95+
const probe = await probeRuntime(url.value, flags.token);
96+
if (!probe.ok) {
97+
printError(`${probe.message} Nothing was recorded. (Record it anyway with --no-verify.)`);
98+
this.exit(1);
99+
return;
100+
}
101+
}
102+
103+
await recordRuntimeTarget(url.value, flags.token);
104+
printSuccess(`Deploy target: the self-hosted runtime ${url.value}`);
105+
console.log(` Recorded in ${getCloudCredentialsPath()}. \`os deploy\` and the \`d\` key in \`os dev\` install there,`);
106+
console.log(' and `os package install` uses it when -u/-t are not given.');
107+
console.log(' Deploy to ObjectOS Cloud again: `os environments switch <id>` or `os environments use-runtime --forget`.');
108+
console.log('');
109+
} catch (error: any) {
110+
if (isExitSignal(error)) throw error;
111+
printError(error?.message || String(error));
112+
this.exit(1);
113+
}
114+
}
115+
}
116+
117+
/** An http(s) url with no query or fragment, trailing slashes dropped. */
118+
export function normalizeRuntimeUrl(raw: string | undefined): { ok: true; value: string } | { ok: false; message: string } {
119+
const trimmed = raw?.trim();
120+
if (!trimmed) {
121+
return { ok: false, message: 'Name the runtime: `os environments use-runtime <url> --token <key>` (or --forget).' };
122+
}
123+
let parsed: URL;
124+
try {
125+
parsed = new URL(trimmed);
126+
} catch {
127+
return { ok: false, message: `'${trimmed}' is not a url. Pass the runtime's base url, e.g. http://localhost:8080.` };
128+
}
129+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
130+
return { ok: false, message: `'${trimmed}' is not an http(s) url. Pass the runtime's base url, e.g. http://localhost:8080.` };
131+
}
132+
if (parsed.search || parsed.hash) {
133+
return { ok: false, message: `'${trimmed}' carries a query or fragment. Pass the runtime's base url only.` };
134+
}
135+
return { ok: true, value: `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, '')}` };
136+
}
137+
138+
/**
139+
* One authenticated read of the install-local door — the door `os deploy`
140+
* will POST to. Never throws.
141+
*/
142+
export async function probeRuntime(
143+
url: string,
144+
token: string,
145+
timeoutMs = 15_000,
146+
): Promise<{ ok: true } | { ok: false; message: string }> {
147+
const controller = new AbortController();
148+
const timer = setTimeout(() => controller.abort(), timeoutMs);
149+
try {
150+
const response = await fetch(`${url}/api/v1/marketplace/install-local`, {
151+
method: 'GET',
152+
headers: { Authorization: `Bearer ${token}` },
153+
signal: controller.signal,
154+
});
155+
if (response.ok) return { ok: true };
156+
if (response.status === 401 || response.status === 403) {
157+
return {
158+
ok: false,
159+
message: `${url} rejected the key (${response.status}). Pass an API key this runtime issued (Setup → API Keys).`,
160+
};
161+
}
162+
if (response.status === 404) {
163+
return {
164+
ok: false,
165+
message:
166+
`${url} does not serve install-local (GET /api/v1/marketplace/install-local answered 404), so it cannot `
167+
+ 'take a deploy. A runtime booted from a composed artifact takes no package installs; any other runtime '
168+
+ 'must mount MarketplaceInstallLocalPlugin (@objectstack/cloud-connection).',
169+
};
170+
}
171+
return { ok: false, message: `${url} answered ${response.status} ${response.statusText} to an authenticated read.` };
172+
} catch (err: any) {
173+
const why = err?.name === 'AbortError' ? `no answer within ${Math.round(timeoutMs / 1000)}s` : (err?.message ?? String(err));
174+
return { ok: false, message: `${url} cannot be reached (${why}). Is the runtime running, and is this its url?` };
175+
} finally {
176+
clearTimeout(timer);
177+
}
178+
}

0 commit comments

Comments
 (0)