diff --git a/.changeset/18265-active-environment-in-cloud-config.md b/.changeset/18265-active-environment-in-cloud-config.md new file mode 100644 index 00000000000..2845542e649 --- /dev/null +++ b/.changeset/18265-active-environment-in-cloud-config.md @@ -0,0 +1,14 @@ +--- +"@objectstack/cli": patch +--- + +`os package publish --install` installs into the environment `os environments switch` just selected, instead of refusing with ``--install` requires `--env ``. Nobody remembers a UUID. + +The two credential stores are two **identities on two servers**, and the active environment used to live in only one of them. `os environments switch` — and `os environments create --activate` — wrote `activeEnvironmentId` into `~/.objectstack/credentials.json` only (the runtime identity, written by `os login`); `os package publish` reads `~/.objectstack/cloud.json` (the cloud identity, written by `os cloud login`) and never opened the other file — so the environment the CLI had just called active, and that `os environments list` marks with a ★, was invisible to the one command that could install into it. + +- **The id now lives in `cloud.json`, beside `activeOrgId`** — the `CloudConfig` field that was already there for exactly this kind of control-plane scope selector, one level up. +- **`os environments switch` records it there as well** when the control plane it just talked to *is* `cloud.json`'s `url`, and keeps writing `credentials.json` unchanged — that copy is what `createApiClient` reads for the `data` / `meta` / `environments` families. +- **`os environments create --activate` records it too**, through the same helper — it is the *other* writer of an active environment id, and the first half of the flow this fixes: `os environments create --org $ORG --name Dev` then `os package publish --install`, with no `switch` in between. Creation succeeding while the record fails stays a warning, never an exit `1`. +- **`--install` with no `--env` and no `$OS_ENVIRONMENT_ID`** falls back to that value, and only when `cloud.json`'s `url` is the control plane being published to. +- **A value written by an older CLI is migrated once**, and only when both files' `url`s agree. +- ⛔ **Publish never reads `credentials.json` for this.** That is not a purity argument: the files carry *different servers* — `credentials.json`'s url falls back to `http://localhost:3000`, `cloud.json`'s default is `https://cloud.objectos.ai`, and the publish POSTs to the latter. An id taken from the runtime store can therefore name an environment on a **different control plane**, which the server resolves by bare id with no name or short-id rescue. The url gate, not the file name, is the invariant, and it lives in one place (`utils/active-environment.ts`). diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 9a34d83634a..10c338d02e0 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1795,8 +1795,8 @@ spells them, so one consumer reads both commands. |---------|-------------| | `os environments list` | List environments visible to the current session | | `os environments show ` | Show one environment | -| `os environments create` | Provision a new environment | -| `os environments switch ` | Set the active environment for later CLI calls | +| `os environments create` | Provision a new environment. With `--activate` (the default) the new environment becomes the active one, recorded in `~/.objectstack/cloud.json` as well when the control plane it talked to is the one `os cloud login` recorded — so [`os package publish --install`](#os-package-publish) can install into it with no `switch` in between | +| `os environments switch ` | Set the active environment for later CLI calls. Recorded in `~/.objectstack/cloud.json` as well when the control plane it talked to is the one `os cloud login` recorded, so [`os package publish --install`](#os-package-publish) can use it | | `os environments bind ` | Bind a compiled local artifact to an existing environment | #### Create an environment from a local artifact @@ -1853,6 +1853,7 @@ os package publish # dist/objectstack.jso os package publish --manifest-id com.acme.crm --version 1.2.0 os package publish dist/objectstack.json --visibility org --note "first cut" os package publish --env env_abc123 --install # publish, then install into an environment +os package publish --install # into the active environment (os environments switch) OS_CLOUD_URL=http://localhost:4000 os package publish # against a local control plane ``` @@ -1863,6 +1864,17 @@ OS_CLOUD_URL=http://localhost:4000 os package publish # against a local cont [`os login`](#os-login) writes, and the two are different accounts. With no token at all the command exits `1` and tells you to run `os cloud login`. +**The install target follows the same split.** `--install` with no `--env` uses +the environment `os environments switch` — or `os environments create +--activate` — recorded **in `cloud.json`**, and that +id is only used when `cloud.json`'s `url` is the control plane this publish is +POSTing to. It is never read out of `credentials.json`: the two files name +different servers (`credentials.json` defaults to `http://localhost:3000`), so +an id taken from there can belong to a different control plane — and the server +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. + **Options:** | Flag | Env equivalent | Purpose | @@ -1877,8 +1889,8 @@ token at all the command exits `1` and tells you to run `os cloud login`. | `--category ` | — | Marketplace category slug (`crm`, `hr`, `devtools`, …) | | `--visibility ` | — | `org` (default, installable across your organization) · `private` (explicit grants only) · `marketplace` (public after review) | | `--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 | -| `--install` | — | Auto-install into `--env` after publishing. Passed without `--env` it reports the mistake 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` | +| `--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 | | `--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 `--visibility marketplace` and a complete listing | diff --git a/content/docs/deployment/publish-and-preview.mdx b/content/docs/deployment/publish-and-preview.mdx index 6a1f3d79198..1add86ed438 100644 --- a/content/docs/deployment/publish-and-preview.mdx +++ b/content/docs/deployment/publish-and-preview.mdx @@ -136,6 +136,10 @@ os package publish # explicit artifact + install into an environment in one step os package publish ./dist/objectstack.json --env env_prod --install + +# no --env: installs into the active environment (`os environments switch`, +# or `os environments create --activate`) +os package publish --install ``` The CLI: @@ -154,9 +158,15 @@ Common flags: | `--version`, `-v` | — | Semver version (default: `artifact.manifest.version`) | | `--visibility` | — | `org` (default) · `private` · `marketplace` | | `--org` | `OS_ORG_ID` | Owner org id (service mode) | -| `--env` | `OS_ENVIRONMENT_ID` | Environment to install the new version into | +| `--env` | `OS_ENVIRONMENT_ID` | Environment to install the new version into. Defaults to the environment `os environments switch` (or `os environments create --activate`) recorded for this control plane in `~/.objectstack/cloud.json` | | `--install` | — | Auto-install the new version into `--env` after publishing | +The `--env` default is read from the **cloud** credential store only, and only +when that file's `url` is the control plane being published to. It is never +taken from `~/.objectstack/credentials.json`: that file records the runtime +identity's server (`http://localhost:3000` by default), so an environment id +from there can name an environment on a different control plane. + In user mode the package is owned by your active organization; in service mode (bearer key) pass `--org`. See [Packages](/docs/plugins/packages) for the package model. diff --git a/packages/cli/src/commands/environments/create.ts b/packages/cli/src/commands/environments/create.ts index 4cab5f7df9f..8385130ed81 100644 --- a/packages/cli/src/commands/environments/create.ts +++ b/packages/cli/src/commands/environments/create.ts @@ -5,6 +5,7 @@ import { printError, emitJson, isExitSignal, errorCodeFields } from '../../utils import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; +import { recordCloudActiveEnvironmentId } from '../../utils/active-environment.js'; /** * `os environments create` — provision a new environment. @@ -19,7 +20,15 @@ import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; * * On success, optionally activates the new environment for the current session * and persists `activeEnvironmentId` into `~/.objectstack/credentials.json` - * (unless `--no-activate` is passed). + * (unless `--no-activate` is passed). When the control plane it just talked + * to IS the one `~/.objectstack/cloud.json` records, the same id is written + * there as well, so `os package publish --install` can install into the + * environment you just created without repeating the uuid. + * + * `os environments switch` records it through the SAME helper. An environment + * id is only meaningful against the server that issued it, and that gate is + * written once, in `utils/active-environment.ts` — two copies of it is how + * one of them stops gating. */ export default class EnvironmentsCreate extends Command { static override description = 'Provision a new environment'; @@ -66,7 +75,7 @@ export default class EnvironmentsCreate extends Command { const { flags } = await this.parse(EnvironmentsCreate); try { - const { client, token } = await createApiClient({ url: flags.url, token: flags.token }); + const { client, token, baseUrl } = await createApiClient({ url: flags.url, token: flags.token }); requireAuth(token); // Resolve the artifact to an absolute path so the server can read it @@ -97,9 +106,20 @@ export default class EnvironmentsCreate extends Command { ...(metadata ? { metadata } : {}), }); + let recordedForCloud = false; if (flags.activate && res?.environment?.id) { try { await client.environments.activate(res.environment.id); + + // Cloud store first, for the reason `os environments switch` writes + // it first: the server session is already switched by the call above, + // so the publish-side record must not be lost to a failure in the + // runtime store below (a user who only ran `os cloud login` has no + // `credentials.json` at all). The url gate inside the helper decides + // whether this id belongs in the cloud store; this call must not grow + // a second copy of it. + recordedForCloud = await recordCloudActiveEnvironmentId(res.environment.id, baseUrl); + const cfg = await readAuthConfig().catch(() => null); if (cfg) { cfg.activeEnvironmentId = res.environment.id; @@ -121,6 +141,9 @@ export default class EnvironmentsCreate extends Command { console.log(`\n✓ Environment created: ${p.display_name ?? p.id} (${p.id})`); if (flags.activate) { console.log(` active environment set to ${p.id}`); + if (recordedForCloud) { + console.log(' (also recorded in cloud.json — `os package publish --install` will use it)'); + } } console.log(''); } diff --git a/packages/cli/src/commands/environments/switch.ts b/packages/cli/src/commands/environments/switch.ts index e74c7c9d313..698877787ad 100644 --- a/packages/cli/src/commands/environments/switch.ts +++ b/packages/cli/src/commands/environments/switch.ts @@ -4,6 +4,7 @@ import { Args, Command, Flags } from '@oclif/core'; import { printError } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; +import { recordCloudActiveEnvironmentId } from '../../utils/active-environment.js'; /** * `os environments switch ` — set the active environment for this CLI session. @@ -13,6 +14,13 @@ import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; * `~/.objectstack/credentials.json` so subsequent CLI commands (and any * client they create via `createApiClient`) automatically target this * environment. + * + * When the control plane it just talked to IS the one `~/.objectstack/cloud.json` + * records, the same id is written there as well, so `os package publish --install` + * can install into the environment you just switched to without repeating the + * uuid. Two files, two servers, an active environment for each — the id is + * never carried across, because it would not resolve on the other side. The + * gate lives in `utils/active-environment.ts`. */ export default class EnvironmentsSwitch extends Command { static override description = 'Activate an environment for subsequent CLI calls'; @@ -40,7 +48,7 @@ export default class EnvironmentsSwitch extends Command { const { args, flags } = await this.parse(EnvironmentsSwitch); try { - const { client, token } = await createApiClient({ url: flags.url, token: flags.token }); + const { client, token, baseUrl } = await createApiClient({ url: flags.url, token: flags.token }); requireAuth(token); // Sanity-check the id resolves — fail fast before writing the cred file @@ -54,13 +62,30 @@ export default class EnvironmentsSwitch extends Command { await client.environments.activate(environment.id); } - const cfg = await readAuthConfig(); - cfg.activeEnvironmentId = environment.id; - cfg.lastUsedAt = new Date().toISOString(); - await writeAuthConfig(cfg); + // Cloud store first: the server session is already switched at this + // point, so the publish-side record must not be lost to a failure in the + // runtime store below (a user who only ran `os cloud login` has no + // `credentials.json` at all). + const recordedForCloud = await recordCloudActiveEnvironmentId(environment.id, baseUrl); + + // Runtime store: unchanged behaviour. This is the copy `createApiClient` + // reads, so the `data` / `meta` / `environments` families keep targeting + // the environment you just switched to. + const cfg = await readAuthConfig().catch(() => null); + if (cfg) { + cfg.activeEnvironmentId = environment.id; + cfg.lastUsedAt = new Date().toISOString(); + await writeAuthConfig(cfg); + } console.log(`\n✓ Active environment: ${environment.display_name ?? environment.id}`); console.log(` id: ${environment.id}`); + if (recordedForCloud) { + console.log(' (also recorded in cloud.json — `os package publish --install` will use it)'); + } + if (!recordedForCloud && !cfg) { + console.log(' ⚠ no local credential store to record it in — run `os login` or `os cloud login`'); + } if (!flags.remote) { console.log(' (local only — server session unchanged)'); } diff --git a/packages/cli/src/commands/package/publish.ts b/packages/cli/src/commands/package/publish.ts index 26b35b2a3a7..1ee543724d7 100644 --- a/packages/cli/src/commands/package/publish.ts +++ b/packages/cli/src/commands/package/publish.ts @@ -10,7 +10,9 @@ * active organization (user mode) or supplied via --org (service mode). * 2. POST /cloud/packages/:id/versions — snapshot dist/objectstack.json * into sys_package_version.manifest_json (status=published). - * 3. (optional) auto-install into a target environment via --env. + * 3. (optional) auto-install into a target environment via --env, or the + * environment `os environments switch` recorded in cloud.json for the + * control plane being published to. * * This is the "upload my local code to my org" path — the single supported * way to publish. (The legacy direct-to-environment `os publish` / `os @@ -32,6 +34,7 @@ import { Args, Command, Flags } from '@oclif/core'; import { PackageSchema } from '@objectstack/spec/marketplace'; import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js'; import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.js'; +import { resolveCloudActiveEnvironmentId } from '../../utils/active-environment.js'; import { readErrorMessage } from '../../utils/response-envelope.js'; /** @@ -279,6 +282,7 @@ export default class PackagePublish extends Command { '$ os package publish', '$ os package publish --manifest-id com.acme.crm --version 1.2.0', '$ os package publish --env env_abc123 --install', + '$ os package publish --install # into the active environment (os environments switch)', '$ os package publish dist/objectstack.json --visibility org --note "first cut"', '$ OS_CLOUD_URL=http://localhost:4000 os package publish # local dev', ]; @@ -333,7 +337,9 @@ export default class PackagePublish extends Command { env: 'OS_ORG_ID', }), env: Flags.string({ - description: 'Environment id to install the new version into after publish', + description: + 'Environment id to install the new version into after publish. Defaults to the ' + + 'environment `os environments switch` recorded for this control plane in cloud.json', env: 'OS_ENVIRONMENT_ID', }), install: Flags.boolean({ @@ -658,12 +664,38 @@ export default class PackagePublish extends Command { if (flags.submit) verBody.submit_for_review = true; if (flags['auto-approve']) verBody.auto_approve = true; - const shouldInstall = flags.install && flags.env; + // 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 = flags.env; + verBody.install_env_id = installEnvId; verBody.seed_sample_data = flags['seed-sample-data']; - } else if (flags.install && !flags.env) { - printError('`--install` requires `--env `. Skipping auto-install.'); + 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.', + ); } const verRes = await this.postJson( diff --git a/packages/cli/src/utils/active-environment.ts b/packages/cli/src/utils/active-environment.ts new file mode 100644 index 00000000000..a9b2e50c432 --- /dev/null +++ b/packages/cli/src/utils/active-environment.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where an "active environment id" lives, and which control plane it belongs to. + * + * ## An environment id is only meaningful against ONE server + * + * The CLI keeps two credential stores on purpose (`cloud-config.ts`'s header + * states the split): `credentials.json` is the **runtime** identity — who you + * are inside your own ObjectOS instance — and `cloud.json` is the **cloud** + * identity on the package registry. They are not two spellings of one account: + * they carry **different servers**. `credentials.json`'s url falls back to + * `http://localhost:3000` (`api-client.ts`), `cloud.json`'s default is + * `https://cloud.objectos.ai` (`DEFAULT_CLOUD_URL`), and that second one is + * where `os package publish` POSTs. + * + * So `activeEnvironmentId` means *"an environment on the server this file is + * about"*. Handing `credentials.json`'s copy to a publish aimed at + * cloud.objectos.ai names a uuid belonging to a **different control plane**; + * the server resolves an install target by bare id, with no name or short-id + * rescue, so the best case is a 404 and the worst is a stranger's id. + * + * ⛔ That is why `os package publish` must never read `credentials.json` for + * this value, and why every read and write below is gated on the two urls + * agreeing. The gate — not the file name — is the invariant. + * + * ## Two stores, two active environments + * + * `os environments switch` keeps writing `credentials.json` (that is the copy + * `createApiClient` reads for the `data` / `meta` / `environments` families, + * and `os environments` authenticating as the runtime identity is deliberate). + * When the control plane it just talked to IS `cloud.json`'s server, it records + * the same id there too — and that is the copy the publish reads back. + * + * ## Two writers, ONE gate + * + * `os environments switch ` is not the only command that names an active + * environment: `os environments create --activate` (the default) names the one + * it just provisioned, and that is the FIRST half of the flow this module + * exists for — create your own cloud dev environment, publish into it, with no + * `switch` anywhere. Both writers call `recordCloudActiveEnvironmentId` and + * neither carries a url check of its own: a second copy of this gate is how + * one of the two stops gating while every test still passes. + */ + +import { readAuthConfig } from './auth-config.js'; +import { tryReadCloudConfig, writeCloudConfig } from './cloud-config.js'; + +/** + * A base url reduced to the identity of the server it names: scheme, host and + * path, without a trailing slash, query or fragment. `new URL` already + * lower-cases scheme and host; an unparseable string is compared as typed + * rather than silently treated as "no url", so a malformed entry can still + * only ever match itself. + */ +export function normalizeControlPlaneUrl(url: string | undefined | null): string | undefined { + if (typeof url !== 'string') return undefined; + const trimmed = url.trim(); + if (!trimmed) return undefined; + try { + const parsed = new URL(trimmed); + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, '')}`; + } catch { + return trimmed.replace(/\/+$/, ''); + } +} + +/** + * True when two recorded urls name the same control plane. An absent url on + * either side is NOT a match: "I do not know which server this is" must never + * read as "the one you are pointed at". + */ +export function isSameControlPlane( + a: string | undefined | null, + b: string | undefined | null, +): boolean { + const left = normalizeControlPlaneUrl(a); + const right = normalizeControlPlaneUrl(b); + return left !== undefined && left === right; +} + +/** + * Record `environmentId` as the active environment in `cloud.json`, but only + * when `controlPlaneUrl` is the server that file is about. + * + * @returns `true` when the id was written, `false` when there is no cloud + * credential or it belongs to a different control plane (both ordinary — + * the caller keeps its own store either way). + */ +export async function recordCloudActiveEnvironmentId( + environmentId: string, + controlPlaneUrl: string | undefined, +): Promise { + const cloud = await tryReadCloudConfig(); + if (!cloud) return false; + if (!isSameControlPlane(cloud.url, controlPlaneUrl)) return false; + + cloud.activeEnvironmentId = environmentId; + cloud.lastUsedAt = new Date().toISOString(); + await writeCloudConfig(cloud); + return true; +} + +/** + * The active environment id for the control plane at `controlPlaneUrl`, read + * from `cloud.json` — the `--env` fallback for `os package publish`. + * + * One-time migration: a user who ran `os environments switch` before this + * value existed in `cloud.json` has it in `credentials.json` instead. It is + * copied across once — and **only** when both files' urls name the control + * plane being published to, so the copy can never cross planes. Once copied, + * `credentials.json` is not consulted again; the migration is the single + * guarded seam, never a resolution path. + */ +export async function resolveCloudActiveEnvironmentId( + controlPlaneUrl: string | undefined, +): Promise { + const cloud = await tryReadCloudConfig(); + if (!cloud) return undefined; + if (!isSameControlPlane(cloud.url, controlPlaneUrl)) return undefined; + if (cloud.activeEnvironmentId) return cloud.activeEnvironmentId; + + const runtime = await readAuthConfig().catch(() => undefined); + if (!runtime?.activeEnvironmentId) return undefined; + if (!isSameControlPlane(runtime.url, controlPlaneUrl)) return undefined; + + cloud.activeEnvironmentId = runtime.activeEnvironmentId; + await writeCloudConfig(cloud); + return cloud.activeEnvironmentId; +} diff --git a/packages/cli/src/utils/api-client.ts b/packages/cli/src/utils/api-client.ts index 393e44e7ee8..c3893d84b2f 100644 --- a/packages/cli/src/utils/api-client.ts +++ b/packages/cli/src/utils/api-client.ts @@ -34,6 +34,12 @@ export interface ApiClientResult { client: ObjectStackClient; token?: string; environmentId?: string; + /** + * The control-plane URL this client actually talks to, after the precedence + * below has been applied. Returned so a caller never has to re-derive it — + * a second copy of that precedence is how the two drift apart. + */ + baseUrl: string; } /** @@ -85,7 +91,7 @@ export async function createApiClient(options: ApiClientOptions = {}): Promise` says "✓ Active + * environment", and the very next `os package publish --install` answers + * "`--install` requires `--env `". The tempting repair is to let publish + * read `credentials.json`, where `switch` used to write the id — and a test + * that only pinned "a fallback exists" would go GREEN on that repair. + * + * It must not. The two credential files carry **different servers**: + * `credentials.json`'s url falls back to `http://localhost:3000`, `cloud.json`'s + * default is `https://cloud.objectos.ai`, and the publish POSTs to the latter. + * An `activeEnvironmentId` read out of the runtime store therefore names an + * environment on a possibly different control plane, and the server resolves an + * install target by bare id with no name or short-id rescue. So the guards come + * in pairs, and each was ablated: + * + * 1. delete the fallback ⇒ `it('installs into the active cloud environment')` reds + * 2. point the fallback at `credentials.json` ⇒ `it('reads the CLOUD store, not + * the runtime store')` reds — the two stores are seeded with DIFFERENT ids + * under the SAME url, so only the source of the value can tell them apart. + * 3. make `os environments create --activate` skip the cloud write ⇒ the + * `create --activate` case reds. There are TWO writers of an active + * environment id, and fixing only `switch` leaves the most natural path + * — create your own dev environment, publish into it — still refusing. + * + * ## Why `$HOME` is redirected rather than the modules mocked + * + * Both stores build every path from `os.homedir()`, which reads `$HOME` on + * POSIX and `%USERPROFILE%` on Windows. Redirecting both puts the real + * `readAuthConfig` / `tryReadCloudConfig` / `writeCloudConfig` on the real + * `node:fs` under test — including the one-time migration, which is a WRITE + * whose absence a mocked module would hide. + */ + +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'; +import PackagePublish from '../src/commands/package/publish.js'; +import EnvironmentsSwitch from '../src/commands/environments/switch.js'; +import EnvironmentsCreate from '../src/commands/environments/create.js'; + +const CLOUD_PLANE = 'http://cloud.test'; +const OTHER_PLANE = 'http://self-hosted.test:3000'; + +/** Env vars that feed an oclif flag on either command — cleared for every case. */ +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 }; + +/** Stub `fetch` so both publish POSTs succeed, and record what was sent. */ +function stubCloud(): 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' } + : { id: 'pkg_1', created: true, visibility: 'org' }; + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ success: true, data }), + } as any; + }), + ); + return calls; +} + +/** Stub `fetch` for `os environments switch` — lookup then activate. */ +function stubEnvironments(environment: { id: string; display_name?: string }): Call[] { + 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: true, + status: 200, + statusText: 'OK', + json: async () => ({ success: true, data: { environment } }), + } as any; + }), + ); + return calls; +} + +describe('#18265: the active environment publish installs into', () => { + let home = ''; + let work = ''; + let artifactPath = ''; + const previous: Record = {}; + const prevCwd = process.cwd(); + + const cloudJson = () => join(home, '.objectstack', 'cloud.json'); + const credentialsJson = () => join(home, '.objectstack', 'credentials.json'); + + async function writeCloud(config: Record): Promise { + await writeFile(cloudJson(), JSON.stringify(config, null, 2)); + } + + async function writeCredentials(config: Record): Promise { + await writeFile(credentialsJson(), JSON.stringify(config, null, 2)); + } + + async function readCloud(): Promise { + return JSON.parse(await readFile(cloudJson(), 'utf8')); + } + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'os-18265-home-')); + work = await mkdtemp(join(tmpdir(), 'os-18265-work-')); + await mkdir(join(home, '.objectstack'), { recursive: true }); + + previous.HOME = process.env.HOME; + previous.USERPROFILE = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + for (const key of MANAGED_ENV) { + previous[key] = process.env[key]; + delete process.env[key]; + } + + artifactPath = join(work, 'objectstack.json'); + await writeFile( + artifactPath, + JSON.stringify({ + manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.2.0' }, + objects: [], + }), + ); + }); + + afterEach(async () => { + process.chdir(prevCwd); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + for (const key of ['HOME', 'USERPROFILE', ...MANAGED_ENV]) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + if (home) await rm(home, { recursive: true, force: true }); + if (work) await rm(work, { recursive: true, force: true }); + }); + + /** The body of the `POST .../versions` call, which carries `install_env_id`. */ + function versionBody(calls: Call[]): any { + const call = calls.find((c) => c.url.endsWith('/versions')); + expect( + call, + 'the publish never reached POST /versions -- the fixture broke before the behaviour ' + + 'under test could run, so a passing assertion below would mean nothing', + ).toBeDefined(); + return call!.body; + } + + it('redirects both credential stores into the temp home (self-validating fixture)', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + const { tryReadCloudConfig } = await import('../src/utils/cloud-config.js'); + const stored = await tryReadCloudConfig(); + expect(stored?.token).toBe('cloud_tok'); + }); + + // ── Guard 1: the fallback exists ──────────────────────────────────────── + it('installs into the active cloud environment when --install carries no --env', async () => { + await writeCloud({ + url: CLOUD_PLANE, + token: 'cloud_tok', + activeEnvironmentId: 'env_cloud_active', + createdAt: 'now', + }); + const calls = stubCloud(); + + await PackagePublish.run([artifactPath, '--install']); + + expect(versionBody(calls).install_env_id).toBe('env_cloud_active'); + // The publish really went to the plane the id belongs to. + expect(calls.every((c) => c.url.startsWith(CLOUD_PLANE))).toBe(true); + }); + + // ── Guard 2: it is the CLOUD store, not the runtime store ─────────────── + it('reads the CLOUD store, not the runtime store, even when both name this same server', async () => { + // Same url on both files, so nothing but the SOURCE of the value can + // distinguish a correct fallback from a `credentials.json` one. + await writeCloud({ + url: CLOUD_PLANE, + token: 'cloud_tok', + activeEnvironmentId: 'env_from_cloud_json', + createdAt: 'now', + }); + await writeCredentials({ + url: CLOUD_PLANE, + token: 'runtime_tok', + activeEnvironmentId: 'env_from_credentials_json', + createdAt: 'now', + }); + const calls = stubCloud(); + + await PackagePublish.run([artifactPath, '--install']); + + const body = versionBody(calls); + expect(body.install_env_id).toBe('env_from_cloud_json'); + expect( + body.install_env_id, + 'publish resolved its install target out of the RUNTIME credential store. That file ' + + 'records an environment on whatever server `os login` pointed at -- localhost:3000 by ' + + 'default -- so the id can belong to a different control plane than the one this publish ' + + 'is POSTing to, which the server resolves by bare id with no name rescue.', + ).not.toBe('env_from_credentials_json'); + }); + + it('refuses to install across control planes: an active environment recorded elsewhere is not used', async () => { + await writeCloud({ + url: CLOUD_PLANE, + token: 'cloud_tok', + createdAt: 'now', + }); + // The runtime store knows an active environment, but on another server. + await writeCredentials({ + url: OTHER_PLANE, + token: 'runtime_tok', + activeEnvironmentId: 'env_on_other_plane', + createdAt: 'now', + }); + const calls = stubCloud(); + + await PackagePublish.run([artifactPath, '--install']); + + expect(versionBody(calls).install_env_id).toBeUndefined(); + // …and the cross-plane id was not laundered into cloud.json either. + expect((await readCloud()).activeEnvironmentId).toBeUndefined(); + }); + + it('keeps --env authoritative over the active environment', async () => { + await writeCloud({ + url: CLOUD_PLANE, + token: 'cloud_tok', + activeEnvironmentId: 'env_cloud_active', + createdAt: 'now', + }); + const calls = stubCloud(); + + await PackagePublish.run([artifactPath, '--install', '--env', 'env_explicit']); + + expect(versionBody(calls).install_env_id).toBe('env_explicit'); + }); + + it('migrates a pre-existing runtime value into cloud.json once, only when the urls agree', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + await writeCredentials({ + url: CLOUD_PLANE, + token: 'runtime_tok', + activeEnvironmentId: 'env_switched_before_upgrade', + createdAt: 'now', + }); + const calls = stubCloud(); + + await PackagePublish.run([artifactPath, '--install']); + + expect(versionBody(calls).install_env_id).toBe('env_switched_before_upgrade'); + // Copied across, so the runtime store is never consulted again. + expect((await readCloud()).activeEnvironmentId).toBe('env_switched_before_upgrade'); + }); + + it('does not install when neither store has an active environment', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + const calls = stubCloud(); + + await PackagePublish.run([artifactPath, '--install']); + + expect(versionBody(calls).install_env_id).toBeUndefined(); + }); + + // ── The writing half: `os environments switch` ────────────────────────── + describe('os environments switch records the id for the cloud plane too', () => { + it('writes cloud.json when the control plane it talked to is cloud.json’s own', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + await writeCredentials({ url: CLOUD_PLANE, token: 'runtime_tok', createdAt: 'now' }); + stubEnvironments({ id: 'env_switched', display_name: 'Dev' }); + + await EnvironmentsSwitch.run(['env_switched']); + + expect((await readCloud()).activeEnvironmentId).toBe('env_switched'); + // The runtime store keeps its copy: `createApiClient` reads THAT one for + // the data / meta / environments families. + const runtime = JSON.parse(await readFile(credentialsJson(), 'utf8')); + expect(runtime.activeEnvironmentId).toBe('env_switched'); + }); + + it('leaves cloud.json alone when the switch talked to a different control plane', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + await writeCredentials({ url: OTHER_PLANE, token: 'runtime_tok', createdAt: 'now' }); + stubEnvironments({ id: 'env_self_hosted', display_name: 'Local' }); + + await EnvironmentsSwitch.run(['env_self_hosted']); + + expect((await readCloud()).activeEnvironmentId).toBeUndefined(); + const runtime = JSON.parse(await readFile(credentialsJson(), 'utf8')); + expect(runtime.activeEnvironmentId).toBe('env_self_hosted'); + }); + }); + + // ── The other writer: `os environments create --activate` ──────────────── + // + // `switch` is not the only command that names an active environment, and it + // is not the one the card's own scenario starts with. "I created my own cloud + // dev environment, now publish to it" is `create --activate` followed by + // `publish --install`, with no `switch` anywhere — so a fix that reaches only + // `switch` still refuses on the most natural path while reading like a fix. + describe('os environments create --activate records the id for the cloud plane too', () => { + it('a freshly created environment is immediately a publish target, with no switch in between', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + await writeCredentials({ url: CLOUD_PLANE, token: 'runtime_tok', createdAt: 'now' }); + stubEnvironments({ id: 'env_created', display_name: 'Dev' }); + + await EnvironmentsCreate.run(['--org', 'org_1', '--name', 'Dev']); + + expect( + (await readCloud()).activeEnvironmentId, + 'create --activate recorded the new environment in the runtime store only, so the very ' + + 'next `os package publish --install` cannot see it. That is the same defect as the one ' + + 'this file guards on `switch`, one command over.', + ).toBe('env_created'); + + // The runtime store keeps its copy too — `createApiClient` reads THAT one. + const runtime = JSON.parse(await readFile(credentialsJson(), 'utf8')); + expect(runtime.activeEnvironmentId).toBe('env_created'); + + // …and the publish half really resolves it, end to end. + const calls = stubCloud(); + await PackagePublish.run([artifactPath, '--install']); + expect(versionBody(calls).install_env_id).toBe('env_created'); + }); + + it('leaves cloud.json alone when the create talked to a different control plane', async () => { + await writeCloud({ url: CLOUD_PLANE, token: 'cloud_tok', createdAt: 'now' }); + await writeCredentials({ url: OTHER_PLANE, token: 'runtime_tok', createdAt: 'now' }); + stubEnvironments({ id: 'env_self_hosted_new', display_name: 'Local' }); + + await EnvironmentsCreate.run(['--org', 'org_1', '--name', 'Local']); + + expect((await readCloud()).activeEnvironmentId).toBeUndefined(); + const runtime = JSON.parse(await readFile(credentialsJson(), 'utf8')); + expect(runtime.activeEnvironmentId).toBe('env_self_hosted_new'); + }); + }); +});