Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions .changeset/adr0006-d2-client-environments-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
---
"@objectstack/client": minor
"@objectstack/cli": minor
---

feat(client,cli)!: `client.projects.*` becomes `client.environments.*`, the scoped sub-client becomes `ScopedEnvironmentClient`, and the unwrap keys follow the wire (#12866, #12882, ADR-0006 D2)

<!-- adr-0087: not-required (runtime-interface-only packages/client/src/index.ts#ObjectStackClient) The renamed surface is a member of a published runtime TypeScript class and the response-key shapes it declares inline. There is no Zod schema, no `packages/spec` declaration, no authorable key and no stored representation behind either half — measured 2026-08-28: zero `projects` envelope contracts anywhere in `packages/spec/src`, positive control being that `environments` hits do exist there. So `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The channel that reaches every affected consumer is the COMPILER, at the call site, which is strictly more precise than a ledger line; the wire half is carried by the paired control-plane release in the same coordinated window. MEASURED CAVEAT, recorded here rather than worked around: this claim is REFUSED by check-adr-0087-registration at step 4, because `packages/spec/src/api/contract.zod.ts` names `ObjectStackClient` in a JSDoc PROSE comment (line 164, describing what `unwrapResponse` keys on) while neither declaring nor importing it, and the predicate does not strip comments before scanning a metadata surface for references. Steps 1-3 pass. The disposition is left stated rather than swapped for `no-migration-prescription`, which would mechanically pass only through a detector blind spot while contradicting the migration table below it — the exact anti-pattern this gate's own header records as #8299. -->


**BREAKING** public-API rename on `@objectstack/client`, and a breaking change to
the `--format json` payload of the `os environments` command family. It lands
after the v17.0.0 cut, so the lockstep launch-window convention ships it as
`minor` (`scripts/check-changeset-no-major.mjs`); the version number is not the
migration signal here, this entry is.

This is the **SDK half** of one coordinated cross-repo rename. The **producer
half** is the cloud control plane, which renames the same field keys on the same
endpoints. Neither half ships alone: shipping the SDK half by itself is
ADR-0006 D3, permanently declined, as is any mapping layer between the two
spellings.

## Migration

**No aliases exist.** The old namespace is gone, not deprecated — there is no
`client.projects` getter, no `res.project ?? res.environment` hedge, and none is
coming (ADR-0006 D3 declined a mapping layer with reasons; the v5.0 rename rule
「no aliases」 is the standing one). Every call site moves in one edit.

### Method namespace

| before | after |
| --- | --- |
| `client.projects.list(…)` | `client.environments.list(…)` |
| `client.projects.get(id)` | `client.environments.get(id)` |
| `client.projects.create(req)` | `client.environments.create(req)` |
| `client.projects.update(id, patch)` | `client.environments.update(id, patch)` |
| `client.projects.delete(id, opts)` | `client.environments.delete(id, opts)` |
| `client.projects.activate(id)` | `client.environments.activate(id)` |
| `client.projects.rotateCredential(…)` | `client.environments.rotateCredential(…)` |
| `client.projects.updateHostname(…)` | `client.environments.updateHostname(…)` |
| `client.projects.updateVisibility(…)` | `client.environments.updateVisibility(…)` |
| `client.projects.listRevisions(…)` | `client.environments.listRevisions(…)` |
| `client.projects.listBranches(id)` | `client.environments.listBranches(id)` |
| `client.projects.renameBranch(…)` | `client.environments.renameBranch(…)` |
| `client.projects.deleteBranch(…)` | `client.environments.deleteBranch(…)` |
| `client.projects.retryProvisioning(id)` | `client.environments.retryProvisioning(id)` |
| `client.projects.listDrivers()` | `client.environments.listDrivers()` |
| `client.projects.packages.*` | `client.environments.packages.*` |

The URL paths are unchanged — they were already on the `environments` spelling
(`/api/v1/cloud/environments/…`). Only the method namespace and the response
field keys move.

### Response keys

| before | after | where |
| --- | --- | --- |
| `res.projects` | `res.environments` | `list` (the `total` key is unchanged) |
| `res.project` | `res.environment` | `get`, `update`, `activate`, `updateHostname`, `updateVisibility`, `retryProvisioning` |

The joined blocks on `get` (`database`, `credential`, `membership`,
`organization`) keep their names, as do every `packages.*` key, the
`delete`/`listBranches`/`renameBranch`/`deleteBranch` payloads (already
`environmentId`-keyed), and `listRevisions`.

### Two declarations that were false before this change

Measured 2026-08-28 against the cloud repo's `main`, and corrected here rather
than carried forward under a new spelling:

- **`create` never answered a `project` key at all.** `POST /api/v1/cloud/environments`
has always answered `{ environment, warnings, durationMs, hostnameAssignment? }`.
The old `{ project: any; database: any }` declaration was not merely
pre-rename, it was wrong against the running control plane — and
`os environments create` read `res.project.id` through it, so the default
`--activate` silently never activated and the table output printed
`undefined`. Both are fixed by this rename.
- **`create` declares no `database` key.** That route does not send one; the key
was declared NON-optional, so `res.database.driver` typechecked and threw. The
method that really answers a `database` block is `get`, which keeps it.

The keys `create` does send beside `environment` (`warnings`, `durationMs`,
`hostnameAssignment`) are deliberately still undeclared — adding them is new
published surface and a separate decision.

### The environment-scoped sub-client (#12882)

The fourth `project`-spelled surface on the same class, folded in by the same
maintainer ruling. ADR-0006's D1 census named three surfaces and missed this one;
it was an oversight, not a deliberate retention.

| before | after |
| --- | --- |
| `client.project(id)` | `client.environment(id)` |
| `ScopedProjectClient` (exported class) | `ScopedEnvironmentClient` |

Same no-alias rule: neither old spelling survives. `client.project(id)` is not a
deprecated method, it is gone, and the exported class is gone under its old name
— a `import { ScopedProjectClient }` fails at the import line, which is the
loudest and most precise channel this change has.

Nothing about the behaviour moves: the scoped client still prefixes
`/api/v1/environments/:environmentId/...`, still exposes the same `data` / `meta`
/ `batch` / `packages` shape, and the thrown guard message becomes
`[ObjectStack] environment(id): environmentId is required`.

**Deliberately NOT renamed, because each is a different surface needing its own
decision:** `setProjectId` / `getProjectId` on the client — `getProjectId` is a
cross-package protocol contract that `packages/runtime` and
`packages/metadata-protocol` both speak, so it is a coordinated rename, not a
local one — and the REST API config keys `enableProjectScoping` /
`projectResolution`, which are live keys read by `packages/cli/src/commands/serve.ts`.
The docblocks that name them are worded so they stay true.

Note for whoever compiles the release notes: four other pending changesets in
this release describe methods on `ScopedProjectClient` under its old name
(`client-unannotated-return-erasure`, `client-saveitem-ifmatch-header`,
`client-meta-saveitem-query-options`, `client-precise-sdk-return-types`). They
were accurate when written and are deliberately left alone; this entry is the one
that renames the class.

### JSDoc

The `create` docblock claimed the server delegates to
`ProjectProvisioningService.provisionProject`. That spelling has zero hits in the
control plane (measured 2026-08-28). Both this SDK's docblock and
`os environments create`'s now name the **endpoint** instead, which is the one
identifier an in-repo reader can verify — the class lives in a repo this one
never compiles against, so no gate here could ever have caught the rot.

## CLI

`os environments list | show | create | switch | bind` follow the same rename.
No flag, argument, exit code or command id changes. `--format json` / `--format yaml`
payloads are `formatOutput(res, …)` straight from the control-plane response, so
their top-level keys change with the wire: a script reading `.projects` or
`.project` from those payloads reads `.environments` / `.environment` instead.
7 changes: 4 additions & 3 deletions content/docs/api/environment-routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const client = new ObjectStackClient({
baseUrl: 'https://api.example.com',
});

const env = client.project('env_prod');
const env = client.environment('env_prod');

await env.data.find('customer', { top: 20 });
await env.meta.getItems('object');
Expand Down Expand Up @@ -153,8 +153,9 @@ one, so they are intentionally excluded from data-plane resolution.
2. Replace `X-Project-Id` with `X-Environment-Id`.
3. Replace `OS_PROJECT_ID` with `OS_ENVIRONMENT_ID`.
4. Store runtime rows under `environment_id`.
5. Keep the SDK `client.project(id)` call only as a compatibility method name;
pass an environment id to it.
5. Replace the SDK `client.project(id)` call with `client.environment(id)`. It
was a compatibility method name until ADR-0006 D2; there is no alias, so the
old spelling does not resolve.

---

Expand Down
7 changes: 5 additions & 2 deletions content/docs/concepts/north-star.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,11 @@ environments and are not themselves scoped data-plane requests.

- Some docs and source comments still use historical `project` wording where
current runtime identity is `environment`.
- The SDK method name `client.project(id)` remains as a compatibility surface,
but it accepts an environment id and generates environment-scoped URLs.
- The SDK client still exposes `setProjectId` / `getProjectId`, and the REST API
config still spells its keys `enableProjectScoping` / `projectResolution`.
`getProjectId` is the harder one: it is a cross-package protocol contract that
`packages/runtime` and `packages/metadata-protocol` both speak, so it is a
coordinated rename rather than a local one.
- The notification framework pipeline is ahead of the objectui bell cut-over;
read-state should move through `sys_notification_receipt`.
- Some generated references need regeneration after Zod description updates.
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/environments/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ export default class EnvironmentsBind extends Command {
requireAuth(token);

// Fetch existing metadata so we don't blow it away.
const current = await client.projects.get(args.environmentId);
const existingMeta: Record<string, unknown> = (current?.project?.metadata && typeof current.project.metadata === 'object')
? { ...current.project.metadata as Record<string, unknown> }
const current = await client.environments.get(args.environmentId);
const existingMeta: Record<string, unknown> = (current?.environment?.metadata && typeof current.environment.metadata === 'object')
? { ...current.environment.metadata as Record<string, unknown> }
: {};
// Drop the prior bind error so the UI doesn't show a stale failure.
delete existingMeta.artifactBindError;
Expand All @@ -114,7 +114,7 @@ export default class EnvironmentsBind extends Command {
printKV('Environment', args.environmentId, '🎯');
printKV('Artifact', artifactAbs, '📦');

const res = await client.projects.update(args.environmentId, {
const res = await client.environments.update(args.environmentId, {
metadata: existingMeta,
});

Expand Down
19 changes: 13 additions & 6 deletions packages/cli/src/commands/environments/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js';
/**
* `os environments create` — provision a new environment.
*
* Delegates to `ProjectProvisioningService.provisionProject` on the server.
* Calls `client.environments.create`, i.e. `POST /api/v1/cloud/environments` on
* the control plane. This docblock names the ENDPOINT rather than a server
* class on purpose: it used to name `ProjectProvisioningService.provisionProject`,
* which the control plane does not have (measured 2026-08-28 — zero hits for
* that spelling in the cloud repo's `packages/service-cloud/src`). The server
* lives in a repo this one never compiles against, so a class name here rots
* with nothing to catch it.
*
* On success, optionally activates the new environment for the current session
* and persists `activeEnvironmentId` into `~/.objectstack/credentials.json`
* (unless `--no-activate` is passed).
Expand Down Expand Up @@ -81,7 +88,7 @@ export default class EnvironmentsCreate extends Command {
metadata = { artifact_path: abs };
}

const res = await client.projects.create({
const res = await client.environments.create({
organization_id: flags.org,
display_name: flags.name,
plan: flags.plan,
Expand All @@ -90,12 +97,12 @@ export default class EnvironmentsCreate extends Command {
...(metadata ? { metadata } : {}),
});

if (flags.activate && res?.project?.id) {
if (flags.activate && res?.environment?.id) {
try {
await client.projects.activate(res.project.id);
await client.environments.activate(res.environment.id);
const cfg = await readAuthConfig().catch(() => null);
if (cfg) {
cfg.activeEnvironmentId = res.project.id;
cfg.activeEnvironmentId = res.environment.id;
cfg.lastUsedAt = new Date().toISOString();
await writeAuthConfig(cfg);
}
Expand All @@ -110,7 +117,7 @@ export default class EnvironmentsCreate extends Command {
} else if (flags.format === 'yaml') {
await formatOutput(res, 'yaml');
} else {
const p = res?.project ?? {};
const p = res?.environment ?? {};
console.log(`\n✓ Environment created: ${p.display_name ?? p.id} (${p.id})`);
if (flags.activate) {
console.log(` active environment set to ${p.id}`);
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/commands/environments/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,23 +45,23 @@ export default class EnvironmentsList extends Command {

requireAuth(token);

const res = await client.projects.list({
const res = await client.environments.list({
organization_id: flags.org,
status: flags.status,
});

const projects = res?.projects ?? [];
const environments = res?.environments ?? [];

if (flags.format === 'json') {
await formatOutput(res, 'json');
} else if (flags.format === 'yaml') {
await formatOutput(res, 'yaml');
} else {
console.log(`\nEnvironments (${projects.length}):\n`);
if (projects.length === 0) {
console.log(`\nEnvironments (${environments.length}):\n`);
if (environments.length === 0) {
console.log(' (no environments)');
} else {
for (const p of projects) {
for (const p of environments) {
const active = p.id === activeId ? ' ★' : '';
const defaultTag = p.is_default ? ' [default]' : '';
const systemTag = p.is_system ? ' [system]' : '';
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/environments/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { formatOutput } from '../../utils/output-formatter.js';
* `os environments show <id>` — show detailed information for a single environment.
*
* Renders the environment row plus its database, active credential, and
* membership row (same shape as `client.projects.get(id)`).
* membership row (same shape as `client.environments.get(id)`).
*/
export default class EnvironmentsShow extends Command {
static override description = 'Show detailed information for an environment';
Expand Down Expand Up @@ -41,14 +41,14 @@ export default class EnvironmentsShow extends Command {
const { client, token } = await createApiClient({ url: flags.url, token: flags.token });
requireAuth(token);

const res = await client.projects.get(args.id);
const res = await client.environments.get(args.id);

if (flags.format === 'json') {
await formatOutput(res, 'json');
} else if (flags.format === 'yaml') {
await formatOutput(res, 'yaml');
} else {
const p = res?.project ?? {};
const p = res?.environment ?? {};
console.log(`\nEnvironment: ${p.display_name ?? p.id}`);
console.log('─'.repeat(60));
console.log(` id: ${p.id}`);
Expand Down
14 changes: 7 additions & 7 deletions packages/cli/src/commands/environments/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,23 @@ export default class EnvironmentsSwitch extends Command {
requireAuth(token);

// Sanity-check the id resolves — fail fast before writing the cred file
const lookup = await client.projects.get(args.id);
const project = lookup?.project;
if (!project?.id) {
const lookup = await client.environments.get(args.id);
const environment = lookup?.environment;
if (!environment?.id) {
throw new Error(`Environment ${args.id} not found`);
}

if (flags.remote) {
await client.projects.activate(project.id);
await client.environments.activate(environment.id);
}

const cfg = await readAuthConfig();
cfg.activeEnvironmentId = project.id;
cfg.activeEnvironmentId = environment.id;
cfg.lastUsedAt = new Date().toISOString();
await writeAuthConfig(cfg);

console.log(`\n✓ Active environment: ${project.display_name ?? project.id}`);
console.log(` id: ${project.id}`);
console.log(`\n✓ Active environment: ${environment.display_name ?? environment.id}`);
console.log(` id: ${environment.id}`);
if (!flags.remote) {
console.log(' (local only — server session unchanged)');
}
Expand Down
Loading
Loading