From 0e3e03cdaec640334c9ffee53274875cc46664e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:37:02 +0000 Subject: [PATCH 1/3] wip(cli): os deploy to a remembered self-hosted runtime Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dLQEXWGsE2SZ3Qn5F --- packages/cli/src/commands/deploy.ts | 106 ++++++++++- .../cli/src/commands/environments/switch.ts | 13 ++ .../src/commands/environments/use-runtime.ts | 178 ++++++++++++++++++ packages/cli/src/commands/package/install.ts | 89 +++++++-- packages/cli/src/utils/cloud-config.ts | 144 +++++++++++--- packages/cli/src/utils/dev-deploy-key.ts | 23 ++- packages/cli/src/utils/runtime-target.ts | 108 +++++++++++ 7 files changed, 617 insertions(+), 44 deletions(-) create mode 100644 packages/cli/src/commands/environments/use-runtime.ts create mode 100644 packages/cli/src/utils/runtime-target.ts diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index cb812fa82bc..a9b5ace7615 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -45,6 +45,31 @@ * * ⛔ Not the retired `os publish` id (see `package/publish.ts`'s header), on * purpose: that id is documented as removed and must stay unresolvable. + * + * ## A self-hosted runtime target + * + * When `os environments use-runtime` made a self-hosted runtime (the Docker + * image, Community or Enterprise) the active deploy target, the same command + * with no flags deploys THERE instead (`--env` still names a cloud environment + * and deploys to it). There is no catalog and no version to mint: the build is + * sent inline to the runtime's install-local door — `os package install` itself, + * run in this process with the remembered url and key, so its refusals and + * answers stay in ONE place: + * + * 1. build (`compileForDeploy`), as above; + * 2. the dry run — the runtime's package upgrade plan of the installed + * 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` passed through; the door's + * refusals (an access upgrade to confirm, …) stop the deploy with exit 1; + * 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. + * + * Whether an upgrade is refused for a rename or a type change (epic + * cloud#3652 Q3) is install-local's verdict, not this command's: a refusal it + * answers is printed and stops the deploy like any other. */ import { readFile } from 'node:fs/promises'; @@ -56,7 +81,10 @@ import { describeEnvironment, resolveInstallTarget } from '../utils/install-targ import { compileForDeploy } from '../utils/deploy-build.js'; import { devVersion } from '../utils/dev-version.js'; import { fetchEnvironmentOrigin, waitUntilServed, wakeEnvironment, type ServedVersionVerdict } from '../utils/served-version.js'; +import { activeRuntimeTarget } from '../utils/runtime-target.js'; +import type { RuntimeTarget } from '../utils/cloud-config.js'; import PackagePublish from './package/publish.js'; +import PackageInstall from './package/install.js'; /** The artifact the build writes and the publish reads — `os build`'s default output. */ export const DEPLOY_ARTIFACT = 'dist/objectstack.json'; @@ -66,12 +94,14 @@ export const DEPLOY_POLL_INTERVAL_MS = 3_000; 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 it is live'; + 'Deploy this app to your ObjectOS Cloud environment (build it, publish a dev version, install it, and wait until ' + + 'it is live), or to the self-hosted runtime `os environments use-runtime` made the active target'; static override examples = [ '$ 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-access-upgrade # self-hosted: confirm the findings the runtime printed', ]; static override flags = { @@ -105,6 +135,11 @@ 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', }), + 'confirm-access-upgrade': Flags.string({ + description: + 'Self-hosted runtime target: confirm the access findings of this upgrade with the finding-set token the ' + + "runtime's refusal printed (as `os package install --confirm-access-upgrade`)", + }), }; async run(): Promise { @@ -112,6 +147,22 @@ export default class Deploy extends Command { printHeader('Deploy'); try { + // 0. Target: the self-hosted runtime when it is the active one and no + // cloud environment was named. + const runtime = flags.env ? undefined : await activeRuntimeTarget(); + if (runtime) { + await this.deployToRuntime(runtime, flags['confirm-access-upgrade']); + return; + } + if (flags['confirm-access-upgrade'] !== undefined) { + printError( + '`--confirm-access-upgrade` 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 --token `.', + ); + this.exit(1); + return; + } + // 1. Credential. const { token, baseUrl } = await resolveCloudCredential({ token: flags.token, server: flags.server }); if (!token) { @@ -229,6 +280,59 @@ export default class Deploy extends Command { this.exit(1); } } + + /** + * 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): Promise { + printStep(`Deploying to the self-hosted runtime ${runtime.url} (os environments use-runtime)`); + + printStep('Building...'); + const artifactPath = resolvePath(process.cwd(), DEPLOY_ARTIFACT); + if (compileForDeploy(artifactPath) !== 0) { + printError('The build failed — fix the errors above, then run `os deploy` again. Nothing was installed.'); + this.exit(1); + return; + } + + const install = async (extra: string[]) => { + const command = new PackageInstall( + [artifactPath, '--url', runtime.url, '--token', runtime.token, ...extra], + this.config, + ); + command.retryCommand = 'os deploy'; + command.embedded = true; + await command.run(); + return command.outcome; + }; + + console.log(''); + printStep(`Asking ${runtime.url} for the upgrade plan (dry run — nothing is installed)...`); + const plan = await install(['--dry-run']); + if (plan?.kind !== 'dry-run') { + 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.`); + this.exit(1); + return; + } + + printStep(`Installing ${plan.manifestId} ${plan.version} into ${runtime.url}...`); + const outcome = await install(confirmAccessUpgrade !== undefined ? ['--confirm-access-upgrade', confirmAccessUpgrade] : []); + 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); + return; + } + if (!outcome.hotLoaded) { + printError( + `${runtime.url} recorded ${outcome.manifestId} ${outcome.version}, but its running kernel could not load it, so ` + + 'it is not live: it loads when the runtime restarts. Fix the cause above, then run `os deploy` again.', + ); + this.exit(1); + return; + } + printSuccess(`Live: ${runtime.url} now serves ${outcome.manifestId} ${outcome.version}.`); + } } /** Print the wait's verdict; return the exit code it means. */ diff --git a/packages/cli/src/commands/environments/switch.ts b/packages/cli/src/commands/environments/switch.ts index d1326d07d1f..fdc4dac7543 100644 --- a/packages/cli/src/commands/environments/switch.ts +++ b/packages/cli/src/commands/environments/switch.ts @@ -5,6 +5,7 @@ import { printError } from '../../utils/format.js'; import { createControlPlaneApiClient, requireControlPlaneAuth } from '../../utils/api-client.js'; import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; import { recordCloudActiveEnvironmentId } from '../../utils/active-environment.js'; +import { activateCloudDeployTarget } from '../../utils/runtime-target.js'; /** * `os environments switch ` — set the active environment for this CLI session. @@ -24,6 +25,11 @@ import { recordCloudActiveEnvironmentId } from '../../utils/active-environment.j * uuid. Two files, two servers, an active environment for each — the id is * never carried across, because it would not resolve on the other side. The * gate lives in `utils/active-environment.ts`. + * + * Recorded there, it also makes ObjectOS Cloud the deploy target again when + * `os environments use-runtime` had made a self-hosted runtime the target: the + * two commands are the two ways to pick where `os deploy` goes, and the one + * that ran last wins. The runtime stays remembered. */ export default class EnvironmentsSwitch extends Command { static override description = 'Activate an environment for subsequent CLI calls'; @@ -70,6 +76,7 @@ export default class EnvironmentsSwitch extends Command { // runtime store below (a user who only ran `os cloud login` has no // `credentials.json` at all). const recordedForCloud = await recordCloudActiveEnvironmentId(environment.id, baseUrl); + const leftRuntime = recordedForCloud ? await activateCloudDeployTarget() : undefined; // Runtime store: the copy `createApiClient` reads, so the `data` / `meta` // families keep targeting the environment you just switched to. Skipped @@ -88,6 +95,12 @@ export default class EnvironmentsSwitch extends Command { if (recordedForCloud) { console.log(' (also recorded in cloud.json — `os package publish --install` will use it)'); } + if (leftRuntime !== undefined) { + console.log( + ` Deploy target: this environment again, not the self-hosted runtime${leftRuntime ? ` ${leftRuntime}` : ''} ` + + '(still remembered; `os environments use-runtime ` to go back)', + ); + } if (!recordedForCloud && !cfg) { console.log(' ⚠ no local credential store to record it in — run `os login` or `os cloud login`'); } diff --git a/packages/cli/src/commands/environments/use-runtime.ts b/packages/cli/src/commands/environments/use-runtime.ts new file mode 100644 index 00000000000..e6b1387608f --- /dev/null +++ b/packages/cli/src/commands/environments/use-runtime.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Args, Command, Flags } from '@oclif/core'; +import { isExitSignal, printError, printStep, printSuccess } from '../../utils/format.js'; +import { forgetRuntimeTarget, recordRuntimeTarget } from '../../utils/runtime-target.js'; +import { getCloudCredentialsPath } from '../../utils/cloud-config.js'; + +/** + * `os environments use-runtime --token ` — remember a SELF-HOSTED + * runtime (ObjectStack's Docker image, Community or Enterprise) as the deploy + * target, beside `os environments switch`, which picks an ObjectOS Cloud + * environment. Whichever of the two ran last is the target `os deploy` and + * `os dev`'s `d` key use; `os package install` falls back to this runtime when + * `-u` / `-t` are not given. + * + * The url and an API key the runtime issued are stored in + * `~/.objectstack/cloud.json` (0600), the file the cloud token lives in, as + * their own record (`utils/cloud-config.ts` states the split; storage and + * resolution are `utils/runtime-target.ts`). The key is sent only to this url. + * + * Before anything is written the key is tried on the runtime's install-local + * door (`GET /api/v1/marketplace/install-local`, which answers only an + * authenticated caller): a key the runtime rejects, a runtime that does not + * serve the door, or one that cannot be reached is refused, naming why — + * `--no-verify` records it anyway (a container that is not up yet). + * + * `--forget` drops the record; the cloud is the deploy target again. + */ +export default class EnvironmentsUseRuntime extends Command { + static override description = + 'Remember a self-hosted runtime (url + API key) as the deploy target for `os deploy`, the `d` key in `os dev`, ' + + 'and `os package install`'; + + static override examples = [ + '$ os environments use-runtime http://localhost:8080 --token osk_…', + '$ OS_RUNTIME_TOKEN=osk_… os environments use-runtime https://objectos.example.com', + '$ os environments use-runtime --forget # deploy to ObjectOS Cloud again', + ]; + + static override args = { + url: Args.string({ description: 'Base URL of the self-hosted runtime (e.g. http://localhost:8080)' }), + }; + + static override flags = { + token: Flags.string({ + char: 't', + description: 'An API key the runtime issued (sent only to this runtime)', + env: 'OS_RUNTIME_TOKEN', + }), + verify: Flags.boolean({ + description: 'Try the key on the runtime before recording it (default: on). Disable with --no-verify.', + default: true, + allowNo: true, + }), + forget: Flags.boolean({ + description: 'Forget the remembered runtime; ObjectOS Cloud is the deploy target again', + default: false, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(EnvironmentsUseRuntime); + + try { + if (flags.forget) { + if (args.url) { + printError('Pass either a runtime url or --forget, not both.'); + this.exit(1); + return; + } + const forgotten = await forgetRuntimeTarget(); + console.log(forgotten + ? `\n✓ Forgot the self-hosted runtime ${forgotten.url}. \`os deploy\` deploys to ObjectOS Cloud again.\n` + : '\n✓ No self-hosted runtime was remembered. `os deploy` deploys to ObjectOS Cloud.\n'); + return; + } + + const url = normalizeRuntimeUrl(args.url); + if (!url.ok) { + printError(url.message); + this.exit(1); + return; + } + if (!flags.token) { + printError( + 'A self-hosted runtime needs an API key it issued: pass --token (or set $OS_RUNTIME_TOKEN). ' + + 'Create one in the runtime\'s Setup → API Keys.', + ); + this.exit(1); + return; + } + + if (flags.verify) { + printStep(`Trying the key on ${url.value}...`); + const probe = await probeRuntime(url.value, flags.token); + if (!probe.ok) { + printError(`${probe.message} Nothing was recorded. (Record it anyway with --no-verify.)`); + this.exit(1); + return; + } + } + + await recordRuntimeTarget(url.value, flags.token); + printSuccess(`Deploy target: the self-hosted runtime ${url.value}`); + console.log(` Recorded in ${getCloudCredentialsPath()}. \`os deploy\` and the \`d\` key in \`os dev\` install there,`); + console.log(' and `os package install` uses it when -u/-t are not given.'); + console.log(' Deploy to ObjectOS Cloud again: `os environments switch ` or `os environments use-runtime --forget`.'); + console.log(''); + } catch (error: any) { + if (isExitSignal(error)) throw error; + printError(error?.message || String(error)); + this.exit(1); + } + } +} + +/** An http(s) url with no query or fragment, trailing slashes dropped. */ +export function normalizeRuntimeUrl(raw: string | undefined): { ok: true; value: string } | { ok: false; message: string } { + const trimmed = raw?.trim(); + if (!trimmed) { + return { ok: false, message: 'Name the runtime: `os environments use-runtime --token ` (or --forget).' }; + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return { ok: false, message: `'${trimmed}' is not a url. Pass the runtime's base url, e.g. http://localhost:8080.` }; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, message: `'${trimmed}' is not an http(s) url. Pass the runtime's base url, e.g. http://localhost:8080.` }; + } + if (parsed.search || parsed.hash) { + return { ok: false, message: `'${trimmed}' carries a query or fragment. Pass the runtime's base url only.` }; + } + return { ok: true, value: `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, '')}` }; +} + +/** + * One authenticated read of the install-local door — the door `os deploy` + * will POST to. Never throws. + */ +export async function probeRuntime( + url: string, + token: string, + timeoutMs = 15_000, +): Promise<{ ok: true } | { ok: false; message: string }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${url}/api/v1/marketplace/install-local`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + if (response.ok) return { ok: true }; + if (response.status === 401 || response.status === 403) { + return { + ok: false, + message: `${url} rejected the key (${response.status}). Pass an API key this runtime issued (Setup → API Keys).`, + }; + } + if (response.status === 404) { + return { + ok: false, + message: + `${url} does not serve install-local (GET /api/v1/marketplace/install-local answered 404), so it cannot ` + + 'take a deploy. A runtime booted from a composed artifact takes no package installs; any other runtime ' + + 'must mount MarketplaceInstallLocalPlugin (@objectstack/cloud-connection).', + }; + } + return { ok: false, message: `${url} answered ${response.status} ${response.statusText} to an authenticated read.` }; + } catch (err: any) { + const why = err?.name === 'AbortError' ? `no answer within ${Math.round(timeoutMs / 1000)}s` : (err?.message ?? String(err)); + return { ok: false, message: `${url} cannot be reached (${why}). Is the runtime running, and is this its url?` }; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts index 8d62991ed2c..365e5b7ae33 100644 --- a/packages/cli/src/commands/package/install.ts +++ b/packages/cli/src/commands/package/install.ts @@ -61,6 +61,16 @@ * `-u/--url` and `-t/--token`. `-r/--runtime` is retired with no alias — the * startup rule keeps no window for an old spelling — so `os package install * -r URL` is refused by the parser as an unknown flag and the fix is `-u URL`. + * + * With neither `-u` nor `$OS_RUNTIME_URL`, the server is the self-hosted + * runtime `os environments use-runtime` remembered, and its API key is the + * credential when no `-t` / `--email` is given — sent only to that runtime's + * own url (`utils/runtime-target.ts`). Nothing remembered: `http://localhost:3000`. + * + * `os deploy` runs this command in-process for a self-hosted target — the dry + * run, then the install — so every refusal and answer below is printed once, + * here, for both; it reads {@link PackageInstall.outcome} and names itself in + * the retry lines through {@link PackageInstall.retryCommand}. */ import { readFile } from 'node:fs/promises'; @@ -69,6 +79,13 @@ import { resolve as resolvePath } from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; import { INSTALL_MODES, customizationBasePackageId, type InstallMode } from '@objectstack/spec/kernel'; import { printHeader, printKV, printSuccess, printWarning, printError, printStep, isExitSignal } from '../../utils/format.js'; +import { DEFAULT_RUNTIME_INSTALL_URL, runtimeInstallTarget } from '../../utils/runtime-target.js'; + +/** What an install answered, for an in-process caller (`os deploy`). Unset until the runtime answered 2xx. */ +export type PackageInstallOutcome = + | { kind: 'dry-run'; manifestId: string; version: string; installedVersion: string | null } + | { kind: 'installed'; manifestId: string; version: string; hotLoaded: boolean; runtime: string } + | { kind: 'other'; manifestId: string }; /** * [#28640 ruling ③A] The connector maps the package export withheld whole, @@ -199,15 +216,20 @@ export default class PackageInstall extends Command { }; static override flags = { + // No oclif `default`: an absent flag must be told apart from a typed one, + // so the remembered runtime can fill it (`utils/runtime-target.ts`). url: Flags.string({ char: 'u', - description: 'Server URL of the runtime to install INTO', + description: + 'Server URL of the runtime to install INTO (default: the runtime `os environments use-runtime` remembered, ' + + `else ${DEFAULT_RUNTIME_INSTALL_URL})`, env: 'OS_RUNTIME_URL', - default: 'http://localhost:3000', }), token: Flags.string({ char: 't', - description: 'Authentication token issued by the target runtime (an API key); instead of --email/--password', + description: + 'Authentication token issued by the target runtime (an API key); instead of --email/--password. Defaults to ' + + 'the key `os environments use-runtime` remembered, when the URL is that runtime', env: 'OS_RUNTIME_TOKEN', }), version: Flags.string({ @@ -275,11 +297,20 @@ export default class PackageInstall extends Command { }), }; + /** What the runtime answered — read by `os deploy`, which runs this command in-process. */ + outcome: PackageInstallOutcome | undefined; + + /** The command a retry line names (`os deploy` sets its own); defaults to this command with its argument. */ + retryCommand: string | undefined; + + /** Set by an in-process caller that prints its own header. */ + embedded = false; + async run(): Promise { const { args, flags } = await this.parse(PackageInstall); + const retry = this.retryCommand ?? `os package install ${args.package}`; - printHeader('Install Package'); - const runtime = flags.url.replace(/\/+$/, ''); + if (!this.embedded) printHeader('Install Package'); // One credential per call: a token and a password sign-in name two // identities, and picking one silently would install as whichever the @@ -294,6 +325,17 @@ export default class PackageInstall extends Command { } try { + const target = await runtimeInstallTarget({ + url: flags.url, + token: flags.token, + useStoredToken: !flags.email && !flags.password, + }); + const runtime = target.url; + const token = target.token; + if (target.rememberedUrl || target.rememberedToken) { + printStep(`Runtime ${runtime} — remembered by \`os environments use-runtime\``); + } + // ---- Resolve the source: inline artifact file vs catalog id --------- const looksLikeFile = args.package.endsWith('.json') || args.package.startsWith('./') @@ -340,7 +382,7 @@ export default class PackageInstall extends Command { // ---- Authenticate against the TARGET runtime ------------------------ let cookie: string | undefined; - if (flags.token) { + if (token) { printStep(`Authenticating to ${runtime} with a token`); } else if (flags.email && flags.password) { printStep(`Signing in to ${runtime} as ${flags.email}...`); @@ -377,7 +419,7 @@ export default class PackageInstall extends Command { headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}), - ...(flags.token ? { Authorization: `Bearer ${flags.token}` } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(body), }, flags.timeout); @@ -395,7 +437,7 @@ export default class PackageInstall extends Command { } console.log(''); console.log(' Confirm them as genuinely platform-wide:'); - console.log(` os package install ${args.package} --confirm-global-uniques`); + console.log(` ${retry} --confirm-global-uniques`); console.log(" …or edit the app's metadata to `unique: 'organization'` and rebuild."); this.exit(1); return; @@ -410,7 +452,7 @@ export default class PackageInstall extends Command { for (const finding of findings) console.log(` • ${finding.describe ?? JSON.stringify(finding)}`); console.log(''); console.log(' Review each finding. To upgrade anyway, confirm exactly these findings:'); - console.log(` os package install ${args.package} --confirm-access-upgrade ${details.findingSet}`); + console.log(` ${retry} --confirm-access-upgrade ${details.findingSet}`); this.exit(1); return; } @@ -418,12 +460,18 @@ export default class PackageInstall extends Command { printError( 'The runtime rejected the call as unauthenticated. Pass --token (or OS_RUNTIME_TOKEN) with a ' + 'token the target runtime issued, or --email/--password (or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) ' + - 'for an account on the target runtime.', + 'for an account on the target runtime, or record one with `os environments use-runtime --token `.', ); } else if (res.status === 404) { + // The door is absent, not refusing: a runtime booted from a composed + // artifact takes no package installs (ObjectOS Enterprise declines + // install-local in that mode), and any other host may simply not + // mount it. Both are named, with what to do — nothing was installed. printError( - `install-local endpoint not found on ${runtime}. The target runtime must mount ` + - 'MarketplaceInstallLocalPlugin (see @objectstack/cloud-connection).', + `${runtime} does not serve install-local (POST /api/v1/marketplace/install-local answered 404), so ` + + 'nothing was installed. A runtime booted from a composed artifact takes no package installs: rebuild ' + + 'its artifact with this package in it and restart it. Any other runtime must mount ' + + 'MarketplaceInstallLocalPlugin (@objectstack/cloud-connection) to take installs.', ); } else { // [#21489] The runtime's refusal is printed with its CODE beside the @@ -444,6 +492,12 @@ export default class PackageInstall extends Command { // [#30884] The dry run's answer: the plan, and nothing installed. if (data.dryRun === true) { + this.outcome = { + kind: 'dry-run', + manifestId: String(data.manifestId ?? label), + version: String(data.version ?? flags.version), + installedVersion: typeof data.installedVersion === 'string' ? data.installedVersion : null, + }; console.log(''); printSuccess('Dry run — nothing was installed or written'); printKV(' Package', String(data.manifestId ?? label)); @@ -460,7 +514,8 @@ export default class PackageInstall extends Command { if (flags['dry-run']) { printError( `The runtime at ${runtime} does not support a dry run: it ignored \`--dry-run\` and INSTALLED ${label}. ` - + 'Upgrade the runtime to preview an upgrade before it is installed.', + + 'Upgrade the runtime to preview an upgrade before it is installed' + + `${this.retryCommand ? `, then run \`${this.retryCommand}\` again` : ''}.`, ); this.exit(1); return; @@ -470,6 +525,7 @@ export default class PackageInstall extends Command { // its own shape too: the writable pair its items were written into, // never a kernel registration or a cached manifest — so, as for an // unmanaged install, none of the managed lines below is true of it. + this.outcome = { kind: 'other', manifestId: String(data.manifestId ?? label) }; const pair = data.customizationPair; if (pair && typeof pair === 'object' && typeof pair.packageId === 'string') { console.log(''); @@ -570,6 +626,13 @@ export default class PackageInstall extends Command { // missing, blank or not a string prints no reason line at all. const notHotLoaded = data.hotLoaded === false; const hotLoadError = typeof data.hotLoadError === 'string' ? data.hotLoadError.trim() : ''; + this.outcome = { + kind: 'installed', + manifestId: String(data.manifestId ?? data.packageId ?? label), + version: String(data.version ?? flags.version), + hotLoaded: !notHotLoaded, + runtime, + }; console.log(''); if (notHotLoaded) { printWarning( diff --git a/packages/cli/src/utils/cloud-config.ts b/packages/cli/src/utils/cloud-config.ts index 507f2718457..85d26ae3af8 100644 --- a/packages/cli/src/utils/cloud-config.ts +++ b/packages/cli/src/utils/cloud-config.ts @@ -27,6 +27,20 @@ * `createControlPlaneApiClient` in `api-client.ts`: `credentials.json`'s * session where it targets the server, else this file's — and for that * family this file's token goes only to this file's `url`. + * + * ## The deploy target — this file's second half + * + * `os deploy` (and `os dev`'s `d` key, which runs it) installs into ONE target: + * the ObjectOS Cloud environment this file's session reaches, or a self-hosted + * runtime `os environments use-runtime` recorded — its url and an API key that + * runtime issued, in `runtimeTarget`. Which one is active is `deployTarget`. + * The key lives here, in this protected (0600) file, beside the cloud token, + * but in its OWN named record: no cloud read ({@link resolveCloudCredential}, + * `token`) ever sees it, and it is sent only to its own `url` + * (`runtime-target.ts`). The two halves outlive each other: `os cloud logout` + * clears the cloud session and keeps the runtime target, and a cloud login + * keeps it too — {@link writeCloudConfig} carries it across every cloud write, + * and only {@link writeDeployTargetRecord} changes it. */ import { chmod, readFile, unlink, writeFile } from 'node:fs/promises'; @@ -61,58 +75,136 @@ export interface CloudConfig { createdAt: string; /** ISO timestamp of last use (best-effort). */ lastUsedAt?: string; + /** The self-hosted runtime target — see the header; written only by {@link writeDeployTargetRecord}. */ + runtimeTarget?: RuntimeTarget; + /** Which target `os deploy` uses; absent means the cloud. Written only by {@link writeDeployTargetRecord}. */ + deployTarget?: DeployTargetKind; +} + +/** A self-hosted runtime `os deploy` installs into, recorded by `os environments use-runtime`. */ +export interface RuntimeTarget { + /** Base URL of the runtime (the one `os package install --url` would name). */ + url: string; + /** An API key the runtime issued — sent only to `url`. */ + token: string; + /** ISO timestamp when it was recorded. */ + recordedAt: string; } +/** The two targets `os deploy` knows. */ +export type DeployTargetKind = 'cloud' | 'runtime'; + +/** The deploy-target half of the file: everything a cloud write must carry across untouched. */ +export interface DeployTargetRecord { + runtimeTarget?: RuntimeTarget; + deployTarget?: DeployTargetKind; +} + +const DEPLOY_TARGET_KEYS = ['runtimeTarget', 'deployTarget'] as const; + export function getCloudCredentialsPath(): string { return join(homedir(), '.objectstack', 'cloud.json'); } -export async function readCloudConfig(): Promise { - const path = getCloudCredentialsPath(); +/** The file as stored, both halves; `{}` when there is none. */ +async function readCloudFile(): Promise> { try { - const content = await readFile(path, 'utf-8'); - return JSON.parse(content) as CloudConfig; + const parsed = JSON.parse(await readFile(getCloudCredentialsPath(), 'utf-8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch (error: any) { - if (error.code === 'ENOENT') { - throw new Error('Not logged in to ObjectOS Cloud. Run `os cloud login` first.'); - } + if (error.code === 'ENOENT') return {}; throw new Error(`Failed to read cloud credentials: ${error.message}`); } } -export async function tryReadCloudConfig(): Promise { +async function writeCloudFile(content: Record): Promise { + const path = getCloudCredentialsPath(); + if (Object.keys(content).length === 0) { + try { + await unlink(path); + } catch (error: any) { + if (error.code !== 'ENOENT') throw new Error(`Failed to delete cloud credentials: ${error.message}`); + } + return; + } + // Bounded (#23858): see `writeAuthConfig`. + makeDirectorySync(join(homedir(), '.objectstack')); + await writeFile(path, JSON.stringify(content, null, 2), { mode: 0o600 }); try { - return await readCloudConfig(); + await chmod(path, 0o600); } catch { - return undefined; + // Platforms without chmod support — silently continue. } } -export async function writeCloudConfig(config: CloudConfig): Promise { - const path = getCloudCredentialsPath(); - const dir = join(homedir(), '.objectstack'); - - // Bounded (#23858): see `writeAuthConfig`. - makeDirectorySync(dir); - await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 }); +/** For a writer: a file that will not parse carries nothing across — the write replaces it, as it always did. */ +async function readCloudFileForWrite(): Promise> { try { - await chmod(path, 0o600); + return await readCloudFile(); } catch { - // Platforms without chmod support — silently continue. + return {}; } } -export async function deleteCloudConfig(): Promise { - const path = getCloudCredentialsPath(); +function deployTargetHalf(content: Record): DeployTargetRecord { + const half: Record = {}; + for (const key of DEPLOY_TARGET_KEYS) if (content[key] !== undefined) half[key] = content[key]; + return half as DeployTargetRecord; +} + +function withoutDeployTarget(content: Record): Record { + const rest = { ...content }; + for (const key of DEPLOY_TARGET_KEYS) delete rest[key]; + return rest; +} + +/** + * The cloud session. A file that holds only a deploy target (`os environments + * use-runtime` without `os cloud login`) holds no session: that reads as not + * logged in, exactly as no file does. + */ +export async function readCloudConfig(): Promise { + const content = await readCloudFile(); + if (typeof content.token !== 'string' || !content.token) { + throw new Error('Not logged in to ObjectOS Cloud. Run `os cloud login` first.'); + } + return content as CloudConfig; +} + +export async function tryReadCloudConfig(): Promise { try { - await unlink(path); - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw new Error(`Failed to delete cloud credentials: ${error.message}`); - } + return await readCloudConfig(); + } catch { + return undefined; } } +/** + * Write the cloud session. The deploy-target half on disk is carried across + * whatever `config` holds of it: a login builds a fresh record, and must not + * forget the self-hosted runtime on the way. + */ +export async function writeCloudConfig(config: CloudConfig): Promise { + const onDisk = deployTargetHalf(await readCloudFileForWrite()); + await writeCloudFile({ ...withoutDeployTarget(config), ...onDisk }); +} + +/** Clear the cloud session; a recorded deploy target stays (see the header). */ +export async function deleteCloudConfig(): Promise { + await writeCloudFile(deployTargetHalf(await readCloudFileForWrite())); +} + +/** The deploy-target half as stored; `{}` when nothing is recorded. */ +export async function readDeployTargetRecord(): Promise { + return deployTargetHalf(await readCloudFile()); +} + +/** Replace the deploy-target half; the cloud session on disk is kept as it is. */ +export async function writeDeployTargetRecord(record: DeployTargetRecord): Promise { + const content = withoutDeployTarget(await readCloudFileForWrite()); + await writeCloudFile({ ...content, ...deployTargetHalf(record as Record) }); +} + /** * The credential and control plane a cloud WRITE uses — `os package publish` * and `os deploy` alike, so the two cannot aim at different servers. diff --git a/packages/cli/src/utils/dev-deploy-key.ts b/packages/cli/src/utils/dev-deploy-key.ts index d4e3a6ce850..8534e0c4dc3 100644 --- a/packages/cli/src/utils/dev-deploy-key.ts +++ b/packages/cli/src/utils/dev-deploy-key.ts @@ -22,10 +22,18 @@ * * No TTY (piped stdin, CI, a backgrounded `os dev`): nothing is attached and * no line is printed — a key nobody can press is not offered. + * + * ## A self-hosted runtime target + * + * When `os environments use-runtime` made a self-hosted runtime the deploy + * target, the line names that runtime and the key runs the same `os deploy`, + * which installs there — no cloud login is asked for: the runtime's own key + * is the credential. */ import { spawn } from 'node:child_process'; import { tryReadCloudConfig } from './cloud-config.js'; +import { activeRuntimeTarget } from './runtime-target.js'; /** The slice of `process.stdin` this module uses — a fake in tests. */ export interface DeployKeyInput { @@ -47,6 +55,8 @@ export interface DeployKeyOptions { runLogin: () => Promise; /** Runs `os deploy`; resolves to its exit status. */ runDeploy: () => Promise; + /** The url of the self-hosted runtime that is the active deploy target, if one is; it needs no cloud login. */ + runtimeTarget?: () => Promise; /** Ctrl-C while the terminal is raw. */ onInterrupt: () => void; } @@ -60,8 +70,9 @@ export interface DeployKeyHandle { readonly current: Promise | undefined; } -/** The offer line, by whether a cloud credential is available. */ -export function deployKeyHint(loggedIn: boolean): string { +/** The offer line, by the target: a self-hosted runtime's url, else whether a cloud credential is available. */ +export function deployKeyHint(loggedIn: boolean, runtimeUrl?: string): string { + if (runtimeUrl) return `Press d to deploy this app to the self-hosted runtime ${runtimeUrl} (runs \`os deploy\`).`; return loggedIn ? 'Press d to deploy this app to your ObjectOS Cloud environment (runs `os deploy`).' : 'Press d to log in to ObjectOS Cloud and deploy this app (runs `os cloud login`, then `os deploy`).'; @@ -94,14 +105,17 @@ export function attachDeployKey(options: DeployKeyOptions): DeployKeyHandle | un listening = false; }; + const runtimeUrl = async () => (options.runtimeTarget ? options.runtimeTarget() : undefined); + const offer = async () => { - print(deployKeyHint(await options.isLoggedIn())); + const runtime = await runtimeUrl(); + print(deployKeyHint(runtime ? true : await options.isLoggedIn(), runtime)); }; const act = async () => { release(); try { - if (!(await options.isLoggedIn())) { + if (!(await runtimeUrl()) && !(await options.isLoggedIn())) { const login = await options.runLogin(); if (login !== 0 || !(await options.isLoggedIn())) { print('Not logged in to ObjectOS Cloud, so nothing was deployed. Press d to try again.'); @@ -171,6 +185,7 @@ export function startDevDeployKey(binPath: string, print: (line: string) => void isLoggedIn: hasCloudCredential, runLogin: () => runCliChild(binPath, ['cloud', 'login']), runDeploy: () => runCliChild(binPath, ['deploy']), + runtimeTarget: async () => (await activeRuntimeTarget().catch(() => undefined))?.url, onInterrupt: () => { process.kill(process.pid, 'SIGINT'); }, }); if (!handle) return undefined; diff --git a/packages/cli/src/utils/runtime-target.ts b/packages/cli/src/utils/runtime-target.ts new file mode 100644 index 00000000000..900b6fe7163 --- /dev/null +++ b/packages/cli/src/utils/runtime-target.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The self-hosted runtime a developer deploys into — ObjectStack's Docker image + * (Community) or ObjectOS Enterprise — remembered so nobody types its url and + * key on every deploy. + * + * ## One record, three readers + * + * `os environments use-runtime --token ` records it and makes it the + * active deploy target (`os environments switch` makes the cloud active again). + * Then: + * + * - `os deploy` (and `os dev`'s `d` key, which runs it) installs into it when + * it is the ACTIVE target ({@link activeRuntimeTarget}); + * - `os package install` falls back to it when `-u` / `-t` are not given, + * active or not — that command only ever talks to a runtime + * ({@link runtimeInstallTarget}). + * + * The record is stored by `cloud-config.ts` (its header: the deploy-target half + * of `~/.objectstack/cloud.json`, 0600); this module adds no second store. + * + * ## The key goes only to the runtime that issued it + * + * A `--url` naming another server never receives the stored key: the url + * identity is `isSameControlPlane` (`active-environment.ts`), the one + * comparison every stored-credential read in this CLI uses. + */ + +import { + readDeployTargetRecord, + writeDeployTargetRecord, + type RuntimeTarget, +} from './cloud-config.js'; +import { isSameControlPlane } from './active-environment.js'; + +/** `os package install`'s url when nothing names one. */ +export const DEFAULT_RUNTIME_INSTALL_URL = 'http://localhost:3000'; + +function usable(target: RuntimeTarget | undefined): RuntimeTarget | undefined { + if (!target || typeof target.url !== 'string' || !target.url.trim()) return undefined; + if (typeof target.token !== 'string' || !target.token) return undefined; + return { ...target, url: target.url.trim().replace(/\/+$/, '') }; +} + +/** The recorded runtime, active or not; undefined when none (or none usable) is recorded. */ +export async function storedRuntimeTarget(): Promise { + return usable((await readDeployTargetRecord()).runtimeTarget); +} + +/** The recorded runtime when it is the active deploy target. */ +export async function activeRuntimeTarget(): Promise { + const record = await readDeployTargetRecord(); + return record.deployTarget === 'runtime' ? usable(record.runtimeTarget) : undefined; +} + +/** Record `url` + `token` and make the runtime the active deploy target. */ +export async function recordRuntimeTarget(url: string, token: string, now: Date = new Date()): Promise { + const target: RuntimeTarget = { url: url.trim().replace(/\/+$/, ''), token, recordedAt: now.toISOString() }; + await writeDeployTargetRecord({ runtimeTarget: target, deployTarget: 'runtime' }); + return target; +} + +/** Forget the recorded runtime; the cloud becomes the deploy target. Returns what was forgotten. */ +export async function forgetRuntimeTarget(): Promise { + const previous = usable((await readDeployTargetRecord()).runtimeTarget); + await writeDeployTargetRecord({}); + return previous; +} + +/** + * Make the cloud the active deploy target, keeping the recorded runtime. + * Returns the runtime's url when it WAS the active target (the caller says so), + * else undefined, writing nothing. + */ +export async function activateCloudDeployTarget(): Promise { + const record = await readDeployTargetRecord(); + if (record.deployTarget !== 'runtime') return undefined; + await writeDeployTargetRecord({ ...record, deployTarget: 'cloud' }); + return record.runtimeTarget?.url ?? ''; +} + +/** + * `os package install`'s server and key: an explicit `--url` / `--token` (oclif + * fills them from `$OS_RUNTIME_URL` / `$OS_RUNTIME_TOKEN`) win; the stored + * runtime fills what they leave open, its key only when the url is its own; + * the url falls back to {@link DEFAULT_RUNTIME_INSTALL_URL}. + * + * `useStoredToken: false` when the caller authenticates otherwise + * (`--email` / `--password`), so two identities are never mixed. + */ +export async function runtimeInstallTarget(flags: { + url?: string; + token?: string; + useStoredToken: boolean; +}): Promise<{ url: string; token: string | undefined; rememberedUrl: boolean; rememberedToken: boolean }> { + const explicitUrl = flags.url?.trim() ? flags.url.trim().replace(/\/+$/, '') : undefined; + const wantsStored = !explicitUrl || (!flags.token && flags.useStoredToken); + const stored = wantsStored ? await storedRuntimeTarget() : undefined; + const url = explicitUrl ?? stored?.url ?? DEFAULT_RUNTIME_INSTALL_URL; + const rememberedToken = Boolean(stored && flags.useStoredToken && !flags.token && isSameControlPlane(stored.url, url)); + return { + url, + token: flags.token ?? (rememberedToken ? stored!.token : undefined), + rememberedUrl: !explicitUrl && Boolean(stored), + rememberedToken, + }; +} From 68adfc4a15dceeb23bce3c8490d1e945e0e67d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:48:13 +0000 Subject: [PATCH 2/3] wip(cli): tests and changeset for the self-hosted deploy target Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dLQEXWGsE2SZ3Qn5F --- .changeset/30888-deploy-self-hosted.md | 14 ++ packages/cli/test/deploy.test.ts | 193 ++++++++++++++++++ packages/cli/test/dev-deploy-key.test.ts | 31 +++ .../cli/test/environments-use-runtime.test.ts | 181 ++++++++++++++++ .../package-install-url-token-flags.test.ts | 68 +++++- 5 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 .changeset/30888-deploy-self-hosted.md create mode 100644 packages/cli/test/environments-use-runtime.test.ts diff --git a/.changeset/30888-deploy-self-hosted.md b/.changeset/30888-deploy-self-hosted.md new file mode 100644 index 00000000000..906232ba37f --- /dev/null +++ b/.changeset/30888-deploy-self-hosted.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cli': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +**`os environments use-runtime --token `** (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 ` makes ObjectOS Cloud the deploy target again (the runtime stays remembered); `os environments use-runtime --forget` drops it. + +**`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 ` 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. + +**`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. diff --git a/packages/cli/test/deploy.test.ts b/packages/cli/test/deploy.test.ts index 4b3caa23e8f..5b30f336d13 100644 --- a/packages/cli/test/deploy.test.ts +++ b/packages/cli/test/deploy.test.ts @@ -408,3 +408,196 @@ describe('devVersion — a semver pre-release of the version the author wrote', expect(result.ok).toBe(false); }); }); + +describe('os deploy — a self-hosted runtime as the active target (os environments use-runtime)', () => { + const RUNTIME = 'http://runtime.test:8080'; + const KEY = 'osk_runtime_key'; + let home = ''; + let work = ''; + const previous: Record = {}; + const prevCwd = process.cwd(); + let output: string[] = []; + const RUNTIME_ENV = [...MANAGED_ENV, 'OS_RUNTIME_URL', 'OS_RUNTIME_TOKEN', 'OS_RUNTIME_EMAIL', 'OS_RUNTIME_PASSWORD'] as const; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'os-30888-deploy-home-')); + work = await mkdtemp(join(tmpdir(), 'os-30888-deploy-work-')); + await mkdir(join(home, '.objectstack'), { recursive: true }); + for (const key of ['HOME', 'USERPROFILE', ...RUNTIME_ENV]) previous[key] = process.env[key]; + process.env.HOME = home; + process.env.USERPROFILE = home; + for (const key of RUNTIME_ENV) delete process.env[key]; + // A cloud login AND a remembered runtime that is the active target: the runtime wins. + await writeFile(join(home, '.objectstack', 'cloud.json'), JSON.stringify({ + url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now', + runtimeTarget: { url: RUNTIME, token: KEY, recordedAt: 'then' }, deployTarget: 'runtime', + })); + process.chdir(work); + compileForDeploy.mockReset(); + compileForDeploy.mockImplementation((artifactPath: string) => { + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileSync(artifactPath, JSON.stringify({ manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.2.0' }, objects: [] })); + return 0; + }); + output = []; + const record = (...args: unknown[]) => { output.push(args.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(record); + vi.spyOn(console, 'error').mockImplementation(record); + }); + + afterEach(async () => { + process.chdir(prevCwd); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await rm(home, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + }); + + type Sent = { url: string; method: string; headers: Record; body: any }; + type Answer = { status: number; body: unknown }; + const PLAN = { + dryRun: true, manifestId: 'com.acme.crm', version: '1.2.0', installedVersion: '1.1.0', + plan: { declaredRenames: [{ object: 'lead', from: 'phone', to: 'mobile' }] }, + environment: { columns: [{ object: 'lead', field: 'phone', rows: 3 }] }, + }; + const INSTALLED = { manifestId: 'com.acme.crm', version: '1.2.0', hotLoaded: true, installedAt: '2026-11-07T00:00:00.000Z' }; + const ok = (data: unknown): Answer => ({ status: 200, body: { success: true, data } }); + const refused = (status: number, code: string, message: string, details?: unknown): Answer => + ({ status, body: { success: false, error: { code, message, ...(details ? { details } : {}) } } }); + + /** The runtime: the dry-run answer, then the install answer. Anything else is unexpected. */ + function stubRuntime(answers: { dryRun?: Answer; install?: Answer }): Sent[] { + const sent: Sent[] = []; + vi.stubGlobal('fetch', vi.fn(async (input: string, init: any = {}) => { + const url = String(input); + const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined; + sent.push({ url, method: init.method ?? 'GET', headers: { ...(init.headers ?? {}) }, body }); + const answer = url === `${RUNTIME}/api/v1/marketplace/install-local` && init.method === 'POST' + ? (body?.dryRun ? answers.dryRun ?? ok(PLAN) : answers.install ?? ok(INSTALLED)) + : { status: 599, body: { success: false, error: { message: `unexpected ${init.method} ${url}` } } }; + return { + ok: answer.status >= 200 && answer.status < 300, + status: answer.status, + statusText: String(answer.status), + headers: { get: () => null }, + json: async () => answer.body, + } as any; + })); + return sent; + } + + async function deploy(argv: string[] = []): Promise { + try { + await Deploy.run(argv); + return undefined; + } catch (err: any) { + const exit = err?.oclif?.exit ?? err?.exitCode; + if (exit === undefined) throw err; + return exit; + } + } + const text = () => output.join('\n'); + + it('with no flags: builds, asks the dry run (plan printed), installs the build inline, and reports it live', async () => { + const sent = stubRuntime({}); + + const exit = await deploy(); + + expect(exit, text()).toBeUndefined(); + expect(compileForDeploy).toHaveBeenCalledWith(join(work, 'dist', 'objectstack.json')); + // Two calls, both to the remembered runtime with its key: the dry run, then the install. No cloud call. + expect(sent.map((s) => [s.method, s.url, s.body?.dryRun === true])).toEqual([ + ['POST', `${RUNTIME}/api/v1/marketplace/install-local`, true], + ['POST', `${RUNTIME}/api/v1/marketplace/install-local`, false], + ]); + for (const s of sent) expect(s.headers.Authorization).toBe(`Bearer ${KEY}`); + // Inline artifact, the manifest's own version: no catalog, no -dev suffix. + expect(sent[1].body.manifest.id).toBe('com.acme.crm'); + expect(sent[1].body.manifest.version).toBe('1.2.0'); + expect(sent[1].body.packageId).toBeUndefined(); + // The plan is printed, declared rename included, before the install. + expect(text()).toContain('Renames declared by renamedFrom'); + expect(text()).toContain('lead.phone → mobile — 3 row(s) hold a value'); + expect(text()).toContain(`Live: ${RUNTIME} now serves com.acme.crm 1.2.0.`); + }); + + it('a refusal from the dry run stops the deploy before any install', async () => { + const sent = stubRuntime({ dryRun: refused(422, 'VALIDATION_ERROR', "Package 'com.acme.crm' declares a job with no body.") }); + + const exit = await deploy(); + + expect(exit).toBe(1); + expect(sent).toHaveLength(1); + expect(sent[0].body.dryRun).toBe(true); + expect(text()).toContain('Install failed (422 VALIDATION_ERROR)'); + }); + + it('a runtime that declines install-local (404, e.g. booted from a composed artifact) is named, exit 1', async () => { + const sent = stubRuntime({ dryRun: { status: 404, body: { success: false, error: { message: 'Not Found' } } } }); + + const exit = await deploy(); + + expect(exit).toBe(1); + expect(sent).toHaveLength(1); + expect(text()).toContain(`${RUNTIME} does not serve install-local`); + expect(text()).toContain('A runtime booted from a composed artifact takes no package installs'); + }); + + it('a runtime that predates the dry run is said so, exit 1, naming what to do', async () => { + // It ignored `dryRun` and installed: the answer carries no `dryRun`. + const sent = stubRuntime({ dryRun: ok(INSTALLED) }); + + const exit = await deploy(); + + expect(exit).toBe(1); + expect(sent).toHaveLength(1); + expect(text()).toContain('does not support a dry run'); + expect(text()).toContain('Upgrade the runtime to preview an upgrade before it is installed, then run `os deploy` again.'); + }); + + it("the install's refusal stops the deploy, and its retry names os deploy; the confirmation passes through", async () => { + const accessRefusal = refused(409, 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED', 'confirm', { + findings: [{ describe: "permission set 'sales' would re-enable lead.delete" }], findingSet: 'fs_123', + }); + let sent = stubRuntime({ install: accessRefusal }); + expect(await deploy()).toBe(1); + expect(sent).toHaveLength(2); + expect(text()).toContain('os deploy --confirm-access-upgrade fs_123'); + + vi.unstubAllGlobals(); + sent = stubRuntime({}); + expect(await deploy(['--confirm-access-upgrade', 'fs_123']), text()).toBeUndefined(); + expect(sent[0].body.confirmAccessUpgrade).toBeUndefined(); + expect(sent[1].body.confirmAccessUpgrade).toBe('fs_123'); + }); + + it('a package the running kernel could not load is not called live: exit 1', async () => { + stubRuntime({ install: ok({ ...INSTALLED, hotLoaded: false, hotLoadError: 'boom' }) }); + + expect(await deploy()).toBe(1); + expect(text()).toContain('it is not live: it loads when the runtime restarts'); + expect(text()).not.toContain('Live:'); + }); + + it('a failed build installs nothing', async () => { + const sent = stubRuntime({}); + compileForDeploy.mockImplementation(() => 1); + + expect(await deploy()).toBe(1); + expect(sent).toHaveLength(0); + expect(text()).toContain('Nothing was installed.'); + }); + + it('--confirm-access-upgrade 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-access-upgrade', 'fs_123'])).toBe(1); + expect(sent).toHaveLength(0); + expect(compileForDeploy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/dev-deploy-key.test.ts b/packages/cli/test/dev-deploy-key.test.ts index fa8b50f2305..8bfa6316b01 100644 --- a/packages/cli/test/dev-deploy-key.test.ts +++ b/packages/cli/test/dev-deploy-key.test.ts @@ -157,3 +157,34 @@ describe('os dev deploy key', () => { expect(dev).toMatch(/if \(!deployKey\) deployKey = startDevDeployKey\(binPath,/); }); }); + +describe('os dev deploy key — a self-hosted runtime as the active target (#30888)', () => { + it('names the runtime, and d runs `os deploy` with no cloud login asked for', async () => { + const terminal = new FakeTerminal(); + const printed: string[] = []; + const runDeploy = vi.fn(async () => 0); + const runLogin = vi.fn(async () => 0); + const handle = attachDeployKey({ + input: terminal, + print: (line) => printed.push(line), + isLoggedIn: async () => false, + runDeploy, + runLogin, + runtimeTarget: async () => 'http://runtime.test:8080', + onInterrupt: () => {}, + }); + await handle!.offer(); + expect(printed[0]).toBe(deployKeyHint(false, 'http://runtime.test:8080')); + expect(printed[0]).toContain('to the self-hosted runtime http://runtime.test:8080'); + + terminal.press('d'); + await handle!.current; + expect(runLogin).not.toHaveBeenCalled(); + expect(runDeploy).toHaveBeenCalledTimes(1); + }); + + it('is wired to the remembered target in os dev', () => { + const source = readFileSync(resolve(HERE, '../src/utils/dev-deploy-key.ts'), 'utf8'); + expect(source).toMatch(/runtimeTarget: async \(\) => \(await activeRuntimeTarget\(\)/); + }); +}); diff --git a/packages/cli/test/environments-use-runtime.test.ts b/packages/cli/test/environments-use-runtime.test.ts new file mode 100644 index 00000000000..f6640e44a31 --- /dev/null +++ b/packages/cli/test/environments-use-runtime.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os environments use-runtime` (#30888): a self-hosted runtime remembered as + * the deploy target — its url and key in `~/.objectstack/cloud.json` (0600), + * the file the cloud token lives in, as their own record that the cloud + * session's writes carry across (`utils/cloud-config.ts`'s header). + * + * Run for real under a redirected `$HOME`; `fetch` is the runtime. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import EnvironmentsUseRuntime from '../src/commands/environments/use-runtime.js'; +import { + deleteCloudConfig, readCloudConfig, resolveCloudCredential, tryReadCloudConfig, writeCloudConfig, DEFAULT_CLOUD_URL, +} from '../src/utils/cloud-config.js'; +import { activateCloudDeployTarget, activeRuntimeTarget, storedRuntimeTarget } from '../src/utils/runtime-target.js'; + +describe('os environments use-runtime', () => { + let home = ''; + let output: string[] = []; + const cloudFile = () => join(home, '.objectstack', 'cloud.json'); + const stored = async () => JSON.parse(await readFile(cloudFile(), 'utf-8')); + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'os-30888-use-runtime-')); + await mkdir(join(home, '.objectstack'), { recursive: true }); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + vi.stubEnv('OS_RUNTIME_TOKEN', undefined); + vi.stubEnv('OS_TOKEN', undefined); + vi.stubEnv('OS_CLOUD_URL', undefined); + output = []; + const record = (...args: unknown[]) => { output.push(args.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(record); + vi.spyOn(console, 'error').mockImplementation(record); + }); + afterEach(async () => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await rm(home, { recursive: true, force: true }); + }); + + function stubProbe(status: number): Array<{ url: string; auth: string | undefined }> { + const seen: Array<{ url: string; auth: string | undefined }> = []; + vi.stubGlobal('fetch', vi.fn(async (input: string, init: any = {}) => { + seen.push({ url: String(input), auth: init.headers?.Authorization }); + return { ok: status < 300, status, statusText: String(status), json: async () => ({}) } as any; + })); + return seen; + } + + async function run(argv: string[]): Promise { + try { + await EnvironmentsUseRuntime.run(argv); + return undefined; + } catch (err: any) { + const exit = err?.oclif?.exit ?? err?.exitCode; + if (exit === undefined) throw err; + return exit; + } + } + const text = () => output.join('\n'); + + it('tries the key on the install-local door, then records url + key as the active target in cloud.json (0600)', async () => { + const seen = stubProbe(200); + + expect(await run(['http://localhost:8080/', '--token', 'osk_1']), text()).toBeUndefined(); + + expect(seen).toEqual([{ url: 'http://localhost:8080/api/v1/marketplace/install-local', auth: 'Bearer osk_1' }]); + const file = await stored(); + expect(file.runtimeTarget).toMatchObject({ url: 'http://localhost:8080', token: 'osk_1' }); + expect(file.deployTarget).toBe('runtime'); + if (process.platform !== 'win32') expect((await stat(cloudFile())).mode & 0o777).toBe(0o600); + expect(await activeRuntimeTarget()).toMatchObject({ url: 'http://localhost:8080', token: 'osk_1' }); + // Recorded without a cloud login: still not logged in to the cloud. + expect(await tryReadCloudConfig()).toBeUndefined(); + await expect(readCloudConfig()).rejects.toThrow('Not logged in to ObjectOS Cloud'); + }); + + it('keeps the cloud session it finds, and the cloud reads never see the runtime key', async () => { + await writeFile(cloudFile(), JSON.stringify({ url: 'http://cloud.test', token: 'cloud_tok', createdAt: 'now', activeEnvironmentId: 'env_1' })); + stubProbe(200); + + expect(await run(['http://localhost:8080', '--token', 'osk_1'])).toBeUndefined(); + + const file = await stored(); + expect(file).toMatchObject({ url: 'http://cloud.test', token: 'cloud_tok', activeEnvironmentId: 'env_1' }); + expect(await resolveCloudCredential({ server: DEFAULT_CLOUD_URL })).toEqual({ token: 'cloud_tok', baseUrl: 'http://cloud.test' }); + }); + + it.each([ + [401, 'rejected the key'], + [404, 'does not serve install-local'], + ])('a probe answered %i is refused, naming why, and records nothing', async (status, words) => { + stubProbe(status); + expect(await run(['http://localhost:8080', '--token', 'osk_1'])).toBe(1); + expect(text()).toContain(words); + expect(text()).toContain('Nothing was recorded'); + expect(existsSync(cloudFile())).toBe(false); + }); + + it('--no-verify records without asking the runtime; no key is refused', async () => { + const seen = stubProbe(500); + expect(await run(['http://localhost:8080'])).toBe(1); + expect(text()).toContain('needs an API key it issued'); + + expect(await run(['http://localhost:8080', '--token', 'osk_1', '--no-verify'])).toBeUndefined(); + expect(seen).toHaveLength(0); + expect((await stored()).runtimeTarget.url).toBe('http://localhost:8080'); + }); + + it('refuses a url that is not an http(s) base url', async () => { + stubProbe(200); + expect(await run(['localhost:8080', '--token', 'osk_1'])).toBe(1); + expect(await run(['http://localhost:8080/?x=1', '--token', 'osk_1'])).toBe(1); + expect(existsSync(cloudFile())).toBe(false); + }); + + it('--forget drops the record; the cloud is the target again, and the cloud session stays', async () => { + await writeFile(cloudFile(), JSON.stringify({ + url: 'http://cloud.test', token: 'cloud_tok', createdAt: 'now', + runtimeTarget: { url: 'http://localhost:8080', token: 'osk_1', recordedAt: 'then' }, deployTarget: 'runtime', + })); + expect(await run(['--forget'])).toBeUndefined(); + const file = await stored(); + expect(file.runtimeTarget).toBeUndefined(); + expect(file.deployTarget).toBeUndefined(); + expect(file.token).toBe('cloud_tok'); + expect(text()).toContain('Forgot the self-hosted runtime http://localhost:8080'); + }); +}); + +describe('cloud.json — the cloud session and the deploy target outlive each other', () => { + let home = ''; + const cloudFile = () => join(home, '.objectstack', 'cloud.json'); + const RUNTIME_HALF = { runtimeTarget: { url: 'http://localhost:8080', token: 'osk_1', recordedAt: 'then' }, deployTarget: 'runtime' }; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'os-30888-cloud-json-')); + await mkdir(join(home, '.objectstack'), { recursive: true }); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + }); + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(home, { recursive: true, force: true }); + }); + + it('a cloud write (a fresh login) keeps the runtime target', async () => { + await writeFile(cloudFile(), JSON.stringify({ url: 'http://old.test', token: 'old', createdAt: 'x', ...RUNTIME_HALF })); + await writeCloudConfig({ url: 'http://new.test', token: 'new', createdAt: 'y' }); + const file = JSON.parse(await readFile(cloudFile(), 'utf-8')); + expect(file).toMatchObject({ url: 'http://new.test', token: 'new', ...RUNTIME_HALF }); + }); + + it('a cloud logout clears the session and keeps the runtime target; with none, the file goes', async () => { + await writeFile(cloudFile(), JSON.stringify({ url: 'http://cloud.test', token: 'tok', createdAt: 'x', ...RUNTIME_HALF })); + await deleteCloudConfig(); + expect(JSON.parse(await readFile(cloudFile(), 'utf-8'))).toEqual(RUNTIME_HALF); + expect(await storedRuntimeTarget()).toMatchObject({ url: 'http://localhost:8080' }); + + await writeFile(cloudFile(), JSON.stringify({ url: 'http://cloud.test', token: 'tok', createdAt: 'x' })); + await deleteCloudConfig(); + expect(existsSync(cloudFile())).toBe(false); + }); + + it('`os environments switch` (activateCloudDeployTarget) makes the cloud the target and keeps the runtime', async () => { + await writeFile(cloudFile(), JSON.stringify({ url: 'http://cloud.test', token: 'tok', createdAt: 'x', ...RUNTIME_HALF })); + expect(await activateCloudDeployTarget()).toBe('http://localhost:8080'); + expect(await activeRuntimeTarget()).toBeUndefined(); + expect(await storedRuntimeTarget()).toMatchObject({ url: 'http://localhost:8080', token: 'osk_1' }); + // Already the cloud: nothing to say, nothing written. + expect(await activateCloudDeployTarget()).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/package-install-url-token-flags.test.ts b/packages/cli/test/package-install-url-token-flags.test.ts index b740cb2acfa..eaacbf6d72e 100644 --- a/packages/cli/test/package-install-url-token-flags.test.ts +++ b/packages/cli/test/package-install-url-token-flags.test.ts @@ -16,7 +16,10 @@ * request — the command never has to guess which one the caller meant. */ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import PackageInstall from '../src/commands/package/install.js'; interface Sent { url: string; method?: string; headers: Record; body?: string } @@ -139,3 +142,66 @@ describe('os package install -u/--url and -t/--token (#30126 row 13)', () => { expect(sent).toHaveLength(0); }); }); + +describe('os package install falls back to the runtime `os environments use-runtime` remembered (#30888)', () => { + const REMEMBERED = 'http://remembered.test:8080'; + let home = ''; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'os-30888-install-home-')); + await mkdir(join(home, '.objectstack'), { recursive: true }); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + for (const key of ['OS_RUNTIME_URL', 'OS_RUNTIME_TOKEN', 'OS_RUNTIME_EMAIL', 'OS_RUNTIME_PASSWORD']) vi.stubEnv(key, undefined); + // Remembered but NOT the active deploy target: install reads it either way. + await writeFile(join(home, '.objectstack', 'cloud.json'), JSON.stringify({ + runtimeTarget: { url: REMEMBERED, token: 'osk_remembered', recordedAt: 'then' }, deployTarget: 'cloud', + })); + }); + afterEach(async () => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await rm(home, { recursive: true, force: true }); + }); + + it('without -u/-t: installs into the remembered runtime with its key, and says where it came from', async () => { + const { sent } = stubRuntime(); + const { out, exit } = await runInstall([]); + expect(exit, out).toBeUndefined(); + expect(sent.map((s) => s.url)).toEqual([`${REMEMBERED}/api/v1/marketplace/install-local`]); + expect(sent[0].headers.Authorization).toBe('Bearer osk_remembered'); + expect(out).toContain('remembered by `os environments use-runtime`'); + }); + + it('-u naming the remembered runtime still takes its key', async () => { + const { sent } = stubRuntime(); + await runInstall(['-u', `${REMEMBERED}/`]); + expect(sent[0].headers.Authorization).toBe('Bearer osk_remembered'); + }); + + it('-u naming another server never receives the remembered key', async () => { + const { sent } = stubRuntime(); + await runInstall(['-u', 'http://other.test']); + expect(sent[0].url).toBe('http://other.test/api/v1/marketplace/install-local'); + expect(sent[0].headers).not.toHaveProperty('Authorization'); + }); + + it('an explicit -t wins over the remembered key; --email/--password never mixes the two', async () => { + let { sent } = stubRuntime(); + await runInstall(['-t', 'osk_explicit']); + expect(sent[0].headers.Authorization).toBe('Bearer osk_explicit'); + + vi.unstubAllGlobals(); + ({ sent } = stubRuntime()); + await runInstall(['--email', 'a@b.test', '--password', 'pw']); + expect(sent.every((s) => !('Authorization' in s.headers))).toBe(true); + }); + + it('nothing remembered: http://localhost:3000, as before', async () => { + await rm(join(home, '.objectstack', 'cloud.json')); + const { sent } = stubRuntime(); + await runInstall([]); + expect(sent[0].url).toBe('http://localhost:3000/api/v1/marketplace/install-local'); + expect(sent[0].headers).not.toHaveProperty('Authorization'); + }); +}); From b57c03aa03ab30e9502660953d02fc93accf3d4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:51:59 +0000 Subject: [PATCH 3/3] fix(cli): cloud-config delete path types its record Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dLQEXWGsE2SZ3Qn5F --- packages/cli/src/utils/cloud-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/cloud-config.ts b/packages/cli/src/utils/cloud-config.ts index 85d26ae3af8..ca3868031f7 100644 --- a/packages/cli/src/utils/cloud-config.ts +++ b/packages/cli/src/utils/cloud-config.ts @@ -191,7 +191,7 @@ export async function writeCloudConfig(config: CloudConfig): Promise { /** Clear the cloud session; a recorded deploy target stays (see the header). */ export async function deleteCloudConfig(): Promise { - await writeCloudFile(deployTargetHalf(await readCloudFileForWrite())); + await writeCloudFile({ ...deployTargetHalf(await readCloudFileForWrite()) }); } /** The deploy-target half as stored; `{}` when nothing is recorded. */