From eb9dcdaeb4d42f3158c271059d29af4605c2db6b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:39:35 +0000 Subject: [PATCH 1/4] test(cli): pin the stored session os environments authenticates with Five subcommands against a local echo control plane: only cloud.json, only credentials.json (the control), and both. Red on 51550933db: with cloud.json alone every subcommand refuses before sending a request. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../environments/cloud-session.test.ts | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 packages/cli/src/commands/environments/cloud-session.test.ts diff --git a/packages/cli/src/commands/environments/cloud-session.test.ts b/packages/cli/src/commands/environments/cloud-session.test.ts new file mode 100644 index 00000000000..fa84fd9c74b --- /dev/null +++ b/packages/cli/src/commands/environments/cloud-session.test.ts @@ -0,0 +1,335 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which stored session `os environments` authenticates with — the pins for + * `createControlPlaneApiClient` (`utils/api-client.ts`). + * + * ## The defect these pins hold closed + * + * The documented hosted flow is `os cloud login`, then `os environments + * create`. `os cloud login` stores its session in `~/.objectstack/cloud.json`; + * the five `os environments` subcommands read only `credentials.json` (the + * `os login` session), so with `cloud.json` alone every one of them exited 1 + * with "Authentication required" before sending a request — while + * `os login --help` sends hosted users to `os cloud login`. The flow looped. + * + * ## The resolution, as ruled + * + * One resolver, shared by all five subcommands: `credentials.json`'s session + * first where it targets the server this command talks to, then `cloud.json`'s. + * So the pins come in three groups, and each one is measured over ALL FIVE + * subcommands — a fix that reaches four of them reads like a fix: + * + * 1. only `cloud.json` — the cloud bearer goes to the cloud url; + * 2. only `credentials.json` — the control: nothing about it moves; + * 3. both — `credentials.json` wins where both name the same server, and + * `--url` naming `cloud.json`'s server selects the cloud session. + * + * Plus the guard on the other side of that ordering: a stored token never goes + * to a server its file does not name, and the active environment `switch` / + * `create` record lands only in the store whose server was talked to. + * + * ## Why a real local HTTP server, and `$HOME` redirected + * + * The commands run in-process through `Command.run`, against a `node:http` + * echo control plane on 127.0.0.1 that records the method, path, bearer and + * `X-Environment-Id` of every request. So the assertion is on what really + * left the CLI — the `ObjectStackClient` on the real `fetch` — not on a stub + * of it. Both credential stores build their paths from `os.homedir()`, which + * reads `$HOME` (`%USERPROFILE%` on Windows), so redirecting both puts the + * real readers and writers on a temp directory and never on the developer's + * own `~/.objectstack`. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { mkdtemp, mkdir, rm, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import EnvironmentsList from './list.js'; +import EnvironmentsShow from './show.js'; +import EnvironmentsCreate from './create.js'; +import EnvironmentsBind from './bind.js'; +import EnvironmentsSwitch from './switch.js'; + +/** One request as the echo control plane received it. */ +interface Seen { + method: string; + path: string; + authorization: string | null; + environmentId: string | null; +} + +interface Echo { + url: string; + seen: Seen[]; + close(): Promise; +} + +/** A minimal control plane answering the five subcommands' routes with success. */ +async function startEcho(): Promise { + const seen: Seen[] = []; + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + req.resume(); + req.on('end', () => { + const path = (req.url ?? '').split('?')[0]; + seen.push({ + method: req.method ?? '', + path, + authorization: req.headers.authorization ?? null, + environmentId: (req.headers['x-environment-id'] as string | undefined) ?? null, + }); + let data: unknown; + if (req.method === 'GET' && path === '/api/v1/cloud/environments') { + data = { environments: [{ id: 'env_1', display_name: 'Dev' }], total: 1 }; + } else if (req.method === 'POST' && path === '/api/v1/cloud/environments') { + data = { environment: { id: 'env_new', display_name: 'Dev' } }; + } else { + data = { environment: { id: 'env_1', display_name: 'Dev', metadata: {} } }; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + seen, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +/** Env vars that feed a flag or the resolver — cleared for every case. */ +const MANAGED_ENV = ['OS_CLOUD_URL', 'OS_TOKEN', 'OS_ENVIRONMENT_ID'] as const; + +let cloudPlane: Echo; +let runtimePlane: Echo; +let home = ''; +let artifactPath = ''; +const previous: Record = {}; + +beforeAll(async () => { + cloudPlane = await startEcho(); + runtimePlane = await startEcho(); +}); + +afterAll(async () => { + await cloudPlane.close(); + await runtimePlane.close(); +}); + +beforeEach(async () => { + cloudPlane.seen.length = 0; + runtimePlane.seen.length = 0; + home = await mkdtemp(join(tmpdir(), 'os-21360-home-')); + await mkdir(join(home, '.objectstack'), { recursive: true }); + artifactPath = join(home, 'objectstack.json'); + await writeFile(artifactPath, JSON.stringify({ manifest: { id: 'com.acme.crm' }, objects: [] })); + 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]; + } +}); + +afterEach(async () => { + 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 }); +}); + +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({ createdAt: 'now', ...config }, null, 2)); +} + +async function writeCredentials(config: Record): Promise { + await writeFile(credentialsJson(), JSON.stringify({ createdAt: 'now', ...config }, null, 2)); +} + +async function readStore(path: string): Promise> { + return JSON.parse(await readFile(path, 'utf8')); +} + +/** The five subcommands, each with the arguments it needs to reach the server. */ +const SUBCOMMANDS: ReadonlyArray Promise]> = [ + ['list', (extra) => EnvironmentsList.run([...extra])], + ['show', (extra) => EnvironmentsShow.run(['env_1', ...extra])], + ['create', (extra) => EnvironmentsCreate.run(['--org', 'org_1', '--name', 'Dev', ...extra])], + ['bind', (extra) => EnvironmentsBind.run(['env_1', '--artifact', artifactPath, ...extra])], + ['switch', (extra) => EnvironmentsSwitch.run(['env_1', ...extra])], +]; + +interface Outcome { + /** The oclif exit code a failing command threw, or `undefined` on success. */ + exit: number | undefined; + /** Everything the command printed, both streams. */ + output: string; +} + +/** Run one subcommand in-process, capturing its output and its exit. */ +async function invoke(run: (extra: string[]) => Promise, extra: string[] = []): Promise { + const lines: string[] = []; + const capture = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + let exit: number | undefined; + try { + await run(extra); + } catch (error: any) { + exit = typeof error?.oclif?.exit === 'number' ? error.oclif.exit : -1; + lines.push(String(error?.message ?? error)); + } finally { + vi.restoreAllMocks(); + } + return { exit, output: lines.join('\n') }; +} + +/** Assert the command succeeded and every request it sent went to `plane` carrying `bearer`. */ +function expectServedBy(name: string, outcome: Outcome, plane: Echo, other: Echo, bearer: string): void { + expect(outcome.exit, `os environments ${name} failed:\n${outcome.output}`).toBeUndefined(); + expect( + plane.seen.length, + `os environments ${name} sent no request to the server it should have talked to -- a ` + + 'refusal before any request is exactly the defect, so every assertion below would be vacuous', + ).toBeGreaterThan(0); + expect(plane.seen.map((s) => s.authorization)).toEqual(plane.seen.map(() => `Bearer ${bearer}`)); + expect(other.seen, `os environments ${name} also talked to the other control plane`).toEqual([]); +} + +describe('os environments: the stored session it authenticates with', () => { + // ── 1. only cloud.json — the state right after `os cloud login` ────────── + describe('with only cloud.json (the `os cloud login` session)', () => { + it.each(SUBCOMMANDS)('%s sends the cloud bearer to the cloud url', async (name, run) => { + await writeCloud({ url: cloudPlane.url, token: 'cloud_tok', activeEnvironmentId: 'env_cloud_active' }); + + const outcome = await invoke(run); + + expectServedBy(name, outcome, cloudPlane, runtimePlane, 'cloud_tok'); + // The active environment travels with the session it was recorded on. + expect(cloudPlane.seen[0].environmentId).toBe('env_cloud_active'); + }); + + it.each(SUBCOMMANDS)( + '%s never sends the cloud bearer to a --url cloud.json does not name', + async (name, run) => { + await writeCloud({ url: cloudPlane.url, token: 'cloud_tok' }); + + const outcome = await invoke(run, ['--url', runtimePlane.url]); + + expect(outcome.exit, `os environments ${name} should have refused:\n${outcome.output}`).toBe(1); + expect(runtimePlane.seen).toEqual([]); + expect(cloudPlane.seen).toEqual([]); + }, + ); + }); + + // ── 2. only credentials.json — the control ─────────────────────────────── + describe('with only credentials.json (the `os login` session) — unchanged', () => { + it.each(SUBCOMMANDS)('%s sends the runtime bearer to the runtime url', async (name, run) => { + await writeCredentials({ url: runtimePlane.url, token: 'runtime_tok', activeEnvironmentId: 'env_runtime_active' }); + + const outcome = await invoke(run); + + expectServedBy(name, outcome, runtimePlane, cloudPlane, 'runtime_tok'); + expect(runtimePlane.seen[0].environmentId).toBe('env_runtime_active'); + }); + + it.each(SUBCOMMANDS)('%s sends the runtime bearer to an explicit --url, as before', async (name, run) => { + await writeCredentials({ url: 'http://127.0.0.1:1', token: 'runtime_tok' }); + + const outcome = await invoke(run, ['--url', runtimePlane.url]); + + expectServedBy(name, outcome, runtimePlane, cloudPlane, 'runtime_tok'); + }); + }); + + // ── 3. both stores ─────────────────────────────────────────────────────── + describe('with both stores', () => { + it.each(SUBCOMMANDS)('%s: credentials.json wins where both name the same server', async (name, run) => { + await writeCloud({ url: cloudPlane.url, token: 'cloud_tok', activeEnvironmentId: 'env_from_cloud_json' }); + await writeCredentials({ url: cloudPlane.url, token: 'runtime_tok', activeEnvironmentId: 'env_from_credentials_json' }); + + const outcome = await invoke(run); + + expectServedBy(name, outcome, cloudPlane, runtimePlane, 'runtime_tok'); + expect(cloudPlane.seen[0].environmentId).toBe('env_from_credentials_json'); + }); + + it.each(SUBCOMMANDS)('%s: with no --url, credentials.json still picks the server', async (name, run) => { + await writeCloud({ url: cloudPlane.url, token: 'cloud_tok' }); + await writeCredentials({ url: runtimePlane.url, token: 'runtime_tok' }); + + const outcome = await invoke(run); + + expectServedBy(name, outcome, runtimePlane, cloudPlane, 'runtime_tok'); + }); + + it.each(SUBCOMMANDS)('%s: --url naming the server of cloud.json selects the cloud session', async (name, run) => { + await writeCloud({ url: cloudPlane.url, token: 'cloud_tok', activeEnvironmentId: 'env_cloud_active' }); + await writeCredentials({ url: runtimePlane.url, token: 'runtime_tok', activeEnvironmentId: 'env_runtime_active' }); + + const outcome = await invoke(run, ['--url', `${cloudPlane.url}/`]); + + expectServedBy(name, outcome, cloudPlane, runtimePlane, 'cloud_tok'); + expect(cloudPlane.seen[0].environmentId).toBe('env_cloud_active'); + }); + }); + + // ── where the active environment is recorded ───────────────────────────── + // + // `switch` and `create --activate` record the id they activated. On the + // cloud session the server talked to is cloud.json's, and credentials.json — + // if it exists — names another server, where that id would not resolve. + describe('the active environment lands only in the store whose server was talked to', () => { + it.each([ + ['switch', (extra: string[]) => EnvironmentsSwitch.run(['env_1', ...extra]), 'env_1'], + ['create', (extra: string[]) => EnvironmentsCreate.run(['--org', 'org_1', '--name', 'Dev', ...extra]), 'env_new'], + ] as const)('%s on the cloud session leaves credentials.json alone', async (name, run, id) => { + await writeCloud({ url: cloudPlane.url, token: 'cloud_tok' }); + await writeCredentials({ url: runtimePlane.url, token: 'runtime_tok', activeEnvironmentId: 'env_runtime_active' }); + + const outcome = await invoke(run, ['--url', cloudPlane.url]); + + expectServedBy(name, outcome, cloudPlane, runtimePlane, 'cloud_tok'); + expect((await readStore(cloudJson())).activeEnvironmentId).toBe(id); + expect( + (await readStore(credentialsJson())).activeEnvironmentId, + `os environments ${name} wrote an environment of cloud.json's server into credentials.json, ` + + 'whose server is a different one -- every `os data` / `os meta` call would then name it there', + ).toBe('env_runtime_active'); + }); + + it.each([ + ['switch', (extra: string[]) => EnvironmentsSwitch.run(['env_1', ...extra]), 'env_1'], + ['create', (extra: string[]) => EnvironmentsCreate.run(['--org', 'org_1', '--name', 'Dev', ...extra]), 'env_new'], + ] as const)('%s with only cloud.json records the id in cloud.json', async (name, run, id) => { + await writeCloud({ url: cloudPlane.url, token: 'cloud_tok' }); + + const outcome = await invoke(run); + + expectServedBy(name, outcome, cloudPlane, runtimePlane, 'cloud_tok'); + expect((await readStore(cloudJson())).activeEnvironmentId).toBe(id); + }); + }); + + // ── no session at all ──────────────────────────────────────────────────── + it.each(SUBCOMMANDS)('%s with no stored session names `os cloud login` in its refusal', async (name, run) => { + const outcome = await invoke(run); + + expect(outcome.exit).toBe(1); + expect(runtimePlane.seen).toEqual([]); + expect(cloudPlane.seen).toEqual([]); + expect(outcome.output, `os environments ${name}: the remedy must name the hosted login`).toContain('os cloud login'); + }); +}); From dda699186c00f7ac30f6625ba38363c19cc111fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:45:56 +0000 Subject: [PATCH 2/4] fix(cli): os environments runs on the os cloud login session through one resolver createControlPlaneApiClient (utils/api-client.ts) chooses the stored session for list, show, create, bind and switch: credentials.json's session where it targets the server (with no --url it names it), else cloud.json's on the same terms; an explicit url neither file names keeps credentials.json's session as before and never gets the cloud token. The active environment comes from the chosen file, and switch / create --activate no longer write a cloud environment id into credentials.json when they ran on the cloud session. The refusal with no session names os cloud login too. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../21360-environments-cloud-session.md | 28 ++++ packages/cli/README.md | 17 ++- .../cli/src/commands/environments/bind.ts | 6 +- .../environments/create-clone-from.test.ts | 2 +- .../cli/src/commands/environments/create.ts | 24 ++- .../cli/src/commands/environments/list.ts | 6 +- .../cli/src/commands/environments/show.ts | 6 +- .../cli/src/commands/environments/switch.ts | 25 ++-- packages/cli/src/utils/api-client.ts | 140 ++++++++++++++++-- packages/cli/src/utils/cloud-config.ts | 12 +- .../publish-active-environment-store.test.ts | 3 +- 11 files changed, 223 insertions(+), 46 deletions(-) create mode 100644 .changeset/21360-environments-cloud-session.md diff --git a/.changeset/21360-environments-cloud-session.md b/.changeset/21360-environments-cloud-session.md new file mode 100644 index 00000000000..b6d4f817b83 --- /dev/null +++ b/.changeset/21360-environments-cloud-session.md @@ -0,0 +1,28 @@ +--- +'@objectstack/cli': patch +--- + +`os environments list | show | create | bind | switch` run on the `os cloud login` session + +Clause-②: no + +The documented hosted flow is `os cloud login`, then `os environments create`. The five +`os environments` subcommands read only `~/.objectstack/credentials.json` (the `os login` +session), so with only `~/.objectstack/cloud.json` they exited 1 with +`Authentication required` before sending any request, while `os login --help` sends hosted +users to `os cloud login`. + +All five now choose their session in one shared resolver: + +- With no `--url` / `OS_CLOUD_URL`, they use the `os login` session when there is one, which + is the same behaviour as before. Otherwise they use the `os cloud login` session and the URL + it recorded. +- With a `--url`, they use the session whose file names that server, `credentials.json` first. + When neither file names it, they use `credentials.json`'s session as before. The cloud token + is never sent to a URL other than its own. +- The active environment sent with each request comes from the chosen session's file. + `os environments switch` and `create --activate` no longer write a cloud environment id into + `credentials.json` when they ran on the cloud session. + +With no session at all, the `Authentication required` message now names `os cloud login` as +well as `os login`. `os package publish` is unchanged: it still reads only `cloud.json`. diff --git a/packages/cli/README.md b/packages/cli/README.md index 17d4c721642..109a5f38d75 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -96,14 +96,19 @@ Typical flow (build → publish → install into an environment, seeding sample ```bash os compile # → dist/objectstack.json -os cloud login # one-time; the session os package publish reads -os environments create --org "$ORG" --name "Dev" --activate # does NOT read that session — see below +os cloud login # one-time; the session os package publish and os environments read +os environments create --org "$ORG" --name "Dev" --activate os package publish --env --install --seed-sample-data ``` -`os environments create` does not use the session `os cloud login` stored: give -it `--url` and `--token` (or `OS_CLOUD_URL` / `OS_TOKEN`), or an `os login` -session. Without either it exits 1 with `Authentication required`. +`os environments` runs on either stored session. With no `--url` it talks to +the server of the `os login` session (`credentials.json`) when there is one, +else to the server of the `os cloud login` session (`cloud.json`), with that +session's token. A `--url` (or `OS_CLOUD_URL`) picks the session that names +that server, `credentials.json`'s first — so with both stored, a `--url` naming +the cloud uses the cloud session. A `--url` neither file names gets +`credentials.json`'s token as before, never `cloud.json`'s. With no session and +no `--token` / `OS_TOKEN`, it exits 1 with `Authentication required`. `os package publish` registers a `sys_package` (keyed by a reverse-domain `--manifest-id`, derived from the artifact when omitted), snapshots the @@ -124,7 +129,7 @@ Two stored sessions exist, and each command authenticates with one of them: | `os cloud login` | `-u, --url` (env `OS_CLOUD_URL`, default `https://cloud.objectos.ai`) | none — `-e, --email` / `-p, --password`, or the browser device flow | writes `~/.objectstack/cloud.json` | | `os cloud whoami` / `os cloud logout` | — | — | reads / deletes `~/.objectstack/cloud.json` | | `os package publish`, `os plugin publish` | `-s, --server` (env `OS_CLOUD_URL`); else the URL in `cloud.json`; else `https://cloud.objectos.ai` | `-t, --token` (env `OS_CLOUD_API_KEY`, then `OS_TOKEN`) | `~/.objectstack/cloud.json` — the `os cloud login` session | -| `os environments list` / `show` / `create` / `bind` / `switch` | `-u, --url` (env `OS_CLOUD_URL`); else the URL in `credentials.json`; else `http://localhost:3000` | `-t, --token` (env `OS_TOKEN`) | `~/.objectstack/credentials.json` — the `os login` session, **not** `os cloud login`'s | +| `os environments list` / `show` / `create` / `bind` / `switch` | `-u, --url` (env `OS_CLOUD_URL`); else the URL of the stored session it uses; else `http://localhost:3000` | `-t, --token` (env `OS_TOKEN`) | No `--url`: `~/.objectstack/credentials.json` (the `os login` session), else `~/.objectstack/cloud.json` (the `os cloud login` session). With `--url`: the file that names that server, `credentials.json` first; when neither does, `credentials.json` as before. `cloud.json`'s token is never sent to another URL | `os package install` is not a cloud command: it installs into a running runtime (`-r, --runtime`, env `OS_RUNTIME_URL`, default `http://localhost:3000`) and signs diff --git a/packages/cli/src/commands/environments/bind.ts b/packages/cli/src/commands/environments/bind.ts index 67fb7d85c2e..18c505683f4 100644 --- a/packages/cli/src/commands/environments/bind.ts +++ b/packages/cli/src/commands/environments/bind.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import { spawnSync } from 'node:child_process'; import { printError, printStep, printKV, emitJson, isExitSignal, errorCodeFields } from '../../utils/format.js'; -import { createApiClient, requireAuth } from '../../utils/api-client.js'; +import { createControlPlaneApiClient, requireControlPlaneAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; /** @@ -99,8 +99,8 @@ export default class EnvironmentsBind extends Command { this.exit(1); } - const { client, token } = await createApiClient({ url: flags.url, token: flags.token }); - requireAuth(token); + const { client, token } = await createControlPlaneApiClient({ url: flags.url, token: flags.token }); + requireControlPlaneAuth(token); // Fetch existing metadata so we don't blow it away. const current = await client.environments.get(args.environmentId); diff --git a/packages/cli/src/commands/environments/create-clone-from.test.ts b/packages/cli/src/commands/environments/create-clone-from.test.ts index 8f52d1e4fea..6173959b575 100644 --- a/packages/cli/src/commands/environments/create-clone-from.test.ts +++ b/packages/cli/src/commands/environments/create-clone-from.test.ts @@ -52,7 +52,7 @@ vi.mock('../../utils/api-client.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - createApiClient: async () => ({ client: stub.client, token: stub.token, baseUrl: 'https://door.test' }), + createControlPlaneApiClient: async () => ({ client: stub.client, token: stub.token, baseUrl: 'https://door.test' }), }; }); diff --git a/packages/cli/src/commands/environments/create.ts b/packages/cli/src/commands/environments/create.ts index 9d0c6152f2a..5e9adb3af5c 100644 --- a/packages/cli/src/commands/environments/create.ts +++ b/packages/cli/src/commands/environments/create.ts @@ -2,7 +2,7 @@ import { Command, Flags } from '@oclif/core'; import { printError, emitJson, isExitSignal, errorCodeFields } from '../../utils/format.js'; -import { createApiClient, requireAuth } from '../../utils/api-client.js'; +import { createControlPlaneApiClient, requireControlPlaneAuth } 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'; @@ -18,12 +18,18 @@ import { recordCloudActiveEnvironmentId } from '../../utils/active-environment.j * lives in a repo this one never compiles against, so a class name here rots * with nothing to catch it. * + * Authenticates through `createControlPlaneApiClient` — `credentials.json`'s + * session where it targets the server, else `cloud.json`'s (the order is + * written once, there). + * * On success, optionally activates the new environment for the current session * and persists `activeEnvironmentId` into `~/.objectstack/credentials.json` - * (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. + * (unless `--no-activate` is passed, or the create ran on `cloud.json`'s + * session, whose server is not the one `credentials.json` names). 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 @@ -79,8 +85,8 @@ export default class EnvironmentsCreate extends Command { const { flags } = await this.parse(EnvironmentsCreate); try { - const { client, token, baseUrl } = await createApiClient({ url: flags.url, token: flags.token }); - requireAuth(token); + const { client, token, baseUrl, session } = await createControlPlaneApiClient({ url: flags.url, token: flags.token }); + requireControlPlaneAuth(token); // Resolve the artifact to an absolute path so the server can read it // regardless of its CWD. Bail early if the file is missing — better @@ -123,7 +129,9 @@ export default class EnvironmentsCreate extends Command { // a second copy of it. recordedForCloud = await recordCloudActiveEnvironmentId(res.environment.id, baseUrl); - const cfg = await readAuthConfig().catch(() => null); + // Runtime store — skipped on `cloud.json`'s session, for the reason + // `os environments switch` gives: that server is not credentials.json's. + const cfg = session === 'cloud' ? null : await readAuthConfig().catch(() => null); if (cfg) { cfg.activeEnvironmentId = res.environment.id; cfg.lastUsedAt = new Date().toISOString(); diff --git a/packages/cli/src/commands/environments/list.ts b/packages/cli/src/commands/environments/list.ts index 8b46e560cac..e4dee19e91a 100644 --- a/packages/cli/src/commands/environments/list.ts +++ b/packages/cli/src/commands/environments/list.ts @@ -2,7 +2,7 @@ import { Command, Flags } from '@oclif/core'; import { printError, emitJson, errorCodeFields } from '../../utils/format.js'; -import { createApiClient, requireAuth } from '../../utils/api-client.js'; +import { createControlPlaneApiClient, requireControlPlaneAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; /** @@ -38,12 +38,12 @@ export default class EnvironmentsList extends Command { const { flags } = await this.parse(EnvironmentsList); try { - const { client, token, environmentId: activeId } = await createApiClient({ + const { client, token, environmentId: activeId } = await createControlPlaneApiClient({ url: flags.url, token: flags.token, }); - requireAuth(token); + requireControlPlaneAuth(token); const res = await client.environments.list({ organization_id: flags.org, diff --git a/packages/cli/src/commands/environments/show.ts b/packages/cli/src/commands/environments/show.ts index 0f37f01d853..33ee6c2d9e8 100644 --- a/packages/cli/src/commands/environments/show.ts +++ b/packages/cli/src/commands/environments/show.ts @@ -2,7 +2,7 @@ import { Args, Command, Flags } from '@oclif/core'; import { printError, emitJson, errorCodeFields } from '../../utils/format.js'; -import { createApiClient, requireAuth } from '../../utils/api-client.js'; +import { createControlPlaneApiClient, requireControlPlaneAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; /** @@ -38,8 +38,8 @@ export default class EnvironmentsShow extends Command { const { args, flags } = await this.parse(EnvironmentsShow); try { - const { client, token } = await createApiClient({ url: flags.url, token: flags.token }); - requireAuth(token); + const { client, token } = await createControlPlaneApiClient({ url: flags.url, token: flags.token }); + requireControlPlaneAuth(token); const res = await client.environments.get(args.id); diff --git a/packages/cli/src/commands/environments/switch.ts b/packages/cli/src/commands/environments/switch.ts index 698877787ad..d1326d07d1f 100644 --- a/packages/cli/src/commands/environments/switch.ts +++ b/packages/cli/src/commands/environments/switch.ts @@ -2,18 +2,21 @@ import { Args, Command, Flags } from '@oclif/core'; import { printError } from '../../utils/format.js'; -import { createApiClient, requireAuth } from '../../utils/api-client.js'; +import { createControlPlaneApiClient, requireControlPlaneAuth } 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. * - * Calls `POST /api/v1/cloud/environments/:id/activate` to update the - * server-side session, then persists `activeEnvironmentId` into + * Authenticates through `createControlPlaneApiClient` — `credentials.json`'s + * session where it targets the server, else `cloud.json`'s (the order is + * written once, there). Calls `POST /api/v1/cloud/environments/:id/activate` + * to update the server-side session, then persists `activeEnvironmentId` into * `~/.objectstack/credentials.json` so subsequent CLI commands (and any * client they create via `createApiClient`) automatically target this - * environment. + * environment — unless the switch ran on `cloud.json`'s session, whose server + * is not the one `credentials.json` names. * * 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` @@ -48,8 +51,8 @@ export default class EnvironmentsSwitch extends Command { const { args, flags } = await this.parse(EnvironmentsSwitch); try { - const { client, token, baseUrl } = await createApiClient({ url: flags.url, token: flags.token }); - requireAuth(token); + const { client, token, baseUrl, session } = await createControlPlaneApiClient({ url: flags.url, token: flags.token }); + requireControlPlaneAuth(token); // Sanity-check the id resolves — fail fast before writing the cred file const lookup = await client.environments.get(args.id); @@ -68,10 +71,12 @@ export default class EnvironmentsSwitch extends Command { // `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); + // Runtime store: the copy `createApiClient` reads, so the `data` / `meta` + // families keep targeting the environment you just switched to. Skipped + // when this switch ran on `cloud.json`'s session: the server it talked to + // is cloud.json's, and the environment would not resolve on the server + // `credentials.json` names. + const cfg = session === 'cloud' ? null : await readAuthConfig().catch(() => null); if (cfg) { cfg.activeEnvironmentId = environment.id; cfg.lastUsedAt = new Date().toISOString(); diff --git a/packages/cli/src/utils/api-client.ts b/packages/cli/src/utils/api-client.ts index c3893d84b2f..16c45fb13d9 100644 --- a/packages/cli/src/utils/api-client.ts +++ b/packages/cli/src/utils/api-client.ts @@ -1,7 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectStackClient } from '@objectstack/client'; -import { readAuthConfig } from './auth-config.js'; +import { readAuthConfig, type AuthConfig } from './auth-config.js'; +import { DEFAULT_CLOUD_URL, tryReadCloudConfig, type CloudConfig } from './cloud-config.js'; +import { isSameControlPlane } from './active-environment.js'; + +/** Where `credentials.json`'s session points when it records no url. */ +const DEFAULT_RUNTIME_URL = 'http://localhost:3000'; + +/** + * The stored session a client's values were read from: `credentials.json` + * (`os login`) or `cloud.json` (`os cloud login`). + */ +export type StoredSession = 'credentials' | 'cloud'; /** * API client configuration options for CLI commands @@ -40,6 +51,29 @@ export interface ApiClientResult { * a second copy of that precedence is how the two drift apart. */ baseUrl: string; + /** + * The stored session {@link createControlPlaneApiClient} chose — the file + * whose server this client talks to. Unset when neither file was chosen, and + * always unset from {@link createApiClient}, which reads `credentials.json` + * only. A command that records an active environment back into a store reads + * it, so it never writes one server's environment id into the other + * server's file. + */ + session?: StoredSession; +} + +function buildClient( + values: { baseUrl: string; token?: string; environmentId?: string; session?: StoredSession }, + debug: boolean | undefined, +): ApiClientResult { + const { baseUrl, token, environmentId, session } = values; + const client = new ObjectStackClient({ + baseUrl, + token, + environmentId, + debug: debug || false, + }); + return { client, token, environmentId, baseUrl, ...(session ? { session } : {}) }; } /** @@ -81,17 +115,91 @@ export async function createApiClient(options: ApiClientOptions = {}): Promise { + const explicitUrl = options.url || process.env.OS_CLOUD_URL; + const runtime = await readAuthConfig().catch(() => undefined); + const cloud = await tryReadCloudConfig(); + const chosen = chooseControlPlaneSession(explicitUrl, runtime, cloud); + + return buildClient( + { + baseUrl: explicitUrl || chosen?.url || DEFAULT_RUNTIME_URL, + token: options.token || process.env.OS_TOKEN || chosen?.token, + environmentId: options.environmentId || process.env.OS_ENVIRONMENT_ID || chosen?.activeEnvironmentId, + session: chosen?.store, + }, + options.debug, + ); } /** @@ -105,3 +213,17 @@ export function requireAuth(token?: string): void { ); } } + +/** + * {@link requireAuth} for the control-plane commands, whose remedy names both + * logins: a client from {@link createControlPlaneApiClient} accepts either + * stored session, and the hosted one is the one most users need. + */ +export function requireControlPlaneAuth(token?: string): void { + if (!token) { + throw new Error( + 'Authentication required. Run `os cloud login` for ObjectStack Cloud or `os login` for a ' + + 'self-hosted control plane, or set OS_TOKEN.' + ); + } +} diff --git a/packages/cli/src/utils/cloud-config.ts b/packages/cli/src/utils/cloud-config.ts index 8c47145377b..b4bf3b78292 100644 --- a/packages/cli/src/utils/cloud-config.ts +++ b/packages/cli/src/utils/cloud-config.ts @@ -20,6 +20,13 @@ * Keeping them in separate files makes it unambiguous which token a * command is going to use, and makes it impossible to accidentally * publish a package with a runtime-scoped token. + * + * The control-plane commands (`os environments …`) are the one family that + * can run on either session, because a control plane is either the hosted + * cloud or a self-hosted server. Which one they use is decided in ONE place, + * `createControlPlaneApiClient` in `api-client.ts`: `credentials.json`'s + * session where it targets the server, else this file's — and for that + * family this file's token goes only to this file's `url`. */ import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; @@ -43,8 +50,9 @@ export interface CloudConfig { /** * Active environment id **on this control plane**, recorded by * `os environments switch` and read back by `os package publish` as the - * `--env` fallback. It sits here, beside this file's own `url`, because an - * environment id is only resolvable on the server it was chosen on — see + * `--env` fallback, and by `os environments` when it runs on this session. + * It sits here, beside this file's own `url`, because an environment id is + * only resolvable on the server it was chosen on — see * `active-environment.ts`, which owns that gate. */ activeEnvironmentId?: string; diff --git a/packages/cli/test/publish-active-environment-store.test.ts b/packages/cli/test/publish-active-environment-store.test.ts index 3654a623f21..499ec186a91 100644 --- a/packages/cli/test/publish-active-environment-store.test.ts +++ b/packages/cli/test/publish-active-environment-store.test.ts @@ -300,7 +300,8 @@ describe('#18265: the active environment publish installs into', () => { expect((await readCloud()).activeEnvironmentId).toBe('env_switched'); // The runtime store keeps its copy: `createApiClient` reads THAT one for - // the data / meta / environments families. + // the data / meta families, and `createControlPlaneApiClient` prefers it + // for the environments family whenever it names the server. const runtime = JSON.parse(await readFile(credentialsJson(), 'utf8')); expect(runtime.activeEnvironmentId).toBe('env_switched'); }); From 97c52b5ce7728decefb19aae303b328221c1beab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:56:20 +0000 Subject: [PATCH 3/4] test(cli): pin credentials.json winning when --url names the server both stores share Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../cli/src/commands/environments/cloud-session.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/src/commands/environments/cloud-session.test.ts b/packages/cli/src/commands/environments/cloud-session.test.ts index fa84fd9c74b..450935cad98 100644 --- a/packages/cli/src/commands/environments/cloud-session.test.ts +++ b/packages/cli/src/commands/environments/cloud-session.test.ts @@ -266,6 +266,15 @@ describe('os environments: the stored session it authenticates with', () => { expect(cloudPlane.seen[0].environmentId).toBe('env_from_credentials_json'); }); + it.each(SUBCOMMANDS)('%s: credentials.json wins where both name the --url server', async (name, run) => { + await writeCloud({ url: cloudPlane.url, token: 'cloud_tok' }); + await writeCredentials({ url: cloudPlane.url, token: 'runtime_tok' }); + + const outcome = await invoke(run, ['--url', cloudPlane.url]); + + expectServedBy(name, outcome, cloudPlane, runtimePlane, 'runtime_tok'); + }); + it.each(SUBCOMMANDS)('%s: with no --url, credentials.json still picks the server', async (name, run) => { await writeCloud({ url: cloudPlane.url, token: 'cloud_tok' }); await writeCredentials({ url: runtimePlane.url, token: 'runtime_tok' }); From c97a04430400ad4991dc8f530a52a182af2f1ffc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:58:23 +0000 Subject: [PATCH 4/4] fix(cli): declare the os environments session widening and correct the active-environment header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five subcommands now accept the cloud.json session they used to refuse, so the changeset declares Clause-② yes (widening) at minor. The active-environment.ts header no longer says os environments authenticates only as the runtime identity. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/21360-environments-cloud-session.md | 4 ++-- packages/cli/src/utils/active-environment.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/21360-environments-cloud-session.md b/.changeset/21360-environments-cloud-session.md index b6d4f817b83..c2980efb69d 100644 --- a/.changeset/21360-environments-cloud-session.md +++ b/.changeset/21360-environments-cloud-session.md @@ -1,10 +1,10 @@ --- -'@objectstack/cli': patch +'@objectstack/cli': minor --- `os environments list | show | create | bind | switch` run on the `os cloud login` session -Clause-②: no +Clause-②: yes (widening) The documented hosted flow is `os cloud login`, then `os environments create`. The five `os environments` subcommands read only `~/.objectstack/credentials.json` (the `os login` diff --git a/packages/cli/src/utils/active-environment.ts b/packages/cli/src/utils/active-environment.ts index a9b2e50c432..d9ecebad48a 100644 --- a/packages/cli/src/utils/active-environment.ts +++ b/packages/cli/src/utils/active-environment.ts @@ -26,10 +26,10 @@ * * ## 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 + * `os environments switch` keeps writing `credentials.json` when it ran on that + * file's session (it is the copy `createApiClient` reads for the `data` / `meta` + * families, and `os environments` runs on either stored session, chosen once in + * `createControlPlaneApiClient`). 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