Skip to content

Commit 12fbb2f

Browse files
fix(cli)!: os environments create drops --clone-from and the unread clone_from_environment_id key (#21028) (#21100)
Fixes #21028 Clause-②: no (narrowing) `os environments create --clone-from X` answered success and created an EMPTY environment: the CLI put the value on the create request as `clone_from_environment_id`, and the control plane never read that key (its create schema does not declare it and strips undeclared keys unread). Cloning an environment is not implemented, so the flag, the request key, the example line and the SDK request-type key are removed. This executes the maintainer's ruling on objectstack-ai/cloud#1876 (record 5731589897); the cloud half landed in objectstack-ai/cloud#2409. The cloning capability itself (objectstack-ai/cloud#2367) is not touched here and stays a separate decision. ## What changed - `packages/cli/src/commands/environments/create.ts`: the `clone-from` flag, the `clone_from_environment_id` request key and the `--clone-from` example line are gone. No bespoke message: a caller who still passes the flag gets oclif's own refusal. - `packages/client/src/index.ts`: the same key is dropped from the `environments.create` request type, and the namespace docblock no longer says the CLI sends it. `create` gains a short "No `clone_from_environment_id`" note beside the existing "No `template_id`" one. - `packages/cli/src/commands/environments/create-clone-from.test.ts` (new): the pin. It drives the real command through `Command.run` against a client whose `fetch` records the wire request. - `.changeset/21028-environments-create-clone-from-removed.md`: `@objectstack/cli` and `@objectstack/client`, both `minor`, BREAKING banner, the one-line fix, and an ADR-0087 disposition (`no-migration-prescription`: no metadata changes shape, so `objectstack migrate meta` has nothing to rewrite; `check-adr-0087-registration` accepts it). ## Repro, before and after Before, on `5dbeb7d7b7` (a scratch probe through the real `Command.run`, since deleted): ```text os environments create --org org_1 --name Dev --no-activate --clone-from env_source accepted: true, exit 0 POST /api/v1/cloud/environments body: organization_id, display_name, plan, clone_from_environment_id = "env_source" ``` After, on `3276f760ae`: ```text os environments create --org org_1 --name Dev --clone-from env_source --no-activate (real CLI entry, bin/run-dev.js) stderr: Error: Nonexistent flag: --clone-from exit 2 no request made os environments create --org org_1 --name Dev --no-activate POST body keys: display_name, organization_id, plan (no clone_from_environment_id) ``` ## The pin, and the ablation Four cases in the new file: a control (a create without the flag POSTs once and the body's key SET is exactly `display_name`, `organization_id`, `plan`), the flag refused in both spellings (`--clone-from X` and `--clone-from=X`: non-zero exit, `Nonexistent flag: --clone-from`, zero requests), and the flag undeclared with no `examples` line spelling a flag the command refuses. - Against the pristine `create.ts` of `5dbeb7d7b7`: 3 of 4 red (both refusals, and the declared-flag case); the control is green. - Ablation, each leg through `scripts/ablation-replace.mjs` in wrap mode (anchor hit once, blob changed, restore proven by blob equals HEAD and an empty `git diff HEAD`). A first attempt at leg 1 was REFUSED by the tool (its anchor count did not drop, nothing ran) and was redone with a non-self-containing anchor. - Leg 1, flag declaration re-added: 3 of 4 red (both refusals, declared-flag case), control green. - Leg 2, request key re-added with a constant value (`clone_from_environment_id: 'env_source'` beside `driver`): 1 of 4 red (the control, on the key set); the three flag cases stay green. - Restoring the key alone, reading the now-undeclared flag, is invisible on the wire (`JSON.stringify` drops `undefined`), which is why the key-set equality exists and why leg 2 plants a value. - After both restores: unmutated run green (2 files, 79 tests). The pin imports `./create.js` from source, so no `dist` is involved in these legs. ## Consumers of the removed client key - The CLI's request travels through this type: `client.environments.create({ ... })` in `create.ts` (the only non-test caller; `git grep "environments\.create\b"`). - `git grep -niE "clone[-_]from|cloneFrom|clone_from"` over the repo, excluding `content/docs/releases` and CHANGELOGs, found only `create.ts` and the two client lines; the four `client.environments.create` calls in `client.environments-namespace.test.ts` do not pass the key. Nothing else breaks. - The pinned sibling `../objectui` at `.objectui-sha` (`e420df310f`): `git grep -iE "clone_from_environment_id|cloneFromEnvironment|clone-from"` exits 1 (zero hits); the positive control finds `environments.*` calls in `packages/app-shell/src/console/organizations/provisionEnvironment.ts`. No sibling fix and no pin bump is owed. - A TypeScript caller that passes the key now fails to compile, naming it. At runtime nothing was different, because the server dropped the key. ## Verification (head `3276f760ae`, local scope; CI owns the full farm) - `pnpm --filter @objectstack/cli typecheck`: exit 0 (`tsc --noEmit` plus `check:test-typecheck`). It found one error in the first version of the new test (the `fetch` stub's parameter types), fixed in the second commit. - `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project unit`: 241 files, 3413 tests passed. - `pnpm --filter @objectstack/client typecheck`: exit 0. `pnpm --filter @objectstack/client exec vitest run --maxWorkers=2`: 50 files, 641 tests passed. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 64 commands, run one by one with the exit code captured before any pipe, all 64 exit 0, then `--ran` on that record: `64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`. Five of them (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`) first exited 3 (PREREQUISITE NOT MET: no `dist/`); that is NOT MEASURED, not a red. They were re-run after `turbo run build --filter='!@objectstack/docs'` and exit 0. - The checkout is behind `origin/main` (`b3d7a70864` when last read). Derived again on a throwaway worktree at that tip with this commit cherry-picked on (clean, no conflict): the same 64-command list, and `--ran` on the record gives the same 64/64 there, without the stale-tree warning. - `pnpm lint` (`eslint . --no-inline-config`): exit 0, 2m46s, repo-wide at `3276f760ae`. - Control-byte self-scan of the four changed files: no hits. ## Acceptance notes - Not run locally, declared to CI: the `@objectstack/cli` integration tier (the diff touches no integration-tier file, spawn entry or driver boot path), the repo-wide `pnpm test`, and the remote gates. - Not measured, not claimed, not filed: whether the control plane reads the other keys on the same request type (`slug`, `env_type`, `project_type`, `region`, `is_default`, `is_system`, `storage_limit_mb`) or the CLI's `--driver` and `--plan`. The control plane's source is not in this session, and no evidence says any of them is dropped. Carrier: none. - No governed surface is touched (`.changeset/**`, `packages/cli/src/**`, `packages/client/src/**` only), and `content/docs/releases/**` and every `CHANGELOG.md` are untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2bddb19 commit 12fbb2f

4 files changed

Lines changed: 242 additions & 5 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/client': minor
4+
---
5+
6+
fix(cli)!: `os environments create` no longer takes `--clone-from`, and the client's `environments.create` request no longer declares `clone_from_environment_id` (#21028)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote in a stack is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What goes is a CLI flag and one optional key on an SDK request type, both of which the control plane ignored. -->
11+
12+
**BREAKING for scripts that pass `--clone-from`, and for TypeScript callers that pass `clone_from_environment_id`.**
13+
14+
**What changed.** `os environments create --clone-from <id>` used to be accepted. It
15+
sent `clone_from_environment_id` on the create request, and the control plane never
16+
read that key: its create schema does not declare it, and an undeclared key is
17+
stripped unread. So the command answered success and created an EMPTY environment,
18+
with nothing saying the clone had not happened. Cloning an environment is not
19+
implemented, so the flag is gone, and so is the key on the request type.
20+
21+
- `os environments create --clone-from <id>` (also spelled `--clone-from=<id>`) is
22+
now refused by the argument parser (`Nonexistent flag: --clone-from`, exit 2) before
23+
any request is made. Before, it created an empty environment and exited 0.
24+
- A create without the flag is unchanged: the request body never carried the key.
25+
- `@objectstack/client`: `client.environments.create({ … })` no longer types
26+
`clone_from_environment_id`, so a call that passes it fails to compile, naming the
27+
key. At runtime the request was never different, because the server dropped it.
28+
29+
**The one-line fix.** Remove `--clone-from <id>` from the command, or
30+
`clone_from_environment_id` from the object you pass to `client.environments.create`.
31+
The environment it creates is the same empty one the old call created.
32+
33+
**What is not affected.** Every other flag of `os environments create`, and every
34+
other field of `client.environments.create`. Starter content is still installed into
35+
a new environment afterwards, from the App Marketplace (`os package install`).
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os environments create` does not take `--clone-from`, and sends no
5+
* `clone_from_environment_id`.
6+
*
7+
* ## The defect this file would have caught
8+
*
9+
* The command declared `--clone-from` ("Clone schema from an existing
10+
* environment id") and put its value on the create request as
11+
* `clone_from_environment_id`. The control plane never read that key: its
12+
* create schema does not declare it, and an undeclared key is stripped by the
13+
* schema's `z.object` gate, which is how the key was "accepted and ignored". So
14+
* `os environments create --clone-from X` answered success and created an
15+
* EMPTY environment, with nothing saying the clone had not happened. Cloning is
16+
* not implemented; a flag that advertises it is a capability the runtime does
17+
* not deliver.
18+
*
19+
* ## What is pinned, and why each half needs its own case
20+
*
21+
* - The flag is REFUSED, by oclif's own "Nonexistent flag" answer and with the
22+
* control plane never called. The refusal is the parser's default on purpose:
23+
* a bespoke message would be a sentence the CLI owns and has to keep true.
24+
* - A create WITHOUT the flag puts no `clone_from_environment_id` on the wire.
25+
* `JSON.stringify` drops an `undefined` value, so this half goes green even
26+
* while the request still names the key; it is the control that proves the
27+
* interception below sees the real request, and the key-set equality is what
28+
* fails if anything ever writes that key with a value again.
29+
* - No `examples` line spells a flag the command refuses. `examples` is printed
30+
* verbatim by `--help`, so a stale line is a copy-paste that fails.
31+
*
32+
* ## Why every case carries an explicit 60s budget
33+
*
34+
* Each case drives a real `Command.run` against the real oclif root, which
35+
* resolves the plugin and manifest surface before the command body runs. The
36+
* budget is wall-clock only and no assertion moves with it; the sibling
37+
* `commands/data/delete-arms-agree.test.ts` states the same reason.
38+
*/
39+
40+
import { describe, it, expect, vi, afterEach } from 'vitest';
41+
import path from 'node:path';
42+
import { fileURLToPath } from 'node:url';
43+
import { ObjectStackClient } from '@objectstack/client';
44+
import EnvironmentsCreate from './create.js';
45+
46+
const stub = vi.hoisted(() => ({
47+
client: undefined as any,
48+
token: 'test-token' as string | undefined,
49+
}));
50+
51+
vi.mock('../../utils/api-client.js', async (importOriginal) => {
52+
const actual = await importOriginal<typeof import('../../utils/api-client.js')>();
53+
return {
54+
...actual,
55+
createApiClient: async () => ({ client: stub.client, token: stub.token, baseUrl: 'https://door.test' }),
56+
};
57+
});
58+
59+
/** `packages/cli` — the oclif root the command is loaded against. */
60+
const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
61+
62+
interface Recorded {
63+
url: string;
64+
method: string | undefined;
65+
body: Record<string, unknown> | undefined;
66+
}
67+
68+
/**
69+
* A client whose `fetch` records every request and answers the create route's
70+
* real envelope. Recording at the WIRE, not at `client.environments.create`,
71+
* so the assertion reads what the control plane would have received.
72+
*/
73+
function recordingClient(): { client: ObjectStackClient; requests: Recorded[] } {
74+
const requests: Recorded[] = [];
75+
const client = new ObjectStackClient({
76+
baseUrl: 'https://door.test',
77+
token: 'test-token',
78+
fetch: async (url: URL | RequestInfo, init?: RequestInit) => {
79+
requests.push({
80+
url: String(url),
81+
method: init?.method,
82+
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
83+
});
84+
return {
85+
ok: true,
86+
status: 201,
87+
statusText: 'Created',
88+
json: async () => ({
89+
environment: { id: 'env_new', display_name: 'Dev' },
90+
warnings: [],
91+
durationMs: 1,
92+
}),
93+
headers: new Headers(),
94+
} as any;
95+
},
96+
});
97+
return { client, requests };
98+
}
99+
100+
interface CliRun {
101+
/** What the command threw, or `undefined` when it returned normally. */
102+
error: (Error & { flags?: string[] }) | undefined;
103+
exitCode: number;
104+
}
105+
106+
async function runCreate(argv: string[]): Promise<CliRun> {
107+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
108+
const writeSpy = vi
109+
.spyOn(process.stdout, 'write')
110+
.mockImplementation(((_chunk: unknown, encodingOrCb?: unknown, maybeCb?: unknown) => {
111+
const done = typeof encodingOrCb === 'function' ? encodingOrCb : maybeCb;
112+
if (typeof done === 'function') (done as (err?: Error | null) => void)(null);
113+
return true;
114+
}) as never);
115+
const savedExitCode = process.exitCode;
116+
process.exitCode = undefined;
117+
let exitCode = 0;
118+
let error: CliRun['error'];
119+
try {
120+
await EnvironmentsCreate.run(argv, { root: CLI_ROOT });
121+
} catch (caught: unknown) {
122+
error = caught as CliRun['error'];
123+
const oclif = (caught as { oclif?: { exit?: number } })?.oclif;
124+
exitCode = typeof oclif?.exit === 'number' ? oclif.exit : 1;
125+
} finally {
126+
if (exitCode === 0 && typeof process.exitCode === 'number') exitCode = process.exitCode;
127+
logSpy.mockRestore();
128+
writeSpy.mockRestore();
129+
process.exitCode = savedExitCode;
130+
}
131+
return { error, exitCode };
132+
}
133+
134+
/** Every flag a create needs and nothing else; `--no-activate` keeps it off `~/.objectstack`. */
135+
const BASE_ARGV = ['--org', 'org_1', '--name', 'Dev', '--no-activate'];
136+
137+
afterEach(() => {
138+
stub.client = undefined;
139+
stub.token = 'test-token';
140+
});
141+
142+
describe('`os environments create` — no `--clone-from`, no `clone_from_environment_id`', () => {
143+
it('CONTROL — a create without the flag POSTs once and its body names no clone key', async () => {
144+
const { client, requests } = recordingClient();
145+
stub.client = client;
146+
const run = await runCreate(BASE_ARGV);
147+
148+
expect(run.error).toBeUndefined();
149+
expect(run.exitCode).toBe(0);
150+
expect(requests).toHaveLength(1);
151+
expect(requests[0].url).toBe('https://door.test/api/v1/cloud/environments');
152+
expect(requests[0].method).toBe('POST');
153+
// The key SET, not only the absence of one key: this is what reddens if a
154+
// value for it is ever written again.
155+
expect(Object.keys(requests[0].body ?? {}).sort()).toEqual(['display_name', 'organization_id', 'plan']);
156+
expect(requests[0].body).not.toHaveProperty('clone_from_environment_id');
157+
}, 60_000);
158+
159+
it.each([
160+
['separate value', ['--clone-from', 'env_source']],
161+
['=value', ['--clone-from=env_source']],
162+
])('`--clone-from` (%s) is refused by the parser, and the control plane is never called', async (_label, extra) => {
163+
const { client, requests } = recordingClient();
164+
stub.client = client;
165+
const run = await runCreate([...BASE_ARGV, ...extra]);
166+
167+
// The parser's own answer: a non-zero exit and its `Nonexistent flag` text.
168+
expect(run.exitCode).not.toBe(0);
169+
expect(run.error?.message).toMatch(/^Nonexistent flag: --clone-from\b/);
170+
// Nothing reached the wire: the user is told, rather than handed an EMPTY
171+
// environment and a success line.
172+
expect(requests).toEqual([]);
173+
}, 60_000);
174+
175+
it('the flag is not declared, and no example line spells a flag the command refuses', () => {
176+
const declared = new Set(
177+
Object.entries(EnvironmentsCreate.flags).flatMap(([name, flag]) => [
178+
`--${name}`,
179+
...((flag as { allowNo?: boolean }).allowNo ? [`--no-${name}`] : []),
180+
]),
181+
);
182+
183+
expect(declared.has('--clone-from')).toBe(false);
184+
185+
const spelled = EnvironmentsCreate.examples
186+
.flatMap((example) => String(example).match(/--[a-z][a-z0-9-]*/g) ?? []);
187+
// The control: the examples really do spell flags, so the loop below is not
188+
// vacuously true over an empty list.
189+
expect(spelled).toContain('--org');
190+
expect(spelled).toContain('--artifact');
191+
for (const flag of spelled) {
192+
expect(declared.has(flag), `example spells ${flag}, which the command does not declare`).toBe(true);
193+
}
194+
});
195+
});

‎packages/cli/src/commands/environments/create.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ export default class EnvironmentsCreate extends Command {
3636
static override examples = [
3737
'$ os environments create --org 00000000-0000-0000-0000-000000000000 --name Staging',
3838
'$ os environments create --org $ORG --name Dev --plan free',
39-
'$ os environments create --org $ORG --name "Clone" --clone-from <source-id> --no-activate',
4039
'$ os environments create --org $ORG --name CRM --artifact ./examples/app-crm/dist/objectstack.json',
4140
];
4241

@@ -57,7 +56,12 @@ export default class EnvironmentsCreate extends Command {
5756
artifact: Flags.string({
5857
description: 'Path to a locally-compiled objectstack.json artifact to bind into this environment',
5958
}),
60-
'clone-from': Flags.string({ description: 'Clone schema from an existing environment id' }),
59+
// No `--clone-from`. It sent `clone_from_environment_id`, which the control
60+
// plane's create schema does not declare and strips unread, so a create that
61+
// passed it answered success and made an EMPTY environment: an accepted-and-
62+
// dropped flag reports success for work that never happened (the same reason
63+
// `--template` is gone). Environments are created empty; oclif's own
64+
// "Nonexistent flag" refusal is the answer a user who still passes it gets.
6165
activate: Flags.boolean({
6266
description: 'Activate the new environment for subsequent CLI calls',
6367
default: true,
@@ -102,7 +106,6 @@ export default class EnvironmentsCreate extends Command {
102106
display_name: flags.name,
103107
plan: flags.plan,
104108
driver: flags.driver,
105-
clone_from_environment_id: flags['clone-from'],
106109
...(metadata ? { metadata } : {}),
107110
});
108111

‎packages/client/src/index.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2917,7 +2917,7 @@ export class ObjectStackClient {
29172917
* plane this namespace calls speaks **snake_case**: the in-repo CLI
29182918
* consumers read `p.display_name`, `p.organization_id`, `p.is_default`,
29192919
* `res.database.database_url`, `res.membership.role`, and send
2920-
* `organization_id` / `display_name` / `clone_from_environment_id`.
2920+
* `organization_id` / `display_name`.
29212921
*
29222922
* So those contracts are not this wire's types. Binding to them would
29232923
* typecheck, be false, and break the CLI at compile time while telling it
@@ -2990,6 +2990,11 @@ export class ObjectStackClient {
29902990
* the `apps/server` templates route, and `sys_environment` has no such
29912991
* column, so the field was accepted, transmitted, and dropped. Its
29922992
* listing counterpart went the same way in #3702.
2993+
*
2994+
* No `clone_from_environment_id`, for the same reason: the control plane's
2995+
* create schema does not declare it and strips it unread, so a create that
2996+
* carried it answered success and made an EMPTY environment. Cloning an
2997+
* environment is not implemented.
29932998
*/
29942999
create: async (req: {
29953000
organization_id: string;
@@ -3003,7 +3008,6 @@ export class ObjectStackClient {
30033008
is_default?: boolean;
30043009
is_system?: boolean;
30053010
storage_limit_mb?: number;
3006-
clone_from_environment_id?: string;
30073011
metadata?: Record<string, unknown>;
30083012
}) => {
30093013
const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments`, {

0 commit comments

Comments
 (0)