Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/30888-deploy-self-hosted.md
Original file line number Diff line number Diff line change
@@ -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 <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.

**`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.

**`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.
106 changes: 105 additions & 1 deletion packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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 <finding-set> # self-hosted: confirm the findings the runtime printed',
];

static override flags = {
Expand Down Expand Up @@ -105,13 +135,34 @@ 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<void> {
const { flags } = await this.parse(Deploy);
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 <url> --token <key>`.',
);
this.exit(1);
return;
}

// 1. Credential.
const { token, baseUrl } = await resolveCloudCredential({ token: flags.token, server: flags.server });
if (!token) {
Expand Down Expand Up @@ -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<void> {
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. */
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/commands/environments/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` — set the active environment for this CLI session.
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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 <url>` to go back)',
);
}
if (!recordedForCloud && !cfg) {
console.log(' ⚠ no local credential store to record it in — run `os login` or `os cloud login`');
}
Expand Down
178 changes: 178 additions & 0 deletions packages/cli/src/commands/environments/use-runtime.ts
Original file line number Diff line number Diff line change
@@ -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 <url> --token <key>` — 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<void> {
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 <key> (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 <id>` 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 <url> --token <key>` (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);
}
}
Loading
Loading