fix(cli): os package publish --install can use the environment you just switched to - #18268
Conversation
…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 <id>`" 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 <noreply@anthropic.com>
… store 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 <noreply@anthropic.com>
…ere it does not Claude-Session: https://claude.ai/code/session_82286b62-3514-46f6-8d53-ca6fbb6df3c6 Co-authored-by: Claude <noreply@anthropic.com>
…cloud credential store Claude-Session: https://claude.ai/code/session_82286b62-3514-46f6-8d53-ca6fbb6df3c6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1ea8f4a2e585b9e36f4fb5b03d74661e2eaa2f07 && git checkout 1ea8f4a2e585b9e36f4fb5b03d74661e2eaa2f07
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c053f748b68c6b877b0462d491b47dff27915692 a841e177ff6b9ed502e35ab223a64dbf4a588d91 && git checkout -B drift-repro c053f748b68c6b877b0462d491b47dff27915692 && git merge --no-ff a841e177ff6b9ed502e35ab223a64dbf4a588d91
node scripts/docs-audit/affected-docs.mjs --json c053f748b68c6b877b0462d491b47dff27915692
|
…ironment 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 <id>`". 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.
…sh 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.
…ges 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.
End-to-end verification — real binaries, real HTTP, before/afterThe unit tests and ablations pin the logic; nothing so far had run the actual command. This does: two real Artifact under test: The headline:
|
origin/main |
this branch | |
|---|---|---|
os environments switch <id> |
cloud.json → <ABSENT>, credentials.json → id |
both written, and it says so: (also recorded in cloud.json — os package publish --install will use it) |
os package publish --install |
✗ `--install` requires `--env <id>`. Skipping auto-install. |
→ Installing into the active environment env-…0001 (os environments switch / os environments create --activate) |
| on the wire | install_env_id = <ABSENT> |
install_env_id = 'env-…0001' |
create --activate → publish --install, the folded-in half
origin/main |
this branch | |
|---|---|---|
cloud.json after create |
<ABSENT> |
env-…0001 |
| publish | refuses | installs |
| on the wire | <ABSENT> |
'env-…0001' |
The two guards, measured on the wire
Cross-plane refusal — cloud.json has no id (url :4711), credentials.json has one under a different url (:9999):
✗ `--install` requires `--env <id>`, $OS_ENVIRONMENT_ID, or an active environment
(`os environments switch <id>` or `os environments create --activate` against this control plane).
on the wire: install_env_id = <ABSENT>
cloud.json after: unchanged — no migration happened
One-time migration — same fixture but credentials.json carries the same url:
→ Installing into the active environment env-…0001
on the wire: install_env_id = 'env-…0001'
cloud.json after: activeEnvironmentId = env-…0001 ← written by the CLI, not by the fixture
That pair is the card's whole argument, executed rather than asserted: the id crosses only when both files name the plane being published to.
What this does NOT cover
- The stub is not the real control plane. It answers the two publish routes and the environment routes in the
ok()envelope shape; it does not exerciseinstallPackageIntoEnvironment, thesys_package_installationUPSERT, or kernel eviction. Those are unchanged by this PR —install_env_idis a pre-existing server contract, and the same two routes were exercised against the real production control plane earlier today by an unrelated publish. - No run against
cloud.objectos.aiwith a real account. This machine has no~/.objectstack/cloud.json. - No browser evidence for Console → Marketplace → Install.
Rig teardown: stub killed, the throwaway origin/main worktree removed, a parallel session's stack left alone.
Re-done on a real rig — the earlier comment used a stub; this one does notThe previous comment's stub was a workaround for a broken local rig. The rig is fixed (the cause is written up at the end), so this is the same matrix against a real two-process stack: Two real binaries as before: one built from
|
| step | origin/main |
this branch |
|---|---|---|
environments switch |
exit 1, ✗ No stored credentials found. Please run os login first. |
✓ Active environment + (also recorded in cloud.json — os package publish --install will use it) |
cloud.json |
activeEnvironmentId <ABSENT> |
the env id |
publish --install |
✗ `--install` requires `--env <id>`. |
→ Installing into the active environment … |
sys_package_installation in the control DB |
no row | pkgi_2e2221b9-…, enabled=1 |
The main failure is worse than the card described, and the rig is what showed it. readAuthConfig() throws when credentials.json is absent, and it runs after the server-side activate has already succeeded — so a user who ran only os cloud login gets a server-side switch that did happen, a client-side exit 1, and an error telling them to run a different login. This branch's .catch(() => null) is what makes that path survivable; it was flagged in the round-1 report and is now reproduced on a live control plane.
os environments create --activate → publish --install → does the app actually run?
Against a freshly created environment 08e35e94-… (created through the CLI, so this exercises the second writer too):
✓ Environment created: 08e35e94-…
active environment set to 08e35e94-…
(also recorded in cloud.json — `os package publish --install` will use it)
→ Installing into the active environment 08e35e94-… (os environments switch / os environments create --activate)
✓ Installed into environment
Tenant runtime log for that environment:
Plugin registered: plugin.app.com.example.todo@4.0.0
and zero kernel build failed / kernel_build_failing / Invalid semantic lines for 08e35e94. The app is loaded and the kernel is healthy — the half a stub cannot answer.
Authentication was NOT changed — the out-of-scope boundary held
Worth stating because the main run's failure looks like an auth difference. git diff origin/main...a841e177ff -- packages/cli/src/utils/api-client.ts adds exactly one thing: baseUrl on ApiClientResult. Token resolution is untouched, and switch still calls requireAuth(token) identically. The data / meta / environments families send the same token they always did.
Two defects this run surfaced, unrelated to this PR
Filed separately so they do not ride on this branch: a non-semver --version is accepted by the publish route and wedges the whole tenant environment into 503 (cloud#2305), and installing a corrected version does not recover it — the runtime keeps rebuilding a version the installation row no longer points at (cloud#2306). Both have timestamped control-DB evidence on their cards.
Still not covered
No run against cloud.objectos.ai with a real account (this machine has no ~/.objectstack/cloud.json), and no browser evidence for Console → Marketplace → Install.
The rig repair, for whoever hits it next
.claude/worktrees/objectstack in the cloud repo is a symlink that every cloud worktree's link:../../../objectstack/... dependency resolves through. It pointed at objectstack-cloudpin-eaba72e, a pinned worktree that had been deleted — so every @objectstack/* symlink under packages/*/node_modules/ was dangling and the stack died with Cannot find package '@objectstack/objectql' imported from …/service-cloud/dist/chunk-*.js, which reads like a stale-dist problem and is not one. Repointed at a fresh worktree of the declared pin (.objectstack-sha = 6ff5b562cd), then pnpm install + build in it, then pnpm install in the cloud worktree. Two further traps: turbo run build fails in @objectstack/service-cloud's dts step (Cannot find module 'zod') unless OS_SKIP_DTS=1, and the first rebuild after the repair reported FULL TURBO — replaying the cache built while the links were still broken, so it needs --force.
Closes #18265
Clause-②: no — this diff adds an optional field to a local CLI credential file (
CloudConfiginpackages/cli/src/utils/cloud-config.ts) and a flag fallback. No new key reaches a published payload, and no contract accepts or refuses anything it did not before.What was wrong
os environments switch— andos environments create --activate, the other writer — persistedactiveEnvironmentIdinto~/.objectstack/credentials.jsononly, the runtime credential store.os package publishreads~/.objectstack/cloud.json— the cloud store — and never opened the other file. So the environment the CLI had just called active, and thatos environments listmarks with a ★ on the very next line, was invisible to the one command that installs into it:Why the obvious fix is the wrong one
Letting publish read
credentials.jsonis not merely impure — it is wrong at runtime. The two files carry different servers:credentials.json's url falls back tohttp://localhost:3000(utils/api-client.ts),cloud.json's default ishttps://cloud.objectos.ai(utils/cloud-config.ts), and the publish POSTs to the latter. AnactiveEnvironmentIdread out of the runtime store therefore names an environment on a possibly different control plane, which the server resolves by bare id with no name or short-id rescue.So the id now lives in
cloud.json, next to theactiveOrgIdthat is already there for the same kind of control-plane scope selector, and the url gate — not the file name — is the invariant. It lives in exactly one place,packages/cli/src/utils/active-environment.ts:os environments switchrecords the id incloud.jsonwhen the control plane it just talked to iscloud.json'surl, and keeps writingcredentials.jsonunchanged.os environments create --activate(the default) records it through the same helper. It is the other writer of an active environment id and the first half of the flow this PR exists for, so fixing onlyswitchwould have left the most natural path refusing while reading like a fix. It is one call, not a second implementation: two copies of a url gate is how one of them stops gating. Its existing failure posture is untouched — creation succeeding while the activation or the record fails stays a warning, never an exit1.os package publish's--envfalls back to that value when neither the flag nor$OS_ENVIRONMENT_IDis present, and only whencloud.json'surlis the plane being published to.credentials.jsonis migrated across once, and only when both files'urls agree.Guards — three legs, each measured red-before / green-after
A guard that only pinned "a fallback exists" would go green on the wrong fix, which is the whole reason this card is not the one-liner it looks like. So the read side has two legs and the second is the real invariant; the write side has a third, for the second writer. Every leg restores from
HEAD(the implementation was committed first) and proves the mutation reached disk before the run is read.Leg A — remove the fallback
Mutation: delete the
resolveCloudActiveEnvironmentIdcall inpackages/cli/src/commands/package/publish.ts.Leg B — point the fallback at
credentials.json(the tempting wrong fix)Mutation: in
packages/cli/src/utils/active-environment.ts, readreadAuthConfig()'sactiveEnvironmentIdahead ofcloud.json's.The asymmetry is the finding. Under leg B the first guard — "installs into the active cloud environment" — stays green: only the second case, which seeds both stores with different ids under the same url, can tell a correct fallback from the runtime-store one.
expected 'env_from_credentials_json' to be 'env_from_cloud_json'is the line that refuses the wrong fix, andexpected 'env_on_other_plane' to be undefinedis the cross-plane leak the card describes, caught in the same leg.Leg C —
os environments create --activatestops recording for the cloud planeMutation: in
packages/cli/src/commands/environments/create.ts, replace therecordCloudActiveEnvironmentIdcall withrecordedForCloud = false— the runtime write stays, exactly ascreatebehaved before this PR.Leg C reds exactly one case, and that is the reading. It is the only leg the new
create --activatecase can distinguish, and legs A and B were re-measured after it was folded in rather than assumed: A now reds 4 cases (the new one included — it asserts the publish end to end) and B still reds the same 3 as before, with the new case staying green under B because both stores hold the same id there. So the "same url, different ids" case remains the only thing in the file that refuses thecredentials.jsonfix; adding a second writer did not quietly make some other assertion carry that weight.Gates
pnpm --filter @objectstack/cli testorigin/maincontrol checkout; none is a file this diff touches. Table belowpnpm --filter @objectstack/cli buildpnpm --filter @objectstack/cli typechecktsc --noEmit+check:test-typecheck: "@objectstack/cli's test layer compiles under packages/cli/tsconfig.test.json"pnpm check:cli-command-idspnpm check:cli-examples-parityos package publishinvocation(s) … == 6 in the block at content/docs/deployment/cli.mdx:1850"pnpm check:empty-changesetpnpm linteslint . --no-inline-configpnpm check:doc-anchorspnpm check:doc-authoringAlso run, from
dispatch-gates.mjs's derived family for these paths — every one exit 0:check:nul-bytes·check:cross-package-test-inputs·check:test-source-alias·check:tier-file-adoption·check:type-check-coverage·check:type-check-debt·check:published-files·check:cli-test-child-env·check:docs-single-h1·check-doc-frontmatter.mjs·check-closing-keyword-parity.mjs·check-docs-section-name.mjs·check-undeclared-dep-imports.mjs·docs-audit/check-affected-docs.mjsThe full
@objectstack/clisuite is red on this machine, and it is red without this changepnpm --filter @objectstack/cli testran both tiers on the final head: 11 failed · 3384 passed over 256 files. Not one failure is in a file this diff touches, and each was controlled against an unmodified checkout rather than argued away.The comparison that matters is the failing set, not the count — and after the second writer was folded in, that set shrank:
src/commands/datasource/envelope-unwrap.test.tsand 4 of the 5published-entry-node-env-source-reroutecases (all load-dependent timeouts) passed this time, and 11 previously skipped cases ran and passed. No file entered the set. The arithmetic closes exactly: 3367 + 11 recovered skips + 4 recovered timeouts + 2 new cases = 3384.A detached worktree at
origin/main(fd1247142a, installed and built the same way) reproduces them. The counts below are the earlier, larger run — the superset; the final run is those same files minusenvelope-unwrap, with 4 of the 5 reroute cases recovered:origin/maintest/published-subpath-console.pin.test.ts(2)expected '/private/var/folders/…' to be '/var/folders/…'— macOS symlinks/var; this is a realpath artefact of the box, not of the treetest/published-subpath-hook-body.pin.test.ts(3)test/serve-runtime-state-project-key.test.ts(5)no child processes were driven at all: expected 2 to be 3test/published-entry-node-env-source-reroute.test.ts(5)Test timed out in 5000msunder full-suite load)src/commands/datasource/envelope-unwrap.test.ts(1)origin/mainand on this branch (11 passed)Hook timed out in 10000msinConfig.load— contention during the 256-file run, not a failureThe targeted run of the file this card adds is green on its own:
Test Files 1 passed · Tests 9 passed (9).Declared deviation — the shared verify lock was never taken.
scripts/pm/os-verify-lock.shprintedUNLOCKED (declared) · no usableflockon this host, so the shared verify lock was NEVER taken and NOTHING was serializedon every run above that went through it (build, typecheck, test). Nothing was serialized against parallel seats on this container.Declared narrowing. This is the targeted local set. The repo-wide gate farm is CI's run:
node scripts/pm/dispatch-gates.mjs --commandsderives 94 commands for this change set, plus 48 artifact-roster families, 11 wide-population families and 6 path-scheduled CI jobs it explicitly marks NOT MEASURED locally.Acceptance notes
origin/mainbefore anything was written, and every line held.cloud-config.ts:42(activeOrgId),cloud-config.ts:30(DEFAULT_CLOUD_URL),cloud-config.ts:50(the path),auth-config.ts:40/:47,switch.ts:58,api-client.ts:68/:78,publish.ts:501andpublish.ts:661are all exactly where and what the card says.os environments switchwrites tocloud.json… otherwise it keeps writingcredentials.json" could be read as exclusive-or. It is implemented as additive: the runtime store is written exactly as before, andcloud.jsonis written as well when the planes agree. An exclusive-or reading would silently stopcreateApiClientfrom finding an active environment for thedata/meta/environmentsfamilies — the very families the card's Out-of-scope section says keep authenticating as the runtime identity.cloud.json'surlbeing the control plane being POSTed to, not only on the value being present. Without it,OS_CLOUD_URL=http://localhost:4000 os package publish --installwould send an id recorded for cloud.objectos.ai to a different plane — the exact failure the card rejects thecredentials.jsonfix for. It costs nothing in the card's own scenario (login, switch and publish all on one plane) and is measured by the third test case.os environments switch's write tocredentials.jsonbecame best-effort. It was an unguardedreadAuthConfig(), which throws when the file does not exist — so a user who only ranos cloud logingot an exit 1 after the server-side activation had already succeeded. The cloud write now happens first, the runtime write matchesenvironments/create.ts's existing.catch(() => null)shape, and the command says so when neither store could record it.os environmentsfamily should move to the cloud identity. It authenticates fromcredentials.json/$OS_TOKENtoday and changing that changes which token existing users send — the card puts it out of scope and it stays out.os environments create --activate(environments/create.ts) as the other writer ofactiveEnvironmentIdand held it back because the card namesswitch. It is in now: on the day this ships,os environments create --org $ORG --name Dev --activatefollowed byos package publish --installis still the most natural path to the card's own scenario, and a half-fix that reads as a fix is worse than an obvious gap. The remedy is one call to the helper that already exists here, it adds no new verification surface, and leg C measures it.package publishnamed onlyos environments switchas the source of the install target — theInstalling into the active environment …step line and the--installrefusal. With two writers they were half-true, so both name both writers now, as do the--envrows and the install-target prose oncli.mdxandpublish-and-preview.mdx.Generated by Claude Code