From 3b4f429edd9bca67a161be9ee987d514a103a74e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:08:04 +0800 Subject: [PATCH 1/7] feat(cli): carry the active environment in cloud.json so `os package publish --install` can use it `os environments switch` persisted `activeEnvironmentId` into the RUNTIME credential store (`credentials.json`); `os package publish` reads the CLOUD store (`cloud.json`) and never opens the other one, so `--install` refused with "`--install` requires `--env `" right after a successful switch. The two files carry different servers, so publish must not read the runtime copy: that id names an environment on a possibly different control plane. Instead the id now lives in `cloud.json` next to `activeOrgId`, written by `switch` when the control plane it just talked to IS `cloud.json`'s `url`, and read back by publish under the same gate. `utils/active-environment.ts` owns that gate; a one-time, url-guarded migration carries an existing value across for users who switched before this change. Claude-Session: https://claude.ai/code/session_82286b62-3514-46f6-8d53-ca6fbb6df3c6 Co-authored-by: Claude --- .../cli/src/commands/environments/switch.ts | 35 ++++- packages/cli/src/commands/package/publish.ts | 40 +++++- packages/cli/src/utils/active-environment.ts | 120 ++++++++++++++++++ packages/cli/src/utils/api-client.ts | 8 +- packages/cli/src/utils/cloud-config.ts | 8 ++ 5 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/utils/active-environment.ts 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..09c3b307246 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,34 @@ 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)`); + } + } else if (flags.install && !installEnvId) { + printError( + '`--install` requires `--env `, $OS_ENVIRONMENT_ID, or an active environment ' + + '(`os environments switch ` 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..2e9ec18e3cb --- /dev/null +++ b/packages/cli/src/utils/active-environment.ts @@ -0,0 +1,120 @@ +// 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. + */ + +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 Date: Tue, 15 Sep 2026 13:12:27 +0800 Subject: [PATCH 2/7] =?UTF-8?q?test(cli):=20guard=20both=20halves=20?= =?UTF-8?q?=E2=80=94=20a=20fallback=20exists,=20and=20it=20is=20the=20clou?= =?UTF-8?q?d=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two guards, because the wrong fix (publish reads credentials.json) looks exactly like the right one from the outside. The second case seeds both credential files with DIFFERENT active environment ids under the SAME url, so only the SOURCE of the value can distinguish them; a fallback pointed at the runtime store reds there while still passing the first case. Claude-Session: https://claude.ai/code/session_82286b62-3514-46f6-8d53-ca6fbb6df3c6 Co-authored-by: Claude --- .../publish-active-environment-store.test.ts | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 packages/cli/test/publish-active-environment-store.test.ts diff --git a/packages/cli/test/publish-active-environment-store.test.ts b/packages/cli/test/publish-active-environment-store.test.ts new file mode 100644 index 00000000000..6ae0ea17f88 --- /dev/null +++ b/packages/cli/test/publish-active-environment-store.test.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `#18265` guards — the active environment `os package publish --install` + * uses comes from the CLOUD credential store, and only from the control plane + * it belongs to. + * + * ## Two guards, because two fixes look alike and only one is correct + * + * The symptom is one line: `os environments switch ` 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. + * + * ## 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'; + +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'); + }); + }); +}); From 915f505a459ebb3bac99c74279fc9f7b750786d8 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:13:44 +0800 Subject: [PATCH 3/7] docs(deployment): state where the --install target comes from, and where it does not Claude-Session: https://claude.ai/code/session_82286b62-3514-46f6-8d53-ca6fbb6df3c6 Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 17 ++++++++++++++--- content/docs/deployment/publish-and-preview.mdx | 11 ++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 9a34d83634a..6d10757bcab 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1796,7 +1796,7 @@ 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 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,16 @@ 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` 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 +1888,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) 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..343b86aebf9 100644 --- a/content/docs/deployment/publish-and-preview.mdx +++ b/content/docs/deployment/publish-and-preview.mdx @@ -136,6 +136,9 @@ 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 environment `os environments switch` last selected +os package publish --install ``` The CLI: @@ -154,9 +157,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` 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. From 23f4765d721f73b65c81f5b1467013e527df9980 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:14:42 +0800 Subject: [PATCH 4/7] =?UTF-8?q?chore(changeset):=20@objectstack/cli=20patc?= =?UTF-8?q?h=20=E2=80=94=20active=20environment=20in=20the=20cloud=20crede?= =?UTF-8?q?ntial=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_82286b62-3514-46f6-8d53-ca6fbb6df3c6 Co-authored-by: Claude --- .../18265-active-environment-in-cloud-config.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/18265-active-environment-in-cloud-config.md 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..c64a3734e05 --- /dev/null +++ b/.changeset/18265-active-environment-in-cloud-config.md @@ -0,0 +1,13 @@ +--- +"@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` wrote `activeEnvironmentId` into `~/.objectstack/credentials.json` (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. +- **`--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`). From 97001e44fc11111a2f5e3667fa242e81181d2797 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:52:12 +0800 Subject: [PATCH 5/7] feat(cli): `os environments create --activate` records the active environment for the cloud plane too `switch` was not the only writer of an active environment id. `create --activate` (the default) names the environment it just provisioned, and it is the first half of the flow this work exists for -- create your own cloud dev environment, then publish into it, with no `switch` anywhere. It wrote `credentials.json` only, so the most natural path still answered "`--install` requires `--env `". It now records through the same `utils/active-environment.ts` helper, so the url gate -- an environment id is used only on the control plane whose `url` recorded it -- has exactly one implementation across both writers. The existing failure posture is unchanged: creation succeeding while the activation or the record fails is a warning, never an exit 1. Both user-visible strings in `package publish` named only `os environments switch` as the source of the value; they name both writers now. --- .../cli/src/commands/environments/create.ts | 27 +++++++++++++++++-- packages/cli/src/commands/package/publish.ts | 8 ++++-- packages/cli/src/utils/active-environment.ts | 10 +++++++ 3 files changed, 41 insertions(+), 4 deletions(-) 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/package/publish.ts b/packages/cli/src/commands/package/publish.ts index 09c3b307246..1ee543724d7 100644 --- a/packages/cli/src/commands/package/publish.ts +++ b/packages/cli/src/commands/package/publish.ts @@ -685,12 +685,16 @@ export default class PackagePublish extends Command { verBody.install_env_id = installEnvId; verBody.seed_sample_data = flags['seed-sample-data']; if (installEnvFromActive) { - printStep(`Installing into the active environment ${installEnvId} (os environments switch)`); + 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 ` against this control plane). Skipping auto-install.', + + '(`os environments switch ` or `os environments create --activate` against this ' + + 'control plane). Skipping auto-install.', ); } diff --git a/packages/cli/src/utils/active-environment.ts b/packages/cli/src/utils/active-environment.ts index 2e9ec18e3cb..a9b2e50c432 100644 --- a/packages/cli/src/utils/active-environment.ts +++ b/packages/cli/src/utils/active-environment.ts @@ -31,6 +31,16 @@ * 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'; From 6c951e41cc77dcc037e5c586c88b552f4899a635 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:52:20 +0800 Subject: [PATCH 6/7] test(cli): guard the second writer -- create --activate makes a publish target Two cases beside the `switch` pair, mirroring it exactly: create against the cloud plane records into `cloud.json` and the very next `publish --install` resolves it end to end; create against a different control plane leaves `cloud.json` untouched while the runtime store still takes its copy. The first case is the one an ablation that skips the cloud write in `create.ts` has to turn red -- without it, a fix that reaches only `switch` reads as a fix while the card's own scenario still refuses. --- .../publish-active-environment-store.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/cli/test/publish-active-environment-store.test.ts b/packages/cli/test/publish-active-environment-store.test.ts index 6ae0ea17f88..3654a623f21 100644 --- a/packages/cli/test/publish-active-environment-store.test.ts +++ b/packages/cli/test/publish-active-environment-store.test.ts @@ -25,6 +25,10 @@ * 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 * @@ -41,6 +45,7 @@ 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'; @@ -312,4 +317,49 @@ describe('#18265: the active environment publish installs into', () => { 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'); + }); + }); }); From a841e177ff6b9ed502e35ab223a64dbf4a588d91 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:52:20 +0800 Subject: [PATCH 7/7] docs(deployment): the active environment has two writers, and both pages say so `os environments create --activate` is as much a source of the `--install` target as `os environments switch`, so the CLI reference's environments table, the `--env` flag rows on both pages, the install-target prose and the changeset all name it. The url gate they describe is unchanged. --- .changeset/18265-active-environment-in-cloud-config.md | 3 ++- content/docs/deployment/cli.mdx | 7 ++++--- content/docs/deployment/publish-and-preview.mdx | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.changeset/18265-active-environment-in-cloud-config.md b/.changeset/18265-active-environment-in-cloud-config.md index c64a3734e05..2845542e649 100644 --- a/.changeset/18265-active-environment-in-cloud-config.md +++ b/.changeset/18265-active-environment-in-cloud-config.md @@ -4,10 +4,11 @@ `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` wrote `activeEnvironmentId` into `~/.objectstack/credentials.json` (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 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 6d10757bcab..10c338d02e0 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1795,7 +1795,7 @@ 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 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 | @@ -1865,7 +1865,8 @@ OS_CLOUD_URL=http://localhost:4000 os package publish # against a local cont 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` recorded **in `cloud.json`**, and that +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 @@ -1888,7 +1889,7 @@ when both files' `url`s agree. | `--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. Defaults to the environment [`os environments switch`](#cloud-environments) recorded **for this control plane** in `~/.objectstack/cloud.json` | +| `--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) | diff --git a/content/docs/deployment/publish-and-preview.mdx b/content/docs/deployment/publish-and-preview.mdx index 343b86aebf9..1add86ed438 100644 --- a/content/docs/deployment/publish-and-preview.mdx +++ b/content/docs/deployment/publish-and-preview.mdx @@ -137,7 +137,8 @@ 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 environment `os environments switch` last selected +# no --env: installs into the active environment (`os environments switch`, +# or `os environments create --activate`) os package publish --install ``` @@ -157,7 +158,7 @@ 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. Defaults to the environment `os environments switch` recorded for this control plane in `~/.objectstack/cloud.json` | +| `--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