Skip to content
14 changes: 14 additions & 0 deletions .changeset/18265-active-environment-in-cloud-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/cli": patch
---

`os package publish --install` installs into the environment `os environments switch` just selected, instead of refusing with ``--install` requires `--env <id>``. Nobody remembers a UUID.

The two credential stores are two **identities on two servers**, and the active environment used to live in only one of them. `os environments switch` — and `os environments create --activate` — wrote `activeEnvironmentId` into `~/.objectstack/credentials.json` only (the runtime identity, written by `os login`); `os package publish` reads `~/.objectstack/cloud.json` (the cloud identity, written by `os cloud login`) and never opened the other file — so the environment the CLI had just called active, and that `os environments list` marks with a ★, was invisible to the one command that could install into it.

- **The id now lives in `cloud.json`, beside `activeOrgId`** — the `CloudConfig` field that was already there for exactly this kind of control-plane scope selector, one level up.
- **`os environments switch` records it there as well** when the control plane it just talked to *is* `cloud.json`'s `url`, and keeps writing `credentials.json` unchanged — that copy is what `createApiClient` reads for the `data` / `meta` / `environments` families.
- **`os environments create --activate` records it too**, through the same helper — it is the *other* writer of an active environment id, and the first half of the flow this fixes: `os environments create --org $ORG --name Dev` then `os package publish --install`, with no `switch` in between. Creation succeeding while the record fails stays a warning, never an exit `1`.
- **`--install` with no `--env` and no `$OS_ENVIRONMENT_ID`** falls back to that value, and only when `cloud.json`'s `url` is the control plane being published to.
- **A value written by an older CLI is migrated once**, and only when both files' `url`s agree.
- ⛔ **Publish never reads `credentials.json` for this.** That is not a purity argument: the files carry *different servers* — `credentials.json`'s url falls back to `http://localhost:3000`, `cloud.json`'s default is `https://cloud.objectos.ai`, and the publish POSTs to the latter. An id taken from the runtime store can therefore name an environment on a **different control plane**, which the server resolves by bare id with no name or short-id rescue. The url gate, not the file name, is the invariant, and it lives in one place (`utils/active-environment.ts`).
20 changes: 16 additions & 4 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1795,8 +1795,8 @@ spells them, so one consumer reads both commands.
|---------|-------------|
| `os environments list` | List environments visible to the current session |
| `os environments show <id>` | Show one environment |
| `os environments create` | Provision a new environment |
| `os environments switch <id>` | Set the active environment for later CLI calls |
| `os environments create` | Provision a new environment. With `--activate` (the default) the new environment becomes the active one, recorded in `~/.objectstack/cloud.json` as well when the control plane it talked to is the one `os cloud login` recorded — so [`os package publish --install`](#os-package-publish) can install into it with no `switch` in between |
| `os environments switch <id>` | 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 <id>` | Bind a compiled local artifact to an existing environment |

#### Create an environment from a local artifact
Expand Down Expand Up @@ -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
```

Expand All @@ -1863,6 +1864,17 @@ OS_CLOUD_URL=http://localhost:4000 os package publish # against a local cont
[`os login`](#os-login) writes, and the two are different accounts. With no
token at all the command exits `1` and tells you to run `os cloud login`.

**The install target follows the same split.** `--install` with no `--env` uses
the environment `os environments switch` — or `os environments create
--activate` — recorded **in `cloud.json`**, and that
id is only used when `cloud.json`'s `url` is the control plane this publish is
POSTing to. It is never read out of `credentials.json`: the two files name
different servers (`credentials.json` defaults to `http://localhost:3000`), so
an id taken from there can belong to a different control plane — and the server
resolves an install target by bare id, with no name or short-id rescue. A value
written by an older CLI into `credentials.json` is copied across once, and only
when both files' `url`s agree.

**Options:**

| Flag | Env equivalent | Purpose |
Expand All @@ -1877,8 +1889,8 @@ token at all the command exits `1` and tells you to run `os cloud login`.
| `--category <slug>` | — | Marketplace category slug (`crm`, `hr`, `devtools`, …) |
| `--visibility <level>` | — | `org` (default, installable across your organization) · `private` (explicit grants only) · `marketplace` (public after review) |
| `--org <id>` | `OS_ORG_ID` | `owner_org_id`. Required with a bearer key in service mode; ignored in user mode |
| `--env <id>` | `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 <id>` | `OS_ENVIRONMENT_ID` | Environment to install the new version into. Defaults to the environment [`os environments switch`](#cloud-environments) — or `os environments create --activate` — recorded **for this control plane** in `~/.objectstack/cloud.json` |
| `--install` | — | Auto-install into `--env` after publishing. With no `--env`, no `$OS_ENVIRONMENT_ID` and no active environment for this control plane, it reports that and publishes without installing |
| `--seed-sample-data` | — | Include sample data in that auto-install |
| `--pre-release` | — | Mark the version as a pre-release (also inferred — see below) |
| `--submit` | — | Submit the new version for marketplace review. Needs `--visibility marketplace` and a complete listing |
Expand Down
12 changes: 11 additions & 1 deletion content/docs/deployment/publish-and-preview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ os package publish

# explicit artifact + install into an environment in one step
os package publish ./dist/objectstack.json --env env_prod --install

# no --env: installs into the active environment (`os environments switch`,
# or `os environments create --activate`)
os package publish --install
```

The CLI:
Expand All @@ -154,9 +158,15 @@ Common flags:
| `--version`, `-v` | — | Semver version (default: `artifact.manifest.version`) |
| `--visibility` | — | `org` (default) · `private` · `marketplace` |
| `--org` | `OS_ORG_ID` | Owner org id (service mode) |
| `--env` | `OS_ENVIRONMENT_ID` | Environment to install the new version into |
| `--env` | `OS_ENVIRONMENT_ID` | Environment to install the new version into. Defaults to the environment `os environments switch` (or `os environments create --activate`) recorded for this control plane in `~/.objectstack/cloud.json` |
| `--install` | — | Auto-install the new version into `--env` after publishing |

The `--env` default is read from the **cloud** credential store only, and only
when that file's `url` is the control plane being published to. It is never
taken from `~/.objectstack/credentials.json`: that file records the runtime
identity's server (`http://localhost:3000` by default), so an environment id
from there can name an environment on a different control plane.

In user mode the package is owned by your active organization; in service mode
(bearer key) pass `--org`. See [Packages](/docs/plugins/packages) for the package model.

Expand Down
27 changes: 25 additions & 2 deletions packages/cli/src/commands/environments/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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('');
}
Expand Down
35 changes: 30 additions & 5 deletions packages/cli/src/commands/environments/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` — set the active environment for this CLI session.
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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)');
}
Expand Down
44 changes: 38 additions & 6 deletions packages/cli/src/commands/package/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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',
];
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -658,12 +664,38 @@ export default class PackagePublish extends Command {
if (flags.submit) verBody.submit_for_review = true;
if (flags['auto-approve']) verBody.auto_approve = true;

const shouldInstall = flags.install && flags.env;
// Install target precedence: `--env` (which oclif also fills from
// $OS_ENVIRONMENT_ID), then the environment `os environments switch`
// recorded in `cloud.json` FOR THIS CONTROL PLANE.
//
// ⛔ Never `credentials.json`. That file's `activeEnvironmentId` belongs
// to whatever server the runtime identity points at — `localhost:3000`
// by default — so it can name an environment on a different control
// plane than the one this publish is POSTing to. The url gate lives in
// `utils/active-environment.ts`; this line must not grow a second one.
let installEnvId = flags.env;
let installEnvFromActive = false;
if (flags.install && !installEnvId) {
installEnvId = await resolveCloudActiveEnvironmentId(baseUrl);
installEnvFromActive = Boolean(installEnvId);
}

const shouldInstall = flags.install && installEnvId;
if (shouldInstall) {
verBody.install_env_id = flags.env;
verBody.install_env_id = installEnvId;
verBody.seed_sample_data = flags['seed-sample-data'];
} else if (flags.install && !flags.env) {
printError('`--install` requires `--env <id>`. Skipping auto-install.');
if (installEnvFromActive) {
printStep(
`Installing into the active environment ${installEnvId} `
+ '(os environments switch / os environments create --activate)',
);
}
} else if (flags.install && !installEnvId) {
printError(
'`--install` requires `--env <id>`, $OS_ENVIRONMENT_ID, or an active environment '
+ '(`os environments switch <id>` or `os environments create --activate` against this '
+ 'control plane). Skipping auto-install.',
);
}

const verRes = await this.postJson(
Expand Down
Loading
Loading