diff --git a/.changeset/30871-os-deploy-dev-loop.md b/.changeset/30871-os-deploy-dev-loop.md new file mode 100644 index 00000000000..cb5a06aa621 --- /dev/null +++ b/.changeset/30871-os-deploy-dev-loop.md @@ -0,0 +1,17 @@ +--- +'@objectstack/cli': patch +'create-objectstack': patch +--- + +feat(cli): `os deploy` — one command from a local checkout to your ObjectOS Cloud environment; installs pick the organization's only environment, keep it across `os cloud login`, and refuse before any write when none resolves + +Clause-②: no +Metadata-protocol: none + +**`os deploy`** (new). With no flags it builds the app (the same compile `os build` runs), publishes it as `-dev.` (a UTC semver pre-release, so deploying again never needs a version bump and never collides with an existing version), installs exactly that version into your organization's environment, and waits until the environment serves it (it reads the control plane's served-version report for the package and, since an environment rebuilds only when it gets a request, sends the environment one on every poll). A failed local build prints its errors and exits 1 having published nothing; a build the environment reports as failed prints the environment's reason and exits 1. Flags only for the uncommon case: `--env ` when your account reaches several environments, `--no-wait`, `--wait-timeout ` (default 600: a failing build is reported only after three failed rounds in a row). Against a control plane that does not yet report which version an environment serves, the command says so and does not claim the version is live. + +**`os package publish --install`** keeps its meaning (a release version, the manifest's own), and now resolves its environment like `os deploy`: `--env` / `$OS_ENVIRONMENT_ID`, then the environment `os environments switch` recorded for this control plane, then the organization's only environment, asked of the control plane and printed. With none, several, or an unreadable list it exits 1 **before registering the package or publishing anything**, naming how to pass one. It used to print "Skipping auto-install", publish anyway and exit 0. + +**`os cloud login`** no longer drops the active environment: re-logging in to the same control plane as the same account keeps `activeEnvironmentId` in `~/.objectstack/cloud.json`. A different control plane or a different account still starts without one. + +**`os dev`** offers the deploy on a key once the local server is up: press `d` to run `os deploy` in the same terminal; when not logged in to ObjectOS Cloud, the same key logs in first. There is no deploy on save. **`os build`** ends with the next step (`os deploy`), and bare `os` lists the everyday commands first. New projects from `create-objectstack` (and `os init`'s app template) ship `npm run deploy`. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 19163925b13..d4dbcb11d63 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -166,6 +166,11 @@ Starts development mode. Three usage shapes: 2. **With a pre-built artifact** (`--artifact `) — skips auto-compile and boots the artifact directly. No `objectstack.config.ts` needed in cwd. Useful for *trying out a published app in dev mode without cloning its source.* 3. **Monorepo root** (cwd has `pnpm-workspace.yaml`) — orchestrates `pnpm -r dev` across packages. +Once the local server is up, `os dev` in an interactive terminal offers +**`d` to deploy**: it runs [`os deploy`](#os-deploy) in the same terminal, and +when you are not logged in to ObjectOS Cloud the same key runs `os cloud login` +first. Saving a file never deploys. + ```bash os dev # Auto-compile cwd config, then start os dev my-package # Workspace package (monorepo orchestration mode) @@ -3063,7 +3068,9 @@ os cloud login --email me@acme.com --password secret # CI Like `os login`, in an interactive terminal it uses the browser-based device flow: it prints a one-time verification URL and polls until you approve. If cloud credentials already exist it exits successfully with "Already logged in"; -pass `--force` to re-authenticate. +pass `--force` to re-authenticate. Re-authenticating to the same control plane +as the same account keeps the active environment recorded in `cloud.json`; a +different control plane or account starts without one. ##### `os cloud login --json` is NDJSON — the same exception as `os login` @@ -3139,6 +3146,53 @@ os environments bind --artifact ./dist/objectstack.json --build is reserved for the server-side reseed endpoint; use it only when that endpoint is available in your deployment. +### Deploy + +#### `os deploy` + +Puts the app in the current directory on your ObjectOS Cloud environment, with +no flags. It builds the app (the compile [`os build`](#os-compile) runs), +publishes it as `-dev.` — a UTC semver +pre-release, so deploying again never needs a version bump — installs that +version into your organization's environment, and waits until the environment +serves it. Scaffolded projects run it as `npm run deploy`, and +[`os dev`](#os-dev) runs it when you press `d`. + +```bash +os cloud login # once per machine +os deploy +os deploy --env env_abc123 # your account reaches several environments +os deploy --no-wait # return once the install is recorded +``` + +The environment is resolved exactly as +[`os package publish --install`](#os-package-publish) resolves it: `--env`, +then the active environment for this control plane, then your organization's +only environment. Every refusal comes before anything is written: not logged +in, no environment to name, a failed build (its errors are printed), or a +`manifest.version` that is not semver. A build the environment itself reports +as failed prints the environment's reason and exits `1`; so does a wait that +times out, naming what it waited for. An environment rebuilds only when it gets +a request, so while it waits the command sends it one on every poll. A failing +build is reported only after three failed rounds in a row, so it can read as +still building for a few minutes before the reason appears. On a control plane +that does not yet report which version an environment serves, the command says +so and does not claim the new version is live. + +There is no deploy on save: on ObjectOS Cloud your organization's environment +is its production, so a deploy is always a deliberate act. + +| Flag | Env equivalent | Purpose | +|---|---|---| +| `--env ` | `OS_ENVIRONMENT_ID` | Environment to deploy into (default: the active one for this control plane, then your organization's only one) | +| `--no-wait` | — | Return once the install is recorded | +| `--wait-timeout ` | — | How long to wait for the environment to serve the new version (default `600`) | +| `-s, --server ` | `OS_CLOUD_URL` | Control-plane URL. Default `https://cloud.objectos.ai`, or the URL recorded by `os cloud login` | +| `-t, --token ` | `OS_CLOUD_API_KEY` | Bearer token; `$OS_TOKEN` and `~/.objectstack/cloud.json` are the fallbacks | + +`os package publish` stays the command for a **release**: it publishes the +version the manifest declares, with `--install` optional. + ### Packages The two commands that move a compiled app onto a platform. They target @@ -3191,6 +3245,14 @@ resolves an install target by bare id, with no name or short-id rescue. A value written by an older CLI into `credentials.json` is copied across once, and only when both files' `url`s agree. +With neither `--env` nor an active environment, `--install` asks the control +plane for your organization's environments and uses the only one, printing +which; archived and platform environments are not counted. On ObjectOS Cloud +every organization has exactly one environment, so this is the usual case. +None, several, or a list that cannot be read is a refusal: the command exits +`1` before it registers the package or publishes a version, and lists the +candidates with how to pass `--env`. + **Options:** | Flag | Env equivalent | Purpose | @@ -3205,8 +3267,8 @@ when both files' `url`s agree. | `--category ` | — | Marketplace category slug (`crm`, `hr`, `devtools`, …) | | `--visibility ` | — | `org` (installable across your organization) · `private` (explicit grants only) · `marketplace` (public after review). Omitted: not sent, so the control plane decides — an existing package keeps its visibility, a new one gets the control plane's default (`org` on ObjectOS Cloud) | | `--org ` | `OS_ORG_ID` | `owner_org_id`. Required with a bearer key in service mode; ignored in user mode | -| `--env ` | `OS_ENVIRONMENT_ID` | Environment to install the new version into. Defaults to the environment [`os environments switch`](#cloud-environments) — or `os environments create --activate` — recorded **for this control plane** in `~/.objectstack/cloud.json` | -| `--install` | — | Auto-install into `--env` after publishing. With no `--env`, no `$OS_ENVIRONMENT_ID` and no active environment for this control plane, it reports that and publishes without installing | +| `--env ` | `OS_ENVIRONMENT_ID` | Environment to install the new version into. Defaults to the environment [`os environments switch`](#cloud-environments) — or `os environments create --activate` — recorded **for this control plane** in `~/.objectstack/cloud.json`, then to your organization's only environment | +| `--install` | — | Install the new version after publishing, into `--env` or the environment it defaults to. With no environment to name — none, several, or a list the control plane would not give — it exits `1` **before publishing anything**, naming how to pass one | | `--seed-sample-data` | — | Include sample data in that auto-install | | `--pre-release` | — | Mark the version as a pre-release (also inferred — see below) | | `--submit` | — | Submit the new version for marketplace review. Needs the package's visibility to be `marketplace` (already stored, or set with `--visibility marketplace`) and a complete listing | diff --git a/packages/cli/package.json b/packages/cli/package.json index c7672bdcd98..e0a40fd215b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,6 +46,7 @@ "oclif": { "bin": "os", "dirname": "objectstack", + "helpClass": "./dist/help.js", "commands": { "strategy": "pattern", "target": "./dist/commands", diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 0450340cbef..6024cabfca4 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -7,4 +7,7 @@ export default class Build extends Compile { /** `os build` is what a deployment runs, so it says what the artifact leaves behind. */ protected override reportsLocalCustomizations = true; + + /** `os build` is the step before `os deploy`, so it says so (`utils/next-steps.ts`). */ + protected override printsNextStep = true; } diff --git a/packages/cli/src/commands/cloud/login.ts b/packages/cli/src/commands/cloud/login.ts index cc1cf068ae9..4241f290170 100644 --- a/packages/cli/src/commands/cloud/login.ts +++ b/packages/cli/src/commands/cloud/login.ts @@ -75,7 +75,33 @@ import { Command, Flags } from '@oclif/core'; import type { CliExitCode } from '../../utils/format.js'; import { printHeader, printKV, printSuccess, printError, emitJson, errorCodeFields } from '../../utils/format.js'; import { loginWithBrowser, loginWithPassword } from '../../utils/auth-flows.js'; -import { DEFAULT_CLOUD_URL, readCloudConfig, writeCloudConfig } from '../../utils/cloud-config.js'; +import { DEFAULT_CLOUD_URL, readCloudConfig, tryReadCloudConfig, writeCloudConfig, type CloudConfig } from '../../utils/cloud-config.js'; +import { isSameControlPlane } from '../../utils/active-environment.js'; + +/** + * The `cloud.json` a successful login writes: the new credential, plus the + * active environment the previous file recorded — when it is still about the + * same control plane and the same account. + * + * A login used to rewrite the file whole, so `activeEnvironmentId` (written by + * `os environments switch` / `os environments create --activate`, read back as + * the install target by `os package publish --install` and `os deploy`) was + * gone after every login and the next install refused for want of it. + * + * Kept only when both hold, because an environment id only resolves on the + * server it was chosen on (`active-environment.ts`) and only for an account + * that can reach it: + * - the previous file's url names the same control plane as this login's; + * - it is the same account — the user ids agree, or one side recorded none. + * Otherwise the id is dropped, exactly as before. Nothing else is carried + * over: every other key is the new login's. + */ +export function mergeCloudLogin(previous: CloudConfig | undefined, next: CloudConfig): CloudConfig { + if (!previous?.activeEnvironmentId) return next; + if (!isSameControlPlane(previous.url, next.url)) return next; + if (previous.userId && next.userId && previous.userId !== next.userId) return next; + return { ...next, activeEnvironmentId: previous.activeEnvironmentId }; +} /** * Emit ONE NDJSON record on stdout — the only `--json` writer in this command. @@ -219,13 +245,15 @@ export default class CloudLogin extends Command { }) : await this.fallbackPasswordPrompt(url, flags.email); - await writeCloudConfig({ - url, - token: result.token, - email: result.user?.email, - userId: result.user?.id, - createdAt: new Date().toISOString(), - }); + await writeCloudConfig( + mergeCloudLogin(await tryReadCloudConfig(), { + url, + token: result.token, + email: result.user?.email, + userId: result.user?.id, + createdAt: new Date().toISOString(), + }), + ); if (flags.json) { // Record 2 of 2 on the device path, and the only record on the @@ -237,7 +265,7 @@ export default class CloudLogin extends Command { if (result.user?.id) printKV('User ID', result.user.id); console.log(''); console.log(' Credentials stored in ~/.objectstack/cloud.json'); - console.log(' Next: `os package publish` to push a package, `os cloud whoami` to verify.'); + console.log(' Next: `os deploy` to put this app on your cloud environment, `os cloud whoami` to verify.'); console.log(''); } } catch (error: any) { diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 323cddda7a7..629234b53a5 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -88,6 +88,7 @@ import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffi // 3a-vii runs, over the automation engine's own judge. import { findFlowCycleRefusals } from '../utils/flow-undeclared-cycle.js'; import { localCustomizationNotice, readLocalCustomizations } from '../utils/local-customizations.js'; +import { BUILD_NEXT_STEP } from '../utils/next-steps.js'; export default class Compile extends Command { static override description = 'Compile ObjectStack configuration to JSON artifact'; @@ -129,6 +130,13 @@ export default class Compile extends Command { */ protected reportsLocalCustomizations = false; + /** + * Whether a successful text-face run ends with the next step + * (`BUILD_NEXT_STEP`). On for `os build` only: `os compile` is what `os dev` + * runs on every boot and rebuild, where a line per rebuild is noise. + */ + protected printsNextStep = false; + async run(): Promise { const { args, flags } = await this.parse(Compile); const timer = createTimer(); @@ -1471,6 +1479,11 @@ export default class Compile extends Command { } } + if (this.printsNextStep) { + console.log(chalk.cyan(` ${BUILD_NEXT_STEP}`)); + console.log(''); + } + } catch (error: any) { if (isExitSignal(error)) throw error; if (flags.json) { diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts new file mode 100644 index 00000000000..cb812fa82bc --- /dev/null +++ b/packages/cli/src/commands/deploy.ts @@ -0,0 +1,284 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os deploy` — put the app in this directory on your ObjectOS Cloud + * environment, with no flags: build it, publish it as a dev version, install + * that version into your organization's environment, and wait until the + * environment serves it. + * + * ## Why one command whose defaults are the whole loop + * + * The same loop through `os package publish` is five things to remember — run + * `os build` first, bump `manifest.version` every time (or the control plane + * answers "version already exists"), know the environment id, pass + * `--install`, and then guess when the environment has actually switched. + * Every one of those is a default here; flags exist only for the uncommon + * case (`--env` for an account that reaches several environments, + * `--no-wait` to return as soon as the install is recorded). + * + * `os package publish` keeps its meaning: publish a RELEASE — the version the + * manifest says — with `--install` optional. This command never publishes a + * release version: what it mints is `-dev.` + * (`utils/dev-version.ts`). + * + * ## The order, and why every refusal comes before the first write + * + * 1. credential (`resolveCloudCredential`, shared with `os package publish`); + * 2. environment (`resolveInstallTarget`, shared with `os package publish + * --install`) — none or several ⇒ refuse, naming `--env`; + * 3. build (`compileForDeploy`) — a failed build prints its reasons and exits; + * 4. version — a `manifest.version` that is not semver is refused; + * 5. publish + install — `os package publish` itself, run in this process + * with the version and environment decided above, so the manifest id, + * namespace and `objectstack.manifest.json` rules stay in ONE place; + * 6. wait (`waitUntilServed`, polling `data.served` — objectstack-ai/cloud#3651) + * — each poll first sends one request to the environment itself, because + * its builds are request-driven; live ⇒ 0; the environment's build failed + * ⇒ its reason and 1; timeout ⇒ what it waited for and 1. + * + * Steps 1–4 write nothing anywhere, so a refusal there leaves no package row, + * no version and no installation behind. + * + * ⛔ No deploy on save, and no `os dev` watcher calls this: on ObjectOS Cloud + * an organization's one environment is its production (cloud ADR-0024), so a + * deploy is always a person's deliberate act — `os dev` offers it on a key. + * + * ⛔ Not the retired `os publish` id (see `package/publish.ts`'s header), on + * purpose: that id is documented as removed and must stay unresolvable. + */ + +import { readFile } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; +import { Command, Flags } from '@oclif/core'; +import { printError, printHeader, printStep, printSuccess, printWarning, isExitSignal } from '../utils/format.js'; +import { DEFAULT_CLOUD_URL, resolveCloudCredential } from '../utils/cloud-config.js'; +import { describeEnvironment, resolveInstallTarget } from '../utils/install-target.js'; +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 PackagePublish from './package/publish.js'; + +/** The artifact the build writes and the publish reads — `os build`'s default output. */ +export const DEPLOY_ARTIFACT = 'dist/objectstack.json'; + +/** How often the wait asks the control plane. */ +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'; + + 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', + ]; + + static override flags = { + env: Flags.string({ + description: + 'Environment id to deploy into. Defaults to the environment `os environments switch` recorded ' + + "for this control plane, then to your organization's only environment", + env: 'OS_ENVIRONMENT_ID', + }), + wait: Flags.boolean({ + description: 'Wait until the environment serves the new version (default: on). Disable with --no-wait.', + default: true, + allowNo: true, + }), + // 600 s, not less: a failed build is reported only after three failed + // rounds in a row (or a build that outruns its own timeout), so a broken + // build can read `pending` for minutes before it reads `failed`. + 'wait-timeout': Flags.integer({ + description: 'Seconds to wait for the environment to serve the new version', + default: 600, + min: 1, + }), + server: Flags.string({ + char: 's', + description: 'ObjectOS Cloud control-plane URL (override for self-hosted control planes)', + env: 'OS_CLOUD_URL', + default: DEFAULT_CLOUD_URL, + }), + token: Flags.string({ + char: 't', + 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', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Deploy); + printHeader('Deploy'); + + try { + // 1. Credential. + const { token, baseUrl } = await resolveCloudCredential({ token: flags.token, server: flags.server }); + if (!token) { + printError('Not logged in to ObjectOS Cloud. Run `os cloud login` first, then `os deploy` again.'); + this.exit(1); + return; + } + + // 2. Environment — before the build, so a deploy that has nowhere to go + // says so in a second rather than after a compile. + const target = await resolveInstallTarget({ + subject: '`os deploy`', + explicit: flags.env, + baseUrl, + token, + timeoutMs: 30_000, + }); + if (target.kind === 'refused') { + printError(target.message); + this.exit(1); + return; + } + const where = describeEnvironment({ id: target.environmentId, displayName: target.displayName }); + printStep( + target.source === 'only' + ? `Deploying to ${where}, your organization's only environment` + : `Deploying to ${where}`, + ); + + // 3. Build. + const artifactPath = resolvePath(process.cwd(), DEPLOY_ARTIFACT); + printStep('Building...'); + if (compileForDeploy(artifactPath) !== 0) { + printError('The build failed — fix the errors above, then run `os deploy` again. Nothing was published.'); + this.exit(1); + return; + } + + // 4. Version. + let artifact: any; + try { + artifact = JSON.parse(await readFile(artifactPath, 'utf-8')); + } catch (err: any) { + printError(`The build reported success but ${DEPLOY_ARTIFACT} cannot be read: ${err.message}`); + this.exit(1); + return; + } + const versioned = devVersion(artifact?.manifest?.version, new Date()); + if (!versioned.ok) { + printError(`${versioned.reason}. Fix \`manifest.version\` in objectstack.config.ts, then run \`os deploy\` again.`); + this.exit(1); + return; + } + + // 5. Publish + install, through `os package publish` itself. + const publish = new PackagePublish( + [ + artifactPath, + '--version', versioned.version, + '--install', + '--env', target.environmentId, + '--server', baseUrl, + ...(flags.token ? ['--token', flags.token] : []), + ], + this.config, + ); + await publish.run(); + const outcome = publish.outcome; + if (!outcome?.installedInto) { + printError( + `${baseUrl} published ${versioned.version} but did not report installing it into ${where}. ` + + 'Check the environment in the ObjectOS Cloud console.', + ); + this.exit(1); + return; + } + + // 6. Wait. + if (!flags.wait) { + console.log(''); + printSuccess(`Installed ${outcome.manifestId} ${outcome.version} into ${where} (not waiting).`); + console.log(' The environment rebuilds in the background and serves the previous build until then.'); + return; + } + console.log(''); + printStep(`Waiting until ${where} serves ${outcome.manifestId} ${outcome.version}...`); + const served = { + baseUrl: outcome.baseUrl, + token: outcome.token, + environmentId: target.environmentId, + manifestId: outcome.manifestId, + version: outcome.version, + versionId: outcome.versionId, + }; + // The environment rebuilds only when it gets a request, so the wait sends + // it one per poll. Without its URL the rebuild waits for real traffic. + const origin = await fetchEnvironmentOrigin(served); + if (!origin) { + printWarning( + `${baseUrl} did not report ${where}'s URL, so this cannot nudge its rebuild: ` + + 'it starts when the environment next gets a request (open the app).', + ); + } + const verdict = await waitUntilServed(served, { + timeoutMs: flags['wait-timeout'] * 1000, + intervalMs: DEPLOY_POLL_INTERVAL_MS, + wake: origin ? () => wakeEnvironment(origin) : undefined, + onPending: (observed) => printStep(` still waiting — ${observed}`), + }); + const code = reportVerdict(verdict, { where, manifestId: outcome.manifestId, version: outcome.version, baseUrl }); + if (code !== 0) this.exit(code); + } catch (error) { + if (isExitSignal(error)) throw error; + printError((error as Error).message); + this.exit(1); + } + } +} + +/** Print the wait's verdict; return the exit code it means. */ +export function reportVerdict( + verdict: ServedVersionVerdict, + ctx: { where: string; manifestId: string; version: string; baseUrl: string }, +): number { + const what = `${ctx.manifestId} ${ctx.version}`; + console.log(''); + switch (verdict.kind) { + case 'served': + printSuccess(`Live: ${ctx.where} now serves ${what}.`); + if (verdict.incomplete) { + printWarning( + "Some functionality is incomplete: the environment's plan withholds a capability this app requires.", + ); + } + return 0; + case 'failed': + printError(`${ctx.where} could not build ${what}: ${verdict.reason}`); + console.log(' It keeps serving the previous build. Fix the cause, then run `os deploy` again.'); + return 1; + case 'timeout': + printError( + `Timed out after ${Math.round(verdict.waitedMs / 1000)}s waiting for ${ctx.where} to serve ${what} ` + + `(last report: ${verdict.lastObserved}). The install is recorded and the environment keeps serving the ` + + 'previous build meanwhile. A failing build is reported only after three failed rounds in a row, so it can ' + + 'read as still building for minutes; wait longer with --wait-timeout , or open the environment ' + + 'in the ObjectOS Cloud console to see its build.', + ); + return 1; + case 'superseded': + printError( + `${ctx.where} no longer points at ${what}: another install replaced it (now version id ` + + `${verdict.desiredVersionId}), so whether ${ctx.version} went live no longer matters. Run \`os deploy\` again ` + + 'to put this build back.', + ); + return 1; + case 'unreadable': + printError( + `Installed ${what} into ${ctx.where}, but its installation could not be read back ` + + `(${verdict.status || 'no response'}: ${verdict.error}), so whether it is live is unknown.`, + ); + return 1; + case 'unsupported': + printWarning( + `Installed ${what} into ${ctx.where}, but ${ctx.baseUrl} does not report which version an environment ` + + 'serves yet, so this cannot confirm it is live. The environment rebuilds in the background and serves ' + + 'the previous build until then.', + ); + return 0; + } +} diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 2130ac8e54f..b6d6fc84c9c 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -41,6 +41,7 @@ import { OBJECTSTACK_MCP_SERVER_NAME } from '@objectstack/mcp'; // #12620 and #12662 protected against. import { describePortSource, parseRequestedPort, formatInvalidPortNotice, portFlagEnv } from '../utils/port-contract.js'; import { applyCryptoKeyFileChannel } from '../utils/internal-crypto-key-file-channel.js'; +import { startDevDeployKey, type DeployKeyHandle } from '../utils/dev-deploy-key.js'; // The ONE dev-TLS contract, shared with the `serve` child this command spawns // (#16804). ⛔ Nothing about certificates is declared in this file, and ⛔ no // certificate is ever generated — the developer brings their own. @@ -875,6 +876,7 @@ export default class Dev extends Command { // per session (`utils/dev-organization-ownership-upgrade.ts`, // `upgradeRefusedDevelopmentDatabase` above for what it prints). let ownershipUpgrade: 'none' | 'pending' | 'attempted' = 'none'; + let deployKey: DeployKeyHandle | undefined; let upgradedBoot = false; const spawnServeChild = (info: { restartIndex: number }) => { const child = spawn( @@ -950,6 +952,13 @@ export default class Dev extends Command { boundProtocol, }); } + // ── The deploy key ─────────────────────────────────────────── + // Last line of the first boot: "press d to deploy" (or to log in, + // then deploy). Attached once — a restart re-enters this handler + // only through the early return above, and the key outlives every + // child. ⛔ Never a save hook: the organization's one cloud + // environment is production (cloud ADR-0024). + if (!deployKey) deployKey = startDevDeployKey(binPath, (line) => console.log(chalk.cyan(` ${line}`))); } }); return child; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ef45f5993e1..14e0f23d17b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -846,6 +846,9 @@ export const TEMPLATES: Record | undefined; + if (flags.install) { + const resolution = await resolveInstallTarget({ + subject: '`--install`', + explicit: flags.env, + baseUrl, + token, + timeoutMs: flags.timeout, + }); + if (resolution.kind === 'refused') { + printError(resolution.message); + this.exit(1); + return; + } + installTarget = resolution; + } + // ---- Step 1: ensure sys_package row --------------------------------- // Manifest defaults already loaded above; CLI flags win. printStep(`Registering package '${manifestId}'...`); @@ -783,38 +829,10 @@ export default class PackagePublish extends Command { if (flags.submit) verBody.submit_for_review = true; if (flags['auto-approve']) verBody.auto_approve = true; - // Install target precedence: `--env` (which oclif also fills from - // $OS_ENVIRONMENT_ID), then the environment `os environments switch` - // recorded in `cloud.json` FOR THIS CONTROL PLANE. - // - // ⛔ Never `credentials.json`. That file's `activeEnvironmentId` belongs - // to whatever server the runtime identity points at — `localhost:3000` - // by default — so it can name an environment on a different control - // plane than the one this publish is POSTing to. The url gate lives in - // `utils/active-environment.ts`; this line must not grow a second one. - let installEnvId = flags.env; - let installEnvFromActive = false; - if (flags.install && !installEnvId) { - installEnvId = await resolveCloudActiveEnvironmentId(baseUrl); - installEnvFromActive = Boolean(installEnvId); - } - - const shouldInstall = flags.install && installEnvId; - if (shouldInstall) { - verBody.install_env_id = installEnvId; + if (installTarget) { + verBody.install_env_id = installTarget.environmentId; verBody.seed_sample_data = flags['seed-sample-data']; - if (installEnvFromActive) { - printStep( - `Installing into the active environment ${installEnvId} ` - + '(os environments switch / os environments create --activate)', - ); - } - } else if (flags.install && !installEnvId) { - printError( - '`--install` requires `--env `, $OS_ENVIRONMENT_ID, or an active environment ' - + '(`os environments switch ` or `os environments create --activate` against this ' - + 'control plane). Skipping auto-install.', - ); + if (installTarget.source !== 'flag') printStep(installTargetLine(installTarget)); } const verRes = await this.postJson( @@ -842,6 +860,17 @@ export default class PackagePublish extends Command { return; } const ver = verRes.body?.data ?? verRes.body; + this.outcome = { + manifestId, + packageId: String(pkg?.id ?? ''), + version: String(ver?.version ?? version), + versionId: typeof ver?.id === 'string' ? ver.id : undefined, + baseUrl, + token, + installedInto: ver?.installation + ? String(ver.installation.environment_id ?? installTarget?.environmentId ?? '') + : undefined, + }; const violations = Array.isArray(verRes.body?.violations) ? verRes.body.violations : []; const warnings = Array.isArray(ver?.warnings) ? ver.warnings : []; @@ -858,7 +887,7 @@ export default class PackagePublish extends Command { if (ver?.installation) { console.log(''); printSuccess('Installed into environment'); - printKV(' Environment', String(ver.installation.environment_id ?? flags.env ?? '—')); + printKV(' Environment', String(ver.installation.environment_id ?? installTarget?.environmentId ?? '—')); printKV(' Installation', String(ver.installation.installation_id ?? '—')); } printKV(' Server', baseUrl); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts new file mode 100644 index 00000000000..a3cc4384c7f --- /dev/null +++ b/packages/cli/src/help.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Help } from '@oclif/core'; +import { formatEverydayCommands } from './utils/next-steps.js'; + +/** + * The CLI's help class (`oclif.helpClass` in package.json): oclif's own, with + * the few everyday commands listed first on the root page — what bare `os` + * and `os --help` print — ahead of the full alphabetical listing of every + * topic and command. Topic and command pages are unchanged. + * + * The list itself lives in `utils/next-steps.ts`, beside the post-build hint, + * so the two places that tell a developer what to run next read one source. + */ +export default class ObjectStackHelp extends Help { + override formatRoot(): string { + return `${super.formatRoot()}\n\n${formatEverydayCommands(this.config.bin)}`; + } +} diff --git a/packages/cli/src/utils/cloud-config.ts b/packages/cli/src/utils/cloud-config.ts index 1794603fecb..507f2718457 100644 --- a/packages/cli/src/utils/cloud-config.ts +++ b/packages/cli/src/utils/cloud-config.ts @@ -112,3 +112,31 @@ export async function deleteCloudConfig(): Promise { } } } + +/** + * The credential and control plane a cloud WRITE uses — `os package publish` + * and `os deploy` alike, so the two cannot aim at different servers. + * + * Token: the explicit `--token` (oclif fills it from `$OS_CLOUD_API_KEY`), + * then `$OS_TOKEN`, then this file's. Server: an explicit `--server` / + * `$OS_CLOUD_URL`, then this file's recorded url, then {@link DEFAULT_CLOUD_URL}. + * ⛔ `credentials.json` (the runtime identity, `os login`) is never read: a + * package published with a runtime-scoped token is the mistake this file's + * header exists to make impossible. + * + * `token` is undefined when no source has one; the caller refuses. + */ +export async function resolveCloudCredential(flags: { + token?: string; + server: string; +}): Promise<{ token: string | undefined; baseUrl: string }> { + let token = flags.token ?? process.env.OS_TOKEN ?? undefined; + let baseUrl = flags.server.replace(/\/+$/, ''); + const serverFlagWasDefault = !process.env.OS_CLOUD_URL && baseUrl === DEFAULT_CLOUD_URL; + if (!token || serverFlagWasDefault) { + const stored = await tryReadCloudConfig(); + if (!token && stored?.token) token = stored.token; + if (serverFlagWasDefault && stored?.url) baseUrl = stored.url.replace(/\/+$/, ''); + } + return { token, baseUrl }; +} diff --git a/packages/cli/src/utils/deploy-build.ts b/packages/cli/src/utils/deploy-build.ts new file mode 100644 index 00000000000..3bf1bccc52a --- /dev/null +++ b/packages/cli/src/utils/deploy-build.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { spawnSync } from 'node:child_process'; + +/** + * `os deploy`'s build step: the same compile `os build` runs, in a child + * `os compile`, its output streamed to this terminal so a failing build shows + * its own reasons. Returns the child's exit status (`null` → 1). + * + * A child rather than an in-process call for the reason every other compile in + * this CLI is one (`os dev`'s boot and watch compiles, `os start`): the config + * module is evaluated in a process of its own, so nothing it does to globals + * or the environment leaks into the publish that follows. No `NODE_ENV` is + * written into the child — `commands/child-env-source-loader.pin.test.ts` + * holds every command source to that. + * + * Its own module so a test can replace the step without spawning a CLI. + */ +export function compileForDeploy(artifactPath: string): number { + const result = spawnSync( + process.execPath, + [process.argv[1], 'compile', '--output', artifactPath], + { stdio: 'inherit' }, + ); + return result.status ?? 1; +} diff --git a/packages/cli/src/utils/dev-deploy-key.ts b/packages/cli/src/utils/dev-deploy-key.ts new file mode 100644 index 00000000000..d4e3a6ce850 --- /dev/null +++ b/packages/cli/src/utils/dev-deploy-key.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os dev`'s deploy key: once the local server is up, the last line says + * "press d to deploy", and pressing `d` runs `os deploy` in the same terminal. + * Not logged in, the same key logs in first (`os cloud login`), then deploys. + * + * ⛔ A KEY, never a save hook. On ObjectOS Cloud an organization's one + * environment is its production (cloud ADR-0024): every deploy is a person's + * deliberate act, so nothing here is wired to the watcher. + * + * ## Why the deploy runs as a child `os deploy` + * + * The key runs exactly the command a developer would type — one deploy path, + * whose output, refusals and exit status are the ones `os deploy` documents. + * While the child runs, this module hands the terminal back (raw mode off, its + * listener removed) so a login prompt in the child reads the keyboard; it + * takes the terminal again when the child exits. + * + * Raw mode swallows the terminal's Ctrl-C, so Ctrl-C is forwarded as SIGINT to + * this process — the path `os dev` already shuts down on. + * + * 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. + */ + +import { spawn } from 'node:child_process'; +import { tryReadCloudConfig } from './cloud-config.js'; + +/** The slice of `process.stdin` this module uses — a fake in tests. */ +export interface DeployKeyInput { + isTTY?: boolean; + setRawMode?(mode: boolean): unknown; + setEncoding?(encoding: BufferEncoding): unknown; + resume(): unknown; + pause(): unknown; + on(event: 'data', listener: (chunk: string | Buffer) => void): unknown; + off(event: 'data', listener: (chunk: string | Buffer) => void): unknown; +} + +export interface DeployKeyOptions { + input: DeployKeyInput; + print: (line: string) => void; + /** True when a cloud credential is available (`cloud.json`, OS_CLOUD_API_KEY, OS_TOKEN). */ + isLoggedIn: () => Promise; + /** Runs `os cloud login`; resolves to its exit status. */ + runLogin: () => Promise; + /** Runs `os deploy`; resolves to its exit status. */ + runDeploy: () => Promise; + /** Ctrl-C while the terminal is raw. */ + onInterrupt: () => void; +} + +export interface DeployKeyHandle { + /** Print the offer line (on boot, and after each deploy). */ + offer(): Promise; + /** Restore the terminal and stop listening. */ + detach(): void; + /** The pending key action, for tests. */ + readonly current: Promise | undefined; +} + +/** The offer line, by whether a cloud credential is available. */ +export function deployKeyHint(loggedIn: boolean): string { + 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`).'; +} + +/** + * Attach the key to `input`. Returns `undefined`, attaching nothing, when the + * input is not a TTY. + */ +export function attachDeployKey(options: DeployKeyOptions): DeployKeyHandle | undefined { + const { input, print } = options; + if (!input.isTTY || typeof input.setRawMode !== 'function') return undefined; + + let listening = false; + let current: Promise | undefined; + + const listen = () => { + if (listening) return; + input.setRawMode!(true); + input.setEncoding?.('utf8'); + input.on('data', onData); + input.resume(); + listening = true; + }; + const release = () => { + if (!listening) return; + input.off('data', onData); + input.setRawMode!(false); + input.pause(); + listening = false; + }; + + const offer = async () => { + print(deployKeyHint(await options.isLoggedIn())); + }; + + const act = async () => { + release(); + try { + if (!(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.'); + return; + } + } + const status = await options.runDeploy(); + if (status !== 0) print(`os deploy exited with status ${status} — see above. Press d to try again.`); + } finally { + listen(); + } + await offer(); + }; + + function onData(chunk: string | Buffer) { + const key = String(chunk); + if (key === '\u0003') { + options.onInterrupt(); + return; + } + if (key !== 'd' && key !== 'D') return; + if (current) return; + current = act().finally(() => { current = undefined; }); + } + + listen(); + return { + offer, + detach: release, + get current() { return current; }, + }; +} + +/** A cloud credential `os deploy` would find: an API key in the environment, or `os cloud login`'s. */ +export async function hasCloudCredential(): Promise { + if (process.env.OS_CLOUD_API_KEY || process.env.OS_TOKEN) return true; + return Boolean((await tryReadCloudConfig())?.token); +} + +/** + * Run `os ` as a child on this terminal; resolve to its exit status. + * + * `OS_ENVIRONMENT_ID` is withheld from the child. Under `os dev` that variable + * names the LOCAL environment (`--environment-id`'s fallback, `env_local` by + * default), and `os deploy` reads the same variable as its cloud `--env` — so + * inheriting it would aim the deploy at an id that exists only on this machine. + * No `NODE_ENV` is written (`commands/child-env-source-loader.pin.test.ts`). + */ +export function runCliChild(binPath: string, args: string[]): Promise { + const env = { ...process.env }; + delete env.OS_ENVIRONMENT_ID; + return new Promise((resolve) => { + const child = spawn(process.execPath, [binPath, ...args], { stdio: 'inherit', env }); + child.on('error', () => resolve(1)); + child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); +} + +/** + * `os dev`'s one call: attach the key to this process's terminal and print the + * offer. Called once, when the first server is listening. No TTY → nothing. + */ +export function startDevDeployKey(binPath: string, print: (line: string) => void): DeployKeyHandle | undefined { + const handle = attachDeployKey({ + input: process.stdin, + print, + isLoggedIn: hasCloudCredential, + runLogin: () => runCliChild(binPath, ['cloud', 'login']), + runDeploy: () => runCliChild(binPath, ['deploy']), + onInterrupt: () => { process.kill(process.pid, 'SIGINT'); }, + }); + if (!handle) return undefined; + process.on('exit', () => handle.detach()); + void handle.offer(); + return handle; +} diff --git a/packages/cli/src/utils/dev-version.ts b/packages/cli/src/utils/dev-version.ts new file mode 100644 index 00000000000..97a7997d8a6 --- /dev/null +++ b/packages/cli/src/utils/dev-version.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The version `os deploy` publishes: `-dev.`. + * + * A semver PRE-RELEASE of the version the author wrote, so deploying twice + * never collides with the control plane's "version already exists" refusal, + * nobody hand-bumps `manifest.version` between deploys, and a dev build sorts + * below the release it leads up to (`1.2.0-dev.… < 1.2.0`). The control plane + * flags any `-dev` version as a pre-release. + * + * - The timestamp is UTC, to the second, so two people in two time zones never + * mint the same string for different builds; it has no leading zero, so it + * is a legal numeric identifier. + * - A `manifest.version` that already carries a pre-release keeps it and gains + * `.dev.` (`1.2.0-beta.1` → `1.2.0-beta.1.dev.`), still below it. + * - Build metadata (`+…`) is dropped: semver ignores it for precedence, so two + * deploys differing only there would be the same version. + * - No `manifest.version` at all → `0.0.0-dev.`, as `os package publish` + * already does for a versionless artifact. + * - A `manifest.version` that is not semver is refused, not repaired: minting + * a version the author never wrote is worse than saying what is wrong. + */ + +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +/** `YYYYMMDDHHMMSS` in UTC. */ +export function devTimestamp(at: Date): string { + return at.toISOString().replace(/[-:T]/g, '').slice(0, 14); +} + +export type DevVersionResult = { ok: true; version: string } | { ok: false; reason: string }; + +export function devVersion(manifestVersion: unknown, at: Date): DevVersionResult { + const ts = devTimestamp(at); + if (manifestVersion === undefined || manifestVersion === null + || (typeof manifestVersion === 'string' && !manifestVersion.trim())) { + return { ok: true, version: `0.0.0-dev.${ts}` }; + } + if (typeof manifestVersion !== 'string') { + return { ok: false, reason: `manifest.version must be a string, got ${typeof manifestVersion}` }; + } + const match = SEMVER.exec(manifestVersion.trim()); + if (!match) { + return { + ok: false, + reason: `manifest.version '${manifestVersion}' is not a semver version (MAJOR.MINOR.PATCH, e.g. 1.0.0)`, + }; + } + const [, major, minor, patch, pre] = match; + const core = `${major}.${minor}.${patch}`; + return { ok: true, version: pre ? `${core}-${pre}.dev.${ts}` : `${core}-dev.${ts}` }; +} diff --git a/packages/cli/src/utils/install-target.ts b/packages/cli/src/utils/install-target.ts new file mode 100644 index 00000000000..0605353dcc9 --- /dev/null +++ b/packages/cli/src/utils/install-target.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which environment a command that INSTALLS into ObjectOS Cloud installs into — + * one resolution for every such command (`os package publish --install` and + * `os deploy`), so the two cannot disagree about where a package lands. + * + * ## The order + * + * 1. the explicit id — `--env`, which oclif also fills from `$OS_ENVIRONMENT_ID`; + * 2. the active environment `os environments switch` / `os environments create + * --activate` recorded in `cloud.json` FOR THIS CONTROL PLANE + * (`active-environment.ts` owns that gate; this module adds no second one); + * 3. the organization's ONLY environment, asked of the control plane + * (`GET /api/v1/cloud/environments`, which the control plane already + * filters to the caller's own organizations). On ObjectOS Cloud every + * organization has exactly one environment (cloud ADR-0024), so for nearly + * everyone this step is the answer and nobody has to type a uuid. + * + * ## No guess — refuse before any write + * + * Zero environments, several, or a list the control plane would not give: the + * answer is a REFUSAL naming how to pass one, and the caller exits non-zero + * before its first write. A command that was asked to install and did not + * must never read as success — publishing anyway and saying "skipping + * auto-install" on one line of a successful run is the shape this replaces. + * + * Archived and platform-infrastructure (`is_system`) rows are not candidates: + * neither is an environment a developer installs into, and counting an + * archived row would turn "the only environment" into "several" for every + * organization that ever archived one. + */ + +import { resolveCloudActiveEnvironmentId } from './active-environment.js'; +import { readErrorMessage } from './response-envelope.js'; + +/** One environment as the control plane's list answers it, reduced to what a pick needs. */ +export interface CloudEnvironmentSummary { + id: string; + displayName?: string; + organizationId?: string; + status?: string; + /** `is_system` on the row: platform infrastructure, not an environment a developer installs into. */ + infrastructure?: boolean; +} + +/** The list call's outcome: the rows, or why there are none to read. */ +export type EnvironmentListOutcome = + | { ok: true; environments: CloudEnvironmentSummary[] } + | { ok: false; status: number; error: string }; + +/** Reads the caller's environments from one control plane. Injected in tests. */ +export type ListCloudEnvironments = (args: { + baseUrl: string; + token: string; + timeoutMs: number; +}) => Promise; + +/** Where a resolved install target came from — printed, so the user sees why. */ +export type InstallTargetSource = 'flag' | 'active' | 'only'; + +export type InstallTargetResolution = + | { kind: 'resolved'; environmentId: string; source: InstallTargetSource; displayName?: string } + | { kind: 'refused'; message: string }; + +/** + * `GET {baseUrl}/api/v1/cloud/environments` with the publish credential. + * Never throws: a network failure, a timeout and a non-2xx are all an + * `ok: false` the resolver turns into a refusal. + */ +export const listCloudEnvironments: ListCloudEnvironments = async ({ baseUrl, token, timeoutMs }) => { + const controller = new AbortController(); + const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined; + try { + const response = await fetch(`${baseUrl}/api/v1/cloud/environments`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + let parsed: any = null; + try { parsed = await response.json(); } catch { /* empty / non-json */ } + if (!response.ok) { + return { ok: false, status: response.status, error: readErrorMessage(parsed, response) }; + } + const rows = (parsed?.data ?? parsed)?.environments; + if (!Array.isArray(rows)) { + return { ok: false, status: response.status, error: 'the answer carried no `environments` list' }; + } + return { + ok: true, + environments: rows + .filter((row: any) => row && typeof row.id === 'string' && row.id) + .map((row: any) => ({ + id: row.id, + displayName: typeof row.display_name === 'string' && row.display_name ? row.display_name : undefined, + organizationId: typeof row.organization_id === 'string' ? row.organization_id : undefined, + status: typeof row.status === 'string' ? row.status : undefined, + infrastructure: row.is_system === true || row.is_system === 1, + })), + }; + } catch (err: any) { + if (err?.name === 'AbortError') { + return { ok: false, status: 0, error: `the request timed out after ${timeoutMs}ms` }; + } + return { ok: false, status: 0, error: err?.message ?? String(err) }; + } finally { + if (timer) clearTimeout(timer); + } +}; + +/** The rows a developer can install into: not archived, not platform infrastructure. */ +export function installableEnvironments(rows: CloudEnvironmentSummary[]): CloudEnvironmentSummary[] { + return rows.filter((row) => row.status !== 'archived' && !row.infrastructure); +} + +/** `name (id)`, or the bare id when the row has no name. */ +export function describeEnvironment(env: { id: string; displayName?: string }): string { + return env.displayName ? `${env.displayName} (${env.id})` : env.id; +} + +/** + * Resolve the install target. `subject` names what needs it, for the refusal's + * first words — `--install` on `os package publish`, `os deploy` on deploy. + */ +export async function resolveInstallTarget(args: { + subject: string; + explicit: string | undefined; + baseUrl: string; + token: string; + timeoutMs: number; + list?: ListCloudEnvironments; +}): Promise { + const explicit = args.explicit?.trim(); + if (explicit) return { kind: 'resolved', environmentId: explicit, source: 'flag' }; + + const active = await resolveCloudActiveEnvironmentId(args.baseUrl); + if (active) return { kind: 'resolved', environmentId: active, source: 'active' }; + + const remedy = 'Pass `--env ` (or set $OS_ENVIRONMENT_ID), or record one with `os environments switch `.'; + const listed = await (args.list ?? listCloudEnvironments)({ + baseUrl: args.baseUrl, + token: args.token, + timeoutMs: args.timeoutMs, + }); + if (!listed.ok) { + return { + kind: 'refused', + message: + `${args.subject} needs an environment to install into. None was given, and the list of your ` + + `organization's environments on ${args.baseUrl} could not be read` + + `${listed.status ? ` (${listed.status})` : ''}: ${listed.error}. ${remedy}`, + }; + } + + const candidates = installableEnvironments(listed.environments); + if (candidates.length === 1) { + const only = candidates[0]; + return { kind: 'resolved', environmentId: only.id, source: 'only', displayName: only.displayName }; + } + if (candidates.length === 0) { + return { + kind: 'refused', + message: + `${args.subject} needs an environment to install into, and your account has none on ${args.baseUrl}. ` + + `Create one in the ObjectOS Cloud console, then run this again. ${remedy}`, + }; + } + return { + kind: 'refused', + message: + `${args.subject} needs an environment to install into, and your account can reach ` + + `${candidates.length} on ${args.baseUrl}, so none was picked:\n` + + candidates.map((env) => ` • ${describeEnvironment(env)}`).join('\n') + + `\n ${remedy}`, + }; +} + +/** The progress line naming where the install goes and why there. */ +export function installTargetLine(target: Extract): string { + const where = describeEnvironment({ id: target.environmentId, displayName: target.displayName }); + switch (target.source) { + case 'only': + return `Installing into ${where}, your organization's only environment`; + case 'active': + return `Installing into the active environment ${where} (os environments switch / os environments create --activate)`; + default: + return `Installing into ${where}`; + } +} diff --git a/packages/cli/src/utils/next-steps.ts b/packages/cli/src/utils/next-steps.ts new file mode 100644 index 00000000000..1513b61bb07 --- /dev/null +++ b/packages/cli/src/utils/next-steps.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What the CLI tells a developer to do next — so nobody has to remember a + * command to find the next one. + * + * - {@link BUILD_NEXT_STEP}: printed after a successful `os build` (not + * `os compile`, which `os dev` runs on every boot and rebuild, where a line + * per rebuild would be noise). + * - {@link EVERYDAY_COMMANDS}: the first block of bare `os` (and `os --help`), + * ahead of oclif's full alphabetical listing (`src/help.ts`). + * + * Every id here must resolve to a real command; `test/next-steps.test.ts` + * holds them to the built command table. + */ + +/** The line after a successful `os build`. */ +export const BUILD_NEXT_STEP = + 'Next: `os deploy` builds, publishes and installs this app on your ObjectOS Cloud environment.'; + +/** The few commands of the everyday loop, in the order they are used. */ +export const EVERYDAY_COMMANDS: ReadonlyArray<{ id: string; summary: string }> = [ + { id: 'dev', summary: 'run this app locally, rebuilding on save (press d to deploy)' }, + { id: 'build', summary: 'compile objectstack.config.ts into dist/objectstack.json' }, + { id: 'deploy', summary: 'put this app on your ObjectOS Cloud environment and wait until it is live' }, + { id: 'cloud login', summary: 'sign in to ObjectOS Cloud (once per machine)' }, + { id: 'validate', summary: 'check the metadata without building' }, +]; + +/** The everyday block as printed, `bin` being the invoked binary name (`os`). */ +export function formatEverydayCommands(bin: string): string { + const width = Math.max(...EVERYDAY_COMMANDS.map((c) => `${bin} ${c.id}`.length)); + const rows = EVERYDAY_COMMANDS.map((c) => ` ${`${bin} ${c.id}`.padEnd(width)} ${c.summary}`); + return ['EVERYDAY COMMANDS', ...rows].join('\n'); +} diff --git a/packages/cli/src/utils/served-version.ts b/packages/cli/src/utils/served-version.ts new file mode 100644 index 00000000000..6db96548c7c --- /dev/null +++ b/packages/cli/src/utils/served-version.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * "Is the environment serving the version I just installed?" — the one client + * function `os deploy` waits on. + * + * ## Why a wait at all + * + * The control plane answers the install as soon as it has written the desired + * state; the environment rebuilds afterwards, keeps serving the OLD build while + * it does (stale-while-rebuild), and keeps serving it if the build fails. So + * "installed" in the publish answer means "asked for", and a developer who + * opens the app right away may see the old version with no word that the new + * one failed to build. + * + * ## The read: `data.served` (objectstack-ai/cloud#3651, PR objectstack-ai/cloud#3662) + * + * `GET /api/v1/cloud/environments/{environmentId}/packages/{manifestId}`, with + * the credential the publish used, answers `data.served`: + * + * state 'live' | 'pending' | 'failed' + * desiredVersionId the installed version — equals the publish answer's `id` + * versionId the version the environment last reported serving + * version its version string + * observedStatus installed | incomplete | failed | pending | null + * observedAt when that was reported + * error the build's failure reason (redacted), when state is 'failed' + * + * `live` ends the wait; `failed` prints `error` and exits non-zero; `pending` + * keeps polling. ⛔ Not the installed-state probe + * (`GET …/installations/:packageId`): it keeps its old shape and carries no + * `served`. + * + * Two facts about the server shape the loop: + * + * 1. **Builds are request-driven.** Nothing rebuilds until the environment + * gets traffic, so every poll also sends one request to the environment's + * own URL ({@link wakeEnvironment}) — that starts the rebuild, or retries a + * failed one. + * 2. **Failures surface late.** A failure is reported only once the runtime's + * failure streak trips (three failed rounds in a row) or a build outruns its + * timeout; one failed round still reads `pending`. The default wait is sized + * for that, and a timeout says so. + * + * Everything that knows this shape lives in this file — {@link fetchServedReport} + * (the request) and {@link interpretServedReport} (the reading) — so a change + * to the contract edits this file only. An answer with no `served` at all is a + * control plane from before that contract: said plainly, never spun to the + * timeout, never called live. + */ + +import { readErrorMessage } from './response-envelope.js'; + +/** What the wait is for: one version of one package in one environment. */ +export interface ServedVersionTarget { + baseUrl: string; + token: string; + environmentId: string; + /** The read is keyed by manifest id. */ + manifestId: string; + version: string; + /** The publish answer's `id` — what `served.desiredVersionId` must equal. */ + versionId: string | undefined; +} + +/** One reading of `data.served`, reduced to what the wait decides on. */ +export type ServedReading = + | { kind: 'served'; version: string; incomplete: boolean } + | { kind: 'failed'; reason: string } + | { kind: 'superseded'; desiredVersionId: string } + | { kind: 'pending'; observed: string } + | { kind: 'unsupported' }; + +/** One poll's outcome, before interpretation. */ +export type ServedReport = + | { ok: true; data: Record } + | { ok: false; status: number; error: string; retryable: boolean }; + +export type FetchServedReport = (target: ServedVersionTarget) => Promise; + +/** The wait's verdict — the caller prints it and picks the exit code. */ +export type ServedVersionVerdict = + | { kind: 'served'; version: string; incomplete: boolean } + | { kind: 'failed'; reason: string } + | { kind: 'superseded'; desiredVersionId: string } + | { kind: 'timeout'; waitedMs: number; lastObserved: string } + | { kind: 'unsupported' } + | { kind: 'unreadable'; status: number; error: string }; + +/** The request half of the contract. Never throws. */ +export const fetchServedReport: FetchServedReport = async (target) => { + const url = + `${target.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(target.environmentId)}` + + `/packages/${encodeURIComponent(target.manifestId)}`; + try { + const response = await fetch(url, { headers: { Authorization: `Bearer ${target.token}` } }); + let parsed: any = null; + try { parsed = await response.json(); } catch { /* empty / non-json */ } + if (!response.ok) { + return { + ok: false, + status: response.status, + error: readErrorMessage(parsed, response), + // An answer about the credential or the ids will not change by asking again. + retryable: response.status >= 500 || response.status === 429, + }; + } + const data = parsed?.data ?? parsed; + return { ok: true, data: data && typeof data === 'object' ? data : {} }; + } catch (err: any) { + return { ok: false, status: 0, error: err?.message ?? String(err), retryable: true }; + } +}; + +/** The reading half of the contract. Pure. */ +export function interpretServedReport( + data: Record, + target: Pick, +): ServedReading { + const served = data.served; + if (!served || typeof served !== 'object') return { kind: 'unsupported' }; + const s = served as Record; + const str = (key: string) => (typeof s[key] === 'string' && (s[key] as string).trim() ? (s[key] as string).trim() : undefined); + + // The installation now points at a different version: someone installed over + // this deploy, so neither `live` nor `failed` is about the version we published. + const desired = str('desiredVersionId'); + if (desired && target.versionId && desired !== target.versionId) { + return { kind: 'superseded', desiredVersionId: desired }; + } + + const state = str('state'); + const observedStatus = str('observedStatus'); + if (state === 'live') { + return { kind: 'served', version: str('version') ?? target.version, incomplete: observedStatus === 'incomplete' }; + } + if (state === 'failed') { + return { kind: 'failed', reason: str('error') ?? 'the environment reported a failed build without a reason' }; + } + const version = str('version'); + const observed = version + ? `${observedStatus ?? 'status not reported'}, serving ${version}` + : (observedStatus ?? 'nothing reported yet'); + return { kind: 'pending', observed }; +} + +/** + * The environment's own origin, from the control plane's environment read + * (`data.environment.hostname`). `undefined` when it has none or the read + * fails — the wait then cannot wake the build, and the caller says so. + */ +export async function fetchEnvironmentOrigin(target: Pick): Promise { + try { + const response = await fetch( + `${target.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(target.environmentId)}`, + { headers: { Authorization: `Bearer ${target.token}` } }, + ); + if (!response.ok) return undefined; + const parsed: any = await response.json().catch(() => null); + const hostname = (parsed?.data ?? parsed)?.environment?.hostname; + if (typeof hostname !== 'string' || !hostname.trim()) return undefined; + const trimmed = hostname.trim().replace(/\/+$/, ''); + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + } catch { + return undefined; + } +} + +/** + * One request to the environment itself, so a request-driven rebuild starts + * (or a failed one retries). Best-effort by design: the answer while it builds + * is a retryable 503 (`SERVICE_UNAVAILABLE`), and nothing about it is read. `/api/v1/discovery` + * is served by the environment's kernel — unlike `/api/v1/health`, the + * container liveness probe, which answers without one. + */ +export async function wakeEnvironment(origin: string, timeoutMs = 10_000): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + await fetch(`${origin}/api/v1/discovery`, { signal: controller.signal }); + } catch { + // Unreachable or slow — the next poll asks again. + } finally { + clearTimeout(timer); + } +} + +/** + * Poll until the environment serves `target.version`, reports its build + * failed, or `timeoutMs` passes. Each poll first wakes the environment (when + * `wake` is given). The clock and the sleep are injectable so a test drives + * the whole wait without real time. + */ +export async function waitUntilServed( + target: ServedVersionTarget, + options: { + timeoutMs: number; + intervalMs: number; + fetchReport?: FetchServedReport; + wake?: () => Promise; + now?: () => number; + sleep?: (ms: number) => Promise; + onPending?: (observed: string) => void; + }, +): Promise { + const fetchReport = options.fetchReport ?? fetchServedReport; + const now = options.now ?? Date.now; + const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const started = now(); + let lastObserved = 'nothing reported yet'; + + for (;;) { + await options.wake?.(); + const report = await fetchReport(target); + if (!report.ok) { + if (!report.retryable) return { kind: 'unreadable', status: report.status, error: report.error }; + lastObserved = `the read answered ${report.status || 'no response'}: ${report.error}`; + } else { + const reading = interpretServedReport(report.data, target); + if (reading.kind !== 'pending') return reading; + if (reading.observed !== lastObserved) options.onPending?.(reading.observed); + lastObserved = reading.observed; + } + const waited = now() - started; + if (waited >= options.timeoutMs) return { kind: 'timeout', waitedMs: waited, lastObserved }; + await sleep(Math.min(options.intervalMs, Math.max(0, options.timeoutMs - waited))); + } +} diff --git a/packages/cli/test/cloud-login-keeps-active-environment.test.ts b/packages/cli/test/cloud-login-keeps-active-environment.test.ts new file mode 100644 index 00000000000..e9124e3d6fc --- /dev/null +++ b/packages/cli/test/cloud-login-keeps-active-environment.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os cloud login` keeps the active environment (#30871 item 2). + * + * `cloud.json`'s `activeEnvironmentId` is the install target `os package + * publish --install` and `os deploy` read back. A login used to rewrite the + * file whole, so the id was gone after every login and the next install had + * to be told again. The login now merges — and keeps the id ONLY where it can + * still resolve: the same control plane, the same account. Each case below is + * one side of that gate, run through the real command against the real file + * under a redirected `$HOME`; only the network login itself is stubbed. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +vi.mock('../src/utils/auth-flows.js', () => ({ + loginWithPassword: vi.fn(async (_url: string, email: string) => ({ + token: 'fresh_tok', + user: { id: email === 'other@acme.test' ? 'usr_other' : 'usr_1', email }, + })), + loginWithBrowser: vi.fn(), +})); + +const { default: CloudLogin } = await import('../src/commands/cloud/login.js'); + +const CLOUD_PLANE = 'http://cloud.test'; + +describe('os cloud login keeps the active environment', () => { + let home = ''; + const previous: Record = {}; + const cloudJson = () => join(home, '.objectstack', 'cloud.json'); + const readCloud = async () => JSON.parse(await readFile(cloudJson(), 'utf8')); + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'os-30871-login-')); + await mkdir(join(home, '.objectstack'), { recursive: true }); + for (const key of ['HOME', 'USERPROFILE', 'OS_CLOUD_URL']) previous[key] = process.env[key]; + process.env.HOME = home; + process.env.USERPROFILE = home; + delete process.env.OS_CLOUD_URL; + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + 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 }); + }); + + async function seed(config: Record) { + await writeFile(cloudJson(), JSON.stringify(config, null, 2)); + } + + it('keeps activeEnvironmentId across a re-login to the same control plane and account', async () => { + await seed({ url: CLOUD_PLANE, token: 'old_tok', userId: 'usr_1', activeEnvironmentId: 'env_kept', createdAt: 'then' }); + + await CloudLogin.run(['--url', CLOUD_PLANE, '--email', 'me@acme.test', '--password', 'pw', '--force']); + + const stored = await readCloud(); + expect(stored.token, 'the fixture never reached the write — the assertion below would mean nothing').toBe('fresh_tok'); + expect(stored.activeEnvironmentId).toBe('env_kept'); + }); + + it('keeps it when the url differs only by a trailing slash', async () => { + await seed({ url: `${CLOUD_PLANE}/`, token: 'old_tok', userId: 'usr_1', activeEnvironmentId: 'env_kept', createdAt: 'then' }); + + await CloudLogin.run(['--url', CLOUD_PLANE, '--email', 'me@acme.test', '--password', 'pw', '--force']); + + expect((await readCloud()).activeEnvironmentId).toBe('env_kept'); + }); + + it('drops it when the login is to a different control plane', async () => { + await seed({ url: 'http://elsewhere.test', token: 'old_tok', userId: 'usr_1', activeEnvironmentId: 'env_elsewhere', createdAt: 'then' }); + + await CloudLogin.run(['--url', CLOUD_PLANE, '--email', 'me@acme.test', '--password', 'pw', '--force']); + + const stored = await readCloud(); + expect(stored.token).toBe('fresh_tok'); + expect(stored.activeEnvironmentId).toBeUndefined(); + }); + + it('drops it when a different account logs in to the same control plane', async () => { + await seed({ url: CLOUD_PLANE, token: 'old_tok', userId: 'usr_1', activeEnvironmentId: 'env_of_usr_1', createdAt: 'then' }); + + await CloudLogin.run(['--url', CLOUD_PLANE, '--email', 'other@acme.test', '--password', 'pw', '--force']); + + const stored = await readCloud(); + expect(stored.userId).toBe('usr_other'); + expect(stored.activeEnvironmentId).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/deploy.test.ts b/packages/cli/test/deploy.test.ts new file mode 100644 index 00000000000..4b3caa23e8f --- /dev/null +++ b/packages/cli/test/deploy.test.ts @@ -0,0 +1,410 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os deploy` — the dev loop's defaults, against a stubbed control plane + * (#30871, scope amendment item 1). + * + * The command is run for real: its own credential and environment resolution, + * `os package publish` in-process for the publish, the real `cloud.json` under + * a redirected `$HOME`. Two seams are replaced: the build step + * (`compileForDeploy`, which would spawn a CLI) writes a fixed artifact, and + * `fetch` is the control plane. + * + * The wait reads `data.served` on `GET …/environments/{id}/packages/{manifestId}`, + * the contract objectstack-ai/cloud#3651 declares in objectstack-ai/cloud#3662 + * (`utils/served-version.ts` states it). The `served` fixtures below are that + * shape. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const compileForDeploy = vi.fn<(artifactPath: string) => number>(); +vi.mock('../src/utils/deploy-build.js', () => ({ compileForDeploy: (p: string) => compileForDeploy(p) })); + +const { default: Deploy, reportVerdict } = await import('../src/commands/deploy.js'); +const { waitUntilServed, interpretServedReport } = await import('../src/utils/served-version.js'); +const { devVersion } = await import('../src/utils/dev-version.js'); + +const CLOUD_PLANE = 'http://cloud.test'; +const MANAGED_ENV = ['OS_CLOUD_URL', 'OS_CLOUD_API_KEY', 'OS_TOKEN', 'OS_ENVIRONMENT_ID', 'OS_ORG_ID', 'OS_PACKAGE_MANIFEST_ID', 'OS_CLOUD_TIMEOUT_MS'] as const; + +type Call = { url: string; method: string; body: any }; + +interface PlaneOptions { + environments?: Array>; + /** `data.served` on the package read, given the version the publish created; absent ⇒ an older control plane. */ + served?: (published: { versionId: string; version: string }) => Record; + /** The environment's hostname on its detail read (default `acme.example.test`; `null` ⇒ none). */ + hostname?: string | null; +} + +const ENV_ORIGIN = 'https://acme.example.test'; + +/** The control plane: list, detail, package upsert, version publish (+install), package read — and the environment itself. */ +function stubPlane(options: PlaneOptions = {}): Call[] { + const calls: Call[] = []; + let published = { versionId: '', version: '' }; + let versionCount = 0; + vi.stubGlobal('fetch', vi.fn(async (input: string, init: any = {}) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const body = init?.body && typeof init.body === 'string' ? JSON.parse(init.body) : undefined; + calls.push({ url, method, body }); + const answer = (data: unknown) => ({ ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) }) as any; + if (url.startsWith(ENV_ORIGIN)) { + // The environment itself, mid-build: the framework's retryable 503. Cloud's + // KernelManager throws a warming error whose own lowercase code is only its + // declared code; the transport's ADR-0112 envelope answers SERVICE_UNAVAILABLE + // (packages/rest/src/rest-resolve-environment-single-window.test.ts pins the pair). + return { + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: async () => ({ + success: false, + error: { code: 'SERVICE_UNAVAILABLE', message: 'The environment is starting up (kernel build in progress). Retry in ~15s.' }, + }), + } as any; + } + if (method === 'GET' && url.endsWith('/api/v1/cloud/environments')) { + const environments = options.environments ?? [{ id: 'env_acme', display_name: 'Acme', status: 'active' }]; + return answer({ environments, total: environments.length }); + } + if (method === 'POST' && url.endsWith('/api/v1/cloud/packages')) { + return answer({ id: 'pkg_1', created: false, visibility: 'org' }); + } + if (method === 'POST' && url.endsWith('/api/v1/cloud/packages/pkg_1/versions')) { + versionCount += 1; + published = { versionId: `ver_${versionCount}`, version: body.version }; + return answer({ + id: published.versionId, + version: body.version, + listing_status: 'draft', + installation: body.install_env_id ? { environment_id: body.install_env_id, installation_id: 'inst_1' } : null, + }); + } + if (method === 'GET' && /\/api\/v1\/cloud\/environments\/[^/]+\/packages\/[^/]+$/.test(url)) { + return answer({ + package: { manifest_id: 'com.acme.crm', status: 'installed' }, + installed: true, + installationId: 'inst_1', + version: published.version, + ...(options.served ? { served: options.served(published) } : {}), + }); + } + if (method === 'GET' && /\/api\/v1\/cloud\/environments\/[^/]+$/.test(url)) { + const hostname = options.hostname === undefined ? 'acme.example.test' : options.hostname; + return answer({ environment: { id: url.split('/').pop(), ...(hostname ? { hostname } : {}) } }); + } + return { ok: false, status: 404, statusText: 'Not Found', json: async () => ({ success: false, error: { message: `unexpected ${method} ${url}` } }) } as any; + })); + return calls; +} + +describe('os deploy — the dev loop with no flags', () => { + let home = ''; + let work = ''; + const previous: Record = {}; + const prevCwd = process.cwd(); + let output: string[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'os-30871-deploy-home-')); + work = await mkdtemp(join(tmpdir(), 'os-30871-deploy-work-')); + await mkdir(join(home, '.objectstack'), { recursive: true }); + for (const key of ['HOME', 'USERPROFILE', ...MANAGED_ENV]) previous[key] = process.env[key]; + process.env.HOME = home; + process.env.USERPROFILE = home; + for (const key of MANAGED_ENV) delete process.env[key]; + await writeFile(join(home, '.objectstack', 'cloud.json'), JSON.stringify({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' })); + process.chdir(work); + + compileForDeploy.mockReset(); + compileForDeploy.mockImplementation((artifactPath: string) => { + // A synchronous write, as the real child's exit is: the artifact exists when the step returns. + 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.useRealTimers(); + 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 }); + }); + + 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'); + const versionPost = (calls: Call[]) => calls.find((c) => c.method === 'POST' && c.url.endsWith('/versions')); + const writes = (calls: Call[]) => calls.filter((c) => c.method !== 'GET'); + const live = (p: { versionId: string; version: string }) => ({ + state: 'live', desiredVersionId: p.versionId, versionId: p.versionId, version: p.version, + observedStatus: 'installed', observedAt: new Date().toISOString(), error: null, + }); + const packageReads = (calls: Call[]) => calls.filter((c) => c.method === 'GET' && /\/packages\/com\.acme\.crm$/.test(c.url)); + + it('builds, publishes -dev., installs into the only environment, and reports it live', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-11-06T08:30:05Z')); + const calls = stubPlane({ served: live }); + + const exit = await deploy(); + + expect(exit, text()).toBeUndefined(); + expect(compileForDeploy).toHaveBeenCalledWith(join(work, 'dist', 'objectstack.json')); + const body = versionPost(calls)!.body; + expect(body.version).toBe('1.2.0-dev.20261106083005'); + expect(body.is_pre_release).toBe(true); + expect(body.install_env_id).toBe('env_acme'); + expect(text()).toContain("Deploying to Acme (env_acme), your organization's only environment"); + expect(text()).toContain('Live: Acme (env_acme) now serves com.acme.crm 1.2.0-dev.20261106083005.'); + // The wait read `served` for THIS package in THIS environment — never the installed-state probe — + // and woke the environment itself, whose builds are request-driven. + expect(packageReads(calls).map((c) => c.url)).toContain(`${CLOUD_PLANE}/api/v1/cloud/environments/env_acme/packages/com.acme.crm`); + expect(calls.some((c) => c.url.includes('/installations/'))).toBe(false); + expect(calls.some((c) => c.url === `${ENV_ORIGIN}/api/v1/discovery`)).toBe(true); + }); + + it('deploys twice in a row from a fixed manifest.version, each time a new version, with no flag', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const calls = stubPlane({ served: live }); + + vi.setSystemTime(new Date('2026-11-06T08:30:05Z')); + expect(await deploy()).toBeUndefined(); + vi.setSystemTime(new Date('2026-11-06T08:31:40Z')); + expect(await deploy()).toBeUndefined(); + + const versions = calls.filter((c) => c.method === 'POST' && c.url.endsWith('/versions')).map((c) => c.body.version); + expect(versions).toEqual(['1.2.0-dev.20261106083005', '1.2.0-dev.20261106083140']); + }); + + it("prints the environment's build-failure reason and exits 1", async () => { + const calls = stubPlane({ + served: (p) => ({ + state: 'failed', desiredVersionId: p.versionId, versionId: 'ver_previous', version: '1.2.0-dev.20261106080000', + observedStatus: 'failed', observedAt: new Date().toISOString(), error: 'Object crm_deal: field stage references unknown picklist', + }), + }); + + const exit = await deploy(); + + expect(exit).toBe(1); + expect(versionPost(calls)).toBeDefined(); + expect(text()).toContain('could not build com.acme.crm 1.2.0-dev.'); + expect(text()).toContain('Object crm_deal: field stage references unknown picklist'); + }); + + it('a failed local build prints that and exits 1 having written nothing', async () => { + compileForDeploy.mockReset(); + compileForDeploy.mockReturnValue(1); + const calls = stubPlane({ served: live }); + + const exit = await deploy(); + + expect(exit).toBe(1); + expect(writes(calls)).toEqual([]); + expect(text()).toContain('The build failed'); + }); + + it('refuses several environments before building, naming --env, with exit 1', async () => { + const calls = stubPlane({ + environments: [{ id: 'env_a', display_name: 'Alpha', status: 'active' }, { id: 'env_b', display_name: 'Beta', status: 'active' }], + }); + + const exit = await deploy(); + + expect(exit).toBe(1); + expect(compileForDeploy).not.toHaveBeenCalled(); + expect(writes(calls)).toEqual([]); + expect(text()).toContain('Alpha (env_a)'); + expect(text()).toContain('--env '); + }); + + it('--env picks among several environments', async () => { + const calls = stubPlane({ + environments: [{ id: 'env_a', status: 'active' }, { id: 'env_b', status: 'active' }], + served: live, + }); + + expect(await deploy(['--env', 'env_b'])).toBeUndefined(); + expect(versionPost(calls)!.body.install_env_id).toBe('env_b'); + expect(calls.some((c) => c.url.endsWith('/api/v1/cloud/environments'))).toBe(false); + }); + + it('refuses when not logged in, before building, with exit 1', async () => { + await rm(join(home, '.objectstack', 'cloud.json')); + const calls = stubPlane(); + + const exit = await deploy(); + + expect(exit).toBe(1); + expect(compileForDeploy).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + expect(text()).toContain('os cloud login'); + }); + + it('says plainly when the control plane does not report a served version, and claims nothing live', async () => { + // A control plane from before the `served` contract: the package read carries no `served`. + stubPlane(); + + const exit = await deploy(); + + expect(exit).toBeUndefined(); + expect(text()).toContain('does not report which version an environment serves yet'); + expect(text()).not.toContain('Live:'); + }); + + it('--no-wait returns once the install is recorded, without reading the installation', async () => { + const calls = stubPlane({ served: live }); + + expect(await deploy(['--no-wait'])).toBeUndefined(); + expect(packageReads(calls)).toEqual([]); + expect(text()).toContain('(not waiting)'); + }); + + it('says it cannot nudge the rebuild when the environment has no URL, and still waits', async () => { + const calls = stubPlane({ served: live, hostname: null }); + + expect(await deploy()).toBeUndefined(); + expect(calls.some((c) => c.url.startsWith(ENV_ORIGIN))).toBe(false); + expect(text()).toContain("cannot nudge its rebuild"); + expect(text()).toContain('Live:'); + }); + + it('reports a deploy another install replaced, with exit 1, instead of calling it live', async () => { + stubPlane({ served: (p) => ({ ...live(p), desiredVersionId: 'ver_someone_else', versionId: 'ver_someone_else' }) }); + + expect(await deploy()).toBe(1); + expect(text()).toContain('another install replaced it'); + expect(text()).not.toContain('Live:'); + }); +}); + +describe('waitUntilServed — the one client function the wait goes through', () => { + const target = { + baseUrl: CLOUD_PLANE, token: 't', environmentId: 'env_acme', + manifestId: 'com.acme.crm', version: '1.2.0-dev.20261106083005', versionId: 'ver_2', + }; + const served = (fields: Record) => ({ ok: true as const, data: { served: { desiredVersionId: 'ver_2', ...fields } } }); + + it('polls through pending, waking the environment before every poll, until it is live', async () => { + const reports = [ + served({ state: 'pending', versionId: 'ver_1', version: '1.2.0-dev.20261106080000', observedStatus: 'installed' }), + served({ state: 'pending', versionId: 'ver_1', version: '1.2.0-dev.20261106080000', observedStatus: 'pending' }), + served({ state: 'live', versionId: 'ver_2', version: target.version, observedStatus: 'installed' }), + ]; + const order: string[] = []; + let clock = 0; + const verdict = await waitUntilServed(target, { + timeoutMs: 60_000, + intervalMs: 3_000, + wake: async () => { order.push('wake'); }, + fetchReport: async () => { order.push('read'); return reports.shift()!; }, + now: () => clock, + sleep: async (ms) => { clock += ms; }, + }); + expect(verdict).toEqual({ kind: 'served', version: target.version, incomplete: false }); + expect(order).toEqual(['wake', 'read', 'wake', 'read', 'wake', 'read']); + expect(clock).toBe(6_000); + }); + + it('a failed build carries the environment\'s error', async () => { + const verdict = await waitUntilServed(target, { + timeoutMs: 60_000, + intervalMs: 3_000, + fetchReport: async () => served({ state: 'failed', versionId: 'ver_1', observedStatus: 'failed', error: 'picklist stage missing' }), + now: () => 0, + sleep: async () => {}, + }); + expect(verdict).toEqual({ kind: 'failed', reason: 'picklist stage missing' }); + }); + + it('times out naming what it last saw, and reportVerdict names what it waited for and why failures read late', async () => { + let clock = 0; + const verdict = await waitUntilServed(target, { + timeoutMs: 9_000, + intervalMs: 3_000, + fetchReport: async () => served({ state: 'pending', versionId: 'ver_1', version: '1.1.0', observedStatus: 'installed' }), + now: () => clock, + sleep: async (ms) => { clock += ms; }, + }); + expect(verdict).toEqual({ kind: 'timeout', waitedMs: 9_000, lastObserved: 'installed, serving 1.1.0' }); + + const lines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { lines.push(a.map(String).join(' ')); }); + const code = reportVerdict(verdict, { where: 'Acme (env_acme)', manifestId: target.manifestId, version: target.version, baseUrl: CLOUD_PLANE }); + vi.restoreAllMocks(); + expect(code).toBe(1); + const said = lines.join('\n'); + expect(said).toContain('Timed out after 9s waiting for Acme (env_acme) to serve com.acme.crm 1.2.0-dev.20261106083005'); + expect(said).toContain('last report: installed, serving 1.1.0'); + expect(said).toContain('three failed rounds in a row'); + }); + + it('reads `live` for another version as superseded, never as this deploy going live', () => { + expect(interpretServedReport({ served: { state: 'live', desiredVersionId: 'ver_9', versionId: 'ver_9' } }, target)) + .toEqual({ kind: 'superseded', desiredVersionId: 'ver_9' }); + }); + + it('an answer with no `served` is a control plane from before the contract', () => { + expect(interpretServedReport({ installed: true, version: '1.2.0' }, target)).toEqual({ kind: 'unsupported' }); + }); + + it('stops at once on an answer that asking again will not change', async () => { + let polls = 0; + const verdict = await waitUntilServed(target, { + timeoutMs: 60_000, + intervalMs: 3_000, + fetchReport: async () => { polls += 1; return { ok: false, status: 403, error: 'forbidden', retryable: false }; }, + now: () => 0, + sleep: async () => {}, + }); + expect(verdict).toEqual({ kind: 'unreadable', status: 403, error: 'forbidden' }); + expect(polls).toBe(1); + }); +}); + +describe('devVersion — a semver pre-release of the version the author wrote', () => { + const at = new Date('2026-11-06T08:30:05Z'); + + it('appends -dev. (UTC) to a release version', () => { + expect(devVersion('1.2.0', at)).toEqual({ ok: true, version: '1.2.0-dev.20261106083005' }); + }); + it('keeps an existing pre-release and drops build metadata', () => { + expect(devVersion('1.2.0-beta.1+sha.abc', at)).toEqual({ ok: true, version: '1.2.0-beta.1.dev.20261106083005' }); + }); + it('falls back to 0.0.0 for a versionless artifact', () => { + expect(devVersion(undefined, at)).toEqual({ ok: true, version: '0.0.0-dev.20261106083005' }); + }); + it('refuses a version that is not semver rather than repairing it', () => { + const result = devVersion('v1.2', at); + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/cli/test/dev-deploy-key.test.ts b/packages/cli/test/dev-deploy-key.test.ts new file mode 100644 index 00000000000..fa8b50f2305 --- /dev/null +++ b/packages/cli/test/dev-deploy-key.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os dev`'s deploy key (#30871, scope amendment item 2): pressing `d` runs + * the deploy path; with no cloud login it logs in first. Never a save hook — + * nothing here is reachable from the watcher, only from a key. + * + * The key handler is driven through a fake terminal; the child it runs is + * checked at the spawn seam (`runCliChild`), so no CLI is started. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const spawnCalls: Array<{ command: string; args: string[]; env: NodeJS.ProcessEnv | undefined }> = []; +vi.mock('node:child_process', () => ({ + spawn: (command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => { + spawnCalls.push({ command, args, env: options?.env }); + const child = new EventEmitter(); + queueMicrotask(() => child.emit('exit', 0, null)); + return child; + }, +})); + +const { attachDeployKey, deployKeyHint, runCliChild } = await import('../src/utils/dev-deploy-key.js'); + +const HERE = dirname(fileURLToPath(import.meta.url)); + +class FakeTerminal extends EventEmitter { + isTTY = true; + raw = false; + rawHistory: boolean[] = []; + setRawMode(mode: boolean) { this.raw = mode; this.rawHistory.push(mode); return this; } + setEncoding() { return this; } + resume() { return this; } + pause() { return this; } + press(key: string) { this.emit('data', key); } +} + +function harness(opts: { loggedIn: boolean; loginSucceeds?: boolean }) { + const terminal = new FakeTerminal(); + const printed: string[] = []; + let loggedIn = opts.loggedIn; + const runDeploy = vi.fn(async () => { + // The terminal is handed to the child while it runs. + expect(terminal.raw, 'the deploy child must get a cooked terminal (a login prompt reads it)').toBe(false); + return 0; + }); + const runLogin = vi.fn(async () => { + if (opts.loginSucceeds) loggedIn = true; + return opts.loginSucceeds ? 0 : 1; + }); + const onInterrupt = vi.fn(); + const handle = attachDeployKey({ + input: terminal, + print: (line) => printed.push(line), + isLoggedIn: async () => loggedIn, + runDeploy, + runLogin, + onInterrupt, + }); + return { terminal, printed, runDeploy, runLogin, onInterrupt, handle }; +} + +describe('os dev deploy key', () => { + beforeEach(() => { spawnCalls.length = 0; }); + + it('offers the deploy and runs the deploy path on d, then offers it again', async () => { + const h = harness({ loggedIn: true }); + await h.handle!.offer(); + expect(h.printed).toEqual([deployKeyHint(true)]); + expect(h.printed[0]).toContain('Press d to deploy'); + + h.terminal.press('d'); + await h.handle!.current; + + expect(h.runDeploy).toHaveBeenCalledTimes(1); + expect(h.runLogin).not.toHaveBeenCalled(); + expect(h.terminal.raw, 'the key is listening again after the deploy').toBe(true); + expect(h.printed.at(-1)).toBe(deployKeyHint(true)); + }); + + it('not logged in: the line offers login, and d logs in, then deploys', async () => { + const h = harness({ loggedIn: false, loginSucceeds: true }); + await h.handle!.offer(); + expect(h.printed[0]).toBe(deployKeyHint(false)); + expect(h.printed[0]).toContain('log in to ObjectOS Cloud'); + + h.terminal.press('d'); + await h.handle!.current; + + expect(h.runLogin).toHaveBeenCalledTimes(1); + expect(h.runDeploy).toHaveBeenCalledTimes(1); + expect(h.runLogin.mock.invocationCallOrder[0]).toBeLessThan(h.runDeploy.mock.invocationCallOrder[0]); + }); + + it('a login that does not complete deploys nothing', async () => { + const h = harness({ loggedIn: false, loginSucceeds: false }); + + h.terminal.press('d'); + await h.handle!.current; + + expect(h.runDeploy).not.toHaveBeenCalled(); + expect(h.printed.join('\n')).toContain('nothing was deployed'); + }); + + it('ignores a second d while a deploy runs, and every other key', async () => { + const h = harness({ loggedIn: true }); + h.terminal.press('x'); + h.terminal.press('d'); + h.terminal.press('d'); + await h.handle!.current; + expect(h.runDeploy).toHaveBeenCalledTimes(1); + }); + + it('forwards Ctrl-C, which raw mode swallows, as an interrupt', () => { + const h = harness({ loggedIn: true }); + h.terminal.press('\u0003'); + expect(h.onInterrupt).toHaveBeenCalledTimes(1); + }); + + it('attaches nothing to a terminal that is not a TTY', () => { + const terminal = new FakeTerminal(); + terminal.isTTY = false; + const handle = attachDeployKey({ + input: terminal, print: () => {}, isLoggedIn: async () => true, + runDeploy: async () => 0, runLogin: async () => 0, onInterrupt: () => {}, + }); + expect(handle).toBeUndefined(); + expect(terminal.rawHistory).toEqual([]); + }); + + it('runs the real `os deploy` child, without the LOCAL environment id', async () => { + const before = process.env.OS_ENVIRONMENT_ID; + process.env.OS_ENVIRONMENT_ID = 'env_local'; + try { + expect(await runCliChild('/path/to/bin/run.js', ['deploy'])).toBe(0); + } finally { + if (before === undefined) delete process.env.OS_ENVIRONMENT_ID; + else process.env.OS_ENVIRONMENT_ID = before; + } + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].command).toBe(process.execPath); + expect(spawnCalls[0].args).toEqual(['/path/to/bin/run.js', 'deploy']); + expect( + spawnCalls[0].env?.OS_ENVIRONMENT_ID, + 'under os dev OS_ENVIRONMENT_ID names the local environment; os deploy reads it as the cloud --env', + ).toBeUndefined(); + }); + + it('is wired into os dev, which starts it once its first server listens', () => { + const dev = readFileSync(resolve(HERE, '../src/commands/dev.ts'), 'utf8'); + expect(dev).toMatch(/import \{ startDevDeployKey[^}]*\} from '\.\.\/utils\/dev-deploy-key\.js';/); + expect(dev).toMatch(/if \(!deployKey\) deployKey = startDevDeployKey\(binPath,/); + }); +}); diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index 47eaf3b2fcc..87c3e387a0a 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -516,3 +516,13 @@ describe('detectPackageManager', () => { expect(detectPackageManager({ npm_config_user_agent: 'npm/10.0.0 node/v22.0.0 linux x64' })).toBe('npm'); }); }); + +// The `app` scaffold is the deployable one, so it ships the dev loop's cloud +// step as `deploy` — the same command `os dev`'s `d` key runs and the +// create-objectstack template ships (its own pin holds that half). +describe('app scaffold ships a deploy script', () => { + it('renders `deploy: objectstack deploy` into the app package.json', () => { + const pkgJson = renderScaffoldPackageJson('my-app', TEMPLATES.app); + expect((pkgJson.scripts as Record).deploy).toBe('objectstack deploy'); + }); +}); diff --git a/packages/cli/test/next-steps.test.ts b/packages/cli/test/next-steps.test.ts new file mode 100644 index 00000000000..8bc5f241ee9 --- /dev/null +++ b/packages/cli/test/next-steps.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The CLI says the next step (#30871, scope amendment item 4): after a + * successful `os build` it names `os deploy`, and bare `os` (the root help + * page) lists the everyday commands first. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Config } from '@oclif/core'; +import ObjectStackHelp from '../src/help.js'; +import { BUILD_NEXT_STEP, EVERYDAY_COMMANDS, formatEverydayCommands } from '../src/utils/next-steps.js'; +import Build from '../src/commands/build.js'; +import Compile from '../src/commands/compile.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG = resolve(HERE, '..'); + +describe('bare os lists the everyday commands first', () => { + it('every everyday id is a real command', () => { + for (const { id } of EVERYDAY_COMMANDS) { + const file = join(PKG, 'src', 'commands', ...id.split(' ')) + '.ts'; + expect(existsSync(file), `\`os ${id}\` is listed as an everyday command but ${file} does not exist`).toBe(true); + } + }); + + it('the root help page carries the everyday block, deploy among it, ahead of the full listing', () => { + // The fields oclif's root page reads, and no more: a full `Config.load` + // scans the whole command table for a page that prints none of it. + const config = { + bin: 'os', + userAgent: '@objectstack/cli/0.0.0 linux-x64 node-v22', + topicSeparator: ' ', + pjson: { description: 'ObjectStack CLI', oclif: {} }, + } as unknown as Config; + const help = new ObjectStackHelp(config); + const root = help.formatRoot(); + expect(root).toContain(formatEverydayCommands(config.bin)); + expect(root).toMatch(/EVERYDAY COMMANDS\n {2}\S+ dev /); + expect(root).toContain(`${config.bin} deploy`); + // The block closes the root section, so oclif prints TOPICS / COMMANDS after it. + expect(root.trimEnd().endsWith(formatEverydayCommands(config.bin))).toBe(true); + }); + + it('is the help class oclif loads', async () => { + const pkg = JSON.parse(await (await import('node:fs/promises')).readFile(join(PKG, 'package.json'), 'utf8')); + expect(pkg.oclif.helpClass).toBe('./dist/help.js'); + }); +}); + +describe('os build names os deploy as the next step; os compile does not', () => { + let dir = ''; + afterEach(async () => { + vi.restoreAllMocks(); + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + async function captured(run: () => Promise): Promise { + const lines: string[] = []; + const record = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(record); + vi.spyOn(console, 'error').mockImplementation(record); + vi.spyOn(console, 'warn').mockImplementation(record); + const cwd = process.cwd(); + process.chdir(dir); + try { + await run(); + } finally { + process.chdir(cwd); + vi.restoreAllMocks(); + } + return lines.join('\n'); + } + + // Under the package's git-ignored `tmp/`, so the config's `@objectstack/spec` + // import resolves through this package's own dependencies. + async function stack(): Promise { + await mkdir(join(PKG, 'tmp'), { recursive: true }); + dir = await mkdtemp(join(PKG, 'tmp', 'next-step-')); + const config = join(dir, 'objectstack.config.ts'); + await writeFile( + config, + "import { defineStack } from '@objectstack/spec';\n" + + "export default defineStack({ manifest: { id: 'com.acme.hint', name: 'Hint', version: '1.0.0', type: 'app' } });\n", + ); + return config; + } + + it('os build ends with the deploy hint', async () => { + const config = await stack(); + const out = await captured(() => Build.run([config, '--output', join(dir, 'dist', 'objectstack.json')])); + expect(out).toContain('Build complete'); + expect(out).toContain(BUILD_NEXT_STEP); + }, 60_000); + + it('os compile — what os dev runs on every rebuild — prints no hint', async () => { + const config = await stack(); + const out = await captured(() => Compile.run([config, '--output', join(dir, 'dist', 'objectstack.json')])); + expect(out).toContain('Build complete'); + expect(out).not.toContain(BUILD_NEXT_STEP); + }, 60_000); +}); diff --git a/packages/cli/test/publish-active-environment-store.test.ts b/packages/cli/test/publish-active-environment-store.test.ts index 499ec186a91..faa7e2b3915 100644 --- a/packages/cli/test/publish-active-environment-store.test.ts +++ b/packages/cli/test/publish-active-environment-store.test.ts @@ -63,16 +63,23 @@ const MANAGED_ENV = [ type Call = { url: string; method: string; body: any }; -/** Stub `fetch` so both publish POSTs succeed, and record what was sent. */ -function stubCloud(): Call[] { +/** + * Stub `fetch` so both publish POSTs succeed, and record what was sent. + * `GET /cloud/environments` answers `environments` (default: none) — the list + * the install target falls back to when neither `--env` nor an active + * environment names one. + */ +function stubCloud(environments: Array> = []): Call[] { const calls: Call[] = []; vi.stubGlobal( 'fetch', vi.fn(async (url: string, init: any = {}) => { const body = init?.body ? JSON.parse(init.body) : undefined; calls.push({ url: String(url), method: init?.method ?? 'GET', body }); - const data = String(url).endsWith('/versions') - ? { id: 'ver_1', version: '1.2.0', listing_status: 'draft' } + const data = String(url).endsWith('/cloud/environments') + ? { environments, total: environments.length } + : String(url).endsWith('/versions') + ? { id: 'ver_1', version: '1.2.0', listing_status: 'draft', installation: body?.install_env_id ? { environment_id: body.install_env_id, installation_id: 'inst_1' } : null } : { id: 'pkg_1', created: true, visibility: 'org' }; return { ok: true, @@ -240,11 +247,14 @@ describe('#18265: the active environment publish installs into', () => { activeEnvironmentId: 'env_on_other_plane', createdAt: 'now', }); - const calls = stubCloud(); + // This plane's own list names a different environment — the only one the + // install may land in. + const calls = stubCloud([{ id: 'env_on_this_plane', display_name: 'Acme', status: 'active' }]); await PackagePublish.run([artifactPath, '--install']); - expect(versionBody(calls).install_env_id).toBeUndefined(); + expect(versionBody(calls).install_env_id).toBe('env_on_this_plane'); + expect(versionBody(calls).install_env_id).not.toBe('env_on_other_plane'); // …and the cross-plane id was not laundered into cloud.json either. expect((await readCloud()).activeEnvironmentId).toBeUndefined(); }); @@ -280,13 +290,13 @@ describe('#18265: the active environment publish installs into', () => { expect((await readCloud()).activeEnvironmentId).toBe('env_switched_before_upgrade'); }); - it('does not install when neither store has an active environment', async () => { + it('falls back to the control plane\'s environment list when neither store has an active environment', async () => { await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); - const calls = stubCloud(); + const calls = stubCloud([{ id: 'env_only', display_name: 'Acme', status: 'active' }]); await PackagePublish.run([artifactPath, '--install']); - expect(versionBody(calls).install_env_id).toBeUndefined(); + expect(versionBody(calls).install_env_id).toBe('env_only'); }); // ── The writing half: `os environments switch` ────────────────────────── @@ -363,4 +373,104 @@ describe('#18265: the active environment publish installs into', () => { expect(runtime.activeEnvironmentId).toBe('env_self_hosted_new'); }); }); + + // ── The organization's only environment, and the refusals (#30871 items 1, 3) ── + // + // With `--install` and neither `--env` nor an active environment, the target + // is the ONE environment the control plane lists for the caller — on + // ObjectOS Cloud every organization has exactly one (cloud ADR-0024). None, + // several, or an unreadable list is a refusal that exits non-zero BEFORE ANY + // WRITE: the package row is not even registered, so a publish that was asked + // to install and could not never reads as success. + describe('the only environment, and refusing before any write', () => { + async function runPublish(argv: string[]): Promise<{ exit: number | undefined; output: string }> { + const lines: string[] = []; + const record = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(record); + vi.spyOn(console, 'error').mockImplementation(record); + let exit: number | undefined; + try { + await PackagePublish.run(argv); + } catch (err: any) { + exit = err?.oclif?.exit ?? err?.exitCode; + if (exit === undefined) throw err; + } + return { exit, output: lines.join('\n') }; + } + + const writes = (calls: Call[]) => calls.filter((c) => c.method !== 'GET'); + + it('installs into the only environment and says which, skipping archived and system rows', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + const calls = stubCloud([ + { id: 'env_acme', display_name: 'Acme Production', status: 'active' }, + { id: 'env_old', display_name: 'Old', status: 'archived' }, + { id: 'env_platform', display_name: 'Platform', status: 'active', is_system: true }, + ]); + + const { exit, output } = await runPublish([artifactPath, '--install']); + + expect(exit).toBeUndefined(); + expect(versionBody(calls).install_env_id).toBe('env_acme'); + expect(output).toContain("Acme Production (env_acme), your organization's only environment"); + // The list was asked with the publish credential, of the publish's plane. + const list = calls.find((c) => c.url.endsWith('/cloud/environments')); + expect(list?.url).toBe(`${CLOUD_PLANE}/api/v1/cloud/environments`); + }); + + it('refuses several environments with exit 1, naming each and --env, before any write', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + const calls = stubCloud([ + { id: 'env_a', display_name: 'Alpha', status: 'active' }, + { id: 'env_b', display_name: 'Beta', status: 'active' }, + ]); + + const { exit, output } = await runPublish([artifactPath, '--install']); + + expect(exit).toBe(1); + expect(writes(calls), 'a refused --install must not register the package or publish a version').toEqual([]); + expect(output).toContain('Alpha (env_a)'); + expect(output).toContain('Beta (env_b)'); + expect(output).toContain('--env '); + }); + + it('refuses an organization with no environment, with exit 1, before any write', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + const calls = stubCloud([{ id: 'env_gone', status: 'archived' }]); + + const { exit, output } = await runPublish([artifactPath, '--install']); + + expect(exit).toBe(1); + expect(writes(calls)).toEqual([]); + expect(output).toContain('has none'); + expect(output).toContain('--env '); + }); + + it('refuses when the environment list cannot be read, with exit 1, before any write', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + const calls: Call[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init: any = {}) => { + calls.push({ url: String(url), method: init?.method ?? 'GET', body: undefined }); + return { ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({ success: false, error: { message: 'control plane down' } }) } as any; + })); + + const { exit, output } = await runPublish([artifactPath, '--install']); + + expect(exit).toBe(1); + expect(writes(calls)).toEqual([]); + expect(output).toContain('503'); + expect(output).toContain('--env '); + }); + + it('asks for no environment list when --install is not passed', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + const calls = stubCloud([{ id: 'env_a' }, { id: 'env_b' }]); + + const { exit } = await runPublish([artifactPath]); + + expect(exit).toBeUndefined(); + expect(calls.some((c) => c.url.endsWith('/cloud/environments'))).toBe(false); + expect(versionBody(calls).install_env_id).toBeUndefined(); + }); + }); }); diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index b687f79faea..eb4b4f167bf 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -379,6 +379,12 @@ const NOT_A_GATE: Readonly> = { // anything behind. 'An advisory about the local dev database, not the stack — says what the artifact leaves behind; judges no metadata and refuses nothing': ['readLocalCustomizations', 'localCustomizationNotice'], + // `os build`'s next-step line (`utils/next-steps.ts`): a constant string + // printed after a successful build, naming `os deploy`. It reads nothing and + // refuses nothing; `os validate` writes no artifact, so it has no next step + // to name. + 'The next step after a successful build — a fixed line naming the command that follows; reads and judges nothing': + ['BUILD_NEXT_STEP'], 'Presentation — renders, formats or serialises a verdict something else reached; judges nothing': [ 'printHeader', diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 39e8e33f729..870d274ecca 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -828,3 +828,22 @@ describe('syncObjectStackDeps', () => { expect(pkg.dependencies['@objectstack/spec']).toBe('^6.0.0'); }); }); + +// ── The dev loop's cloud step ships with every scaffold ────────────────────── +// +// A freshly created project deploys with `npm run deploy` — no command to +// remember, and the same one `os dev`'s `d` key runs. Every bundled template +// is held to it, and to the exact command: a `deploy` script that ran +// something else (`objectstack build`, `objectstack package publish`) would +// pass a presence check while never reaching the environment. +describe('bundled templates ship a deploy script', () => { + for (const template of fs + .readdirSync(TEMPLATES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name)) { + it(`${template}/package.json runs \`objectstack deploy\` as \`deploy\``, () => { + const pkg = JSON.parse(fs.readFileSync(path.join(TEMPLATES_DIR, template, 'package.json'), 'utf8')); + expect(pkg.scripts?.deploy).toBe('objectstack deploy'); + }); + } +}); diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index e11f0a65c04..4f9c7413fbc 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -10,6 +10,7 @@ "dev": "objectstack dev", "start": "objectstack start", "build": "objectstack build", + "deploy": "objectstack deploy", "validate": "objectstack validate", "lint": "objectstack lint", "typecheck": "tsc --noEmit",