Skip to content

Commit 2fbddd4

Browse files
fix(cli,rest)!: os package install takes -u/--url and -t/--token; the /meta permission refusal is localized (#30792)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DY5gzehJPFXRJ6RuAr8coQ1)_ domain:server#4 · os-elon-musk · session_016WV4cxrNurGFU9PsE6NwNT Fixes #30126 Clause-②: yes (narrowing) Metadata-protocol: none ## 维护者速读 - 改了什么:`os package install` 的服务器参数从 `-r/--runtime` 改名为 `-u/--url`(无别名),并新增 `-t/--token`,与 `os package export` 等命令写法一致;打开无权访问的应用、仪表盘、列表视图时,403 拒绝文案改为按调用者语言输出(中文控制台显示「您没有执行此操作的权限,如需访问请联系管理员。」)。 - 为什么改:#30126 第 13 行(裁决 A:统一服务器与凭据写法)和第 12 行(Setup 应用拒绝文案是英文,还带着 `com.objectstack.setup` 这种 API 名)。 - 风险与回滚:破坏性变更,只影响传 `-r`/`--runtime` 的脚本,会被当作未知参数拒绝;只设 `OS_RUNTIME_URL` 的脚本不受影响。拒绝文案不再含条目名,两条 #8013 断言随之改写,请在合约评审中确认。回滚即撤本 PR。 - 先看:`packages/cli/src/commands/package/install.ts` 的 `url` / `token` 参数;`packages/rest/src/meta-item-read-gate.ts` 的 `permissionDeniedMessage`。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run test/package-install-url-token-flags.test.ts`(8 条全绿);`pnpm --filter @objectstack/rest exec vitest run src/meta-permission-refusal-locale.test.ts`(9 条全绿)。 - 需要你做的:标题带 `!`,入队前需要你对本 PR 点头,并完成合约评审(收窄面)。落地由席位负责。 ## What changes - **Row 13, `@objectstack/cli` (BREAKING).** `os package install` names its server `-u, --url` (env `OS_RUNTIME_URL`, default `http://localhost:3000`, both unchanged) in place of `-r, --runtime`, with no alias. A script that passes `-r` or `--runtime` is refused as an unknown flag. A new `-t, --token` (env `OS_RUNTIME_TOKEN`) sends a token the target runtime issued as `Authorization: Bearer` on the install request, with no sign-in round trip; `--email` / `--password` still work. Passing a token together with `--email` / `--password` is refused before any request. These are the spellings `os package export` and about twenty other server-talking commands use. `packages/cli` has no shared flag helper to reuse, so the two flags are declared inline with the same shape as `export`'s. - **Docs and templates.** `content/docs/deployment/cli.mdx` (example, flag table, credential paragraph), `content/docs/deployment/publish-and-preview.mdx`, `packages/cli/README.md`, and the `create-objectstack` scaffold's `AGENTS.md` and blank `README.md` now say `--url`. The scaffold's two lines were outside the claimed surface but would have become false, so they are fixed here, with a `create-objectstack` patch in the changeset. - **Row 12, `@objectstack/rest`.** The `/meta` read gate's three `403 PERMISSION_DENIED` refusals (app, dashboard, list view) rendered fixed English sentences naming the item's API name. They now render the operation catalog's `permission_denied` (`renderOperationMessage`, `@objectstack/spec/system`) in the caller's `ExecutionContext.locale`. That is the catalog and key plugin-security's object and capability refusals already use. The REST door also hands in the `errors.*` override lookup, so a deployment's `errors.permission_denied` translation applies here as it does there. The runtime dispatcher calls the same gate and gets the built-in catalog in the caller's locale; it hands no override lookup, because `packages/runtime` is outside this claim. Status, code and envelope are unchanged. ## Tests - `packages/cli/test/package-install-url-token-flags.test.ts` (new, 8): `--url` / `-u` reach the request URL; `--runtime` and `-r` are refused before any request; `--token`, `-t` and `OS_RUNTIME_TOKEN` send `Authorization: Bearer`; no token sends no header; token plus email/password is refused with exit 1 before any request. - Sixteen existing CLI test files pass `--url` where they passed `--runtime`, including seven integration files that spawn the CLI. - `packages/rest/src/meta-permission-refusal-locale.test.ts` (new, 9): a `zh-CN` caller reads the Chinese sentence on all three refusals; a caller with no locale reads the English one, which carries no API name; a deployment override wins; a lookup that echoes the key is no override; a lookup that throws keeps the built-in sentence and the 403. - `meta-app-publish-gate.test.ts` and `meta-item-absent-404.test.ts`: the two #8013 assertions `message toContain('finance')` now assert the catalog sentence. Status and code assertions are unchanged. - Ablations through `scripts/ablation-replace.mjs`, each restored with blob == HEAD: the token/email refusal guard disabled → 1 of 8 failed; the locale dropped from `renderOperationMessage` → 6 of 9 failed. ## Acceptance notes - **The refusal no longer names the item.** The `permission_denied` key takes no placeholders on purpose (`operation-message.ts`), and the name the gate had was the API name (`com.objectstack.setup`), which is what row 12 reported. Keeping a name would need a new catalog key in `packages/spec`, which this claim forbids. The Console's "You don't have access" page shows the server sentence (`ui/packages/app-shell` CHANGELOG notes it named the item); it now shows the localized sentence. This is the point for the contract review to confirm. - `createMetaItemReadGate`'s `name` parameter is now read by no gate. It is kept (as `_name`) so neither transport's call changes. - Row 9: ruling A, no change. Row 16: ruling A, filed by the seat as its own card. ## Out of scope - `skills/objectstack-platform/references/operations.md:106` still says `--runtime` for `os package install`. The seat files it as a skills-lane card, serial behind this PR. ## Verification <details><summary>Derived gates and suites</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `6a2119cc`: 97 commands, each run with its exit code captured before any pipe. 95 exit 0 on the first pass; two exit 3 (prerequisite) and exit 0 after their named builds: `check:skill-examples` (after building `@objectstack/client-react`: 283 prose examples type-check) and `check:dual-build-cjs-loads` (after building the six packages it named). - `dispatch-gates --ran`: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. Changed lines: 470 (+421 / -49, 30 files). - `check-adr-0087-registration`: one declared-breaking changeset, disposition `not-required (no-migration-prescription)`. - `pnpm --filter @objectstack/cli typecheck`, `pnpm --filter @objectstack/rest typecheck`: exit 0. - `@objectstack/cli` unit layer: 388 files / 5415 tests passed. The seven touched integration files: 7 files / 77 tests passed. - `@objectstack/rest` full suite: 396 files. The first run failed the two #8013 assertions above; after updating them, those two files pass 53/53. - `@objectstack/runtime` read-gate parity, app package-id and dispatcher tests: 3 files / 381 tests passed. - Dogfood (title carries `!`): `pnpm --filter @objectstack/dogfood test` on `6a2119cc`, 422 files passed / 3 skipped, 3527 tests passed / 41 skipped. - Merged `origin/main` before the gate sweep; the incoming commits touch neither `packages/cli`, `packages/rest` nor `packages/spec`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cxrNurGFU9PsE6NwNT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 100303c commit 2fbddd4

30 files changed

Lines changed: 421 additions & 49 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/rest': patch
4+
'create-objectstack': patch
5+
---
6+
7+
fix(cli,rest)!: `os package install` names its server and credential `-u/--url` and `-t/--token`, like `os package export`; the `/meta` read gate's permission refusal is localized
8+
9+
Clause-②: yes (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) no key of any schema, export or stored row changes: a command-line option of one CLI command is renamed, which no metadata, artifact or database row records, so there is nothing for `objectstack migrate meta` to rewrite; the rename is for the person or script typing the command -->
13+
14+
**BREAKING** for a script that runs `os package install` with `-r` or `--runtime`. The option is renamed with no alias, so the old spelling is refused as an unknown flag. Write `-u` / `--url` in its place:
15+
16+
- FROM `os package install -r <url>` / `os package install --runtime <url>` TO `os package install -u <url>` / `os package install --url <url>`.
17+
18+
The value, its default (`http://localhost:3000`) and its environment variable (`OS_RUNTIME_URL`) are unchanged. A script that sets only `OS_RUNTIME_URL` needs no change.
19+
20+
`os package install` also takes `-t` / `--token` (environment variable `OS_RUNTIME_TOKEN`, new): a token the target runtime issued, sent as `Authorization: Bearer`, instead of signing in with `--email` / `--password`. Passing both is refused before any request. These are the spellings `os package export` and the other server-talking commands use.
21+
22+
The scaffold's `AGENTS.md` and blank template README now spell the install command with `--url`.
23+
24+
`@objectstack/rest`: an app, a dashboard or a list view a signed-in caller may not open is still refused `403 PERMISSION_DENIED`, and the sentence is now the operation catalog's `permission_denied` in the caller's locale, the sentence plugin-security's object refusals already use. Opening the Setup app without access in a Chinese console read `You do not have permission to open the 'com.objectstack.setup' app.`; it now reads 「您没有执行此操作的权限,如需访问请联系管理员。」. The sentence no longer carries the item's API name. A deployment's `errors.permission_denied` translation overrides it on the REST door, as it does for those refusals.

‎content/docs/deployment/cli.mdx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3245,7 +3245,7 @@ Two modes, chosen by the shape of the argument:
32453245

32463246
```bash
32473247
# catalog mode — the TARGET runtime resolves the version from its own catalog
3248-
os package install com.acme.crm --version 1.2.0 --runtime https://app.example.com
3248+
os package install com.acme.crm --version 1.2.0 --url https://app.example.com
32493249

32503250
# air-gapped mode — the artifact is read locally and sent inline; no catalog, works offline
32513251
os package install ./dist/objectstack.json
@@ -3258,18 +3258,25 @@ catalog id that happens to match a file in the working directory is installed
32583258
from that file instead.
32593259

32603260
**The credential is the *runtime* identity, not your cloud login.** The target
3261-
runtime authenticates the call with its own session, so `--email` / `--password`
3262-
(or `OS_RUNTIME_EMAIL` / `OS_RUNTIME_PASSWORD`) name an account **on that
3263-
runtime**. A `401` means exactly that, and the command says so; `os cloud login`
3261+
runtime authenticates the call itself: pass a token it issued (`-t, --token`, or
3262+
`OS_RUNTIME_TOKEN`), or `--email` / `--password` (or `OS_RUNTIME_EMAIL` /
3263+
`OS_RUNTIME_PASSWORD`) for an account **on that runtime** — one or the other,
3264+
not both. A `401` means exactly that, and the command says so; `os cloud login`
32643265
credentials do not apply here.
32653266

3267+
The server and the credential are spelled as in `os package export` and the
3268+
other server-talking commands: `-u, --url` and `-t, --token`. `-r, --runtime`
3269+
is gone with no alias, so a script that passes it is refused as an unknown flag;
3270+
write `-u URL` instead.
3271+
32663272
**Options:**
32673273

32683274
| Flag | Env equivalent | Purpose |
32693275
|---|---|---|
32703276
| `package` (positional, required) | — | Package manifest id (`com.acme.crm`) **or** a path to a compiled artifact JSON |
3271-
| `-r, --runtime <url>` | `OS_RUNTIME_URL` | Base URL of the runtime to install **into** (default `http://localhost:3000`) |
3277+
| `-u, --url <url>` | `OS_RUNTIME_URL` | Base URL of the runtime to install **into** (default `http://localhost:3000`) |
32723278
| `-v, --version <semver>` | — | Version to install in catalog mode (default `latest`). Air-gapped mode takes the version from the artifact |
3279+
| `-t, --token <token>` | `OS_RUNTIME_TOKEN` | A token the target runtime issued (an API key); instead of `--email` / `--password` |
32733280
| `--email <email>` | `OS_RUNTIME_EMAIL` | Account email on the target runtime |
32743281
| `--password <password>` | `OS_RUNTIME_PASSWORD` | Account password on the target runtime |
32753282
| `--confirm-global-uniques` | — | Affirm this app's installation-wide `unique` constraints are genuinely platform-wide — see below |

‎content/docs/deployment/publish-and-preview.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ and caches the manifest on disk so the install survives a restart:
198198

199199
```bash
200200
# catalog mode: the target runtime fetches the version from its own catalog
201-
os package install com.acme.crm --version 1.2.0 --runtime https://app.example.com
201+
os package install com.acme.crm --version 1.2.0 --url https://app.example.com
202202

203203
# air-gapped mode: the artifact is read locally and sent inline, no catalog
204204
os package install ./dist/objectstack.json

‎packages/cli/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,9 @@ Two stored sessions exist, and each command authenticates with one of them:
139139
| `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 |
140140

141141
`os package install` is not a cloud command: it installs into a running runtime
142-
(`-r, --runtime`, env `OS_RUNTIME_URL`, default `http://localhost:3000`) and signs
143-
in there with `--email` / `--password` (env `OS_RUNTIME_EMAIL` /
144-
`OS_RUNTIME_PASSWORD`).
142+
(`-u, --url`, env `OS_RUNTIME_URL`, default `http://localhost:3000`) and
143+
authenticates there with `-t, --token` (env `OS_RUNTIME_TOKEN`) or `--email` /
144+
`--password` (env `OS_RUNTIME_EMAIL` / `OS_RUNTIME_PASSWORD`).
145145

146146
### Plugin Management
147147

‎packages/cli/src/commands/package/install.ts‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,16 @@
4343
*
4444
* This complements `os package publish` (which uploads to the CLOUD): the
4545
* pairing is publish-to-cloud / install-into-runtime. The target runtime
46-
* authenticates the call with its own better-auth session — pass
47-
* --email/--password (or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an
48-
* account on the TARGET runtime, not your cloud login.
46+
* authenticates the call itself — pass a token it issued (-t/--token, or
47+
* OS_RUNTIME_TOKEN), or --email/--password (or OS_RUNTIME_EMAIL /
48+
* OS_RUNTIME_PASSWORD) for an account on the TARGET runtime, not your cloud
49+
* login.
50+
*
51+
* [#30126 row 13, ruling A] The server and the credential are spelled as
52+
* `os package export` and the other server-talking commands spell them:
53+
* `-u/--url` and `-t/--token`. `-r/--runtime` is retired with no alias — the
54+
* startup rule keeps no window for an old spelling — so `os package install
55+
* -r URL` is refused by the parser as an unknown flag and the fix is `-u URL`.
4956
*/
5057

5158
import { readFile } from 'node:fs/promises';
@@ -88,10 +95,11 @@ export default class PackageInstall extends Command {
8895

8996
static override examples = [
9097
'$ os package install com.acme.crm',
91-
'$ os package install com.acme.crm --version 1.2.0 --runtime http://localhost:3000',
98+
'$ os package install com.acme.crm --version 1.2.0 --url http://localhost:3000',
9299
'$ os package install ./dist/objectstack.json # air-gapped, no catalog',
93100
'$ os package install ./dist/objectstack.json --install-mode unmanaged # copy into the environment as its own metadata',
94101
'$ OS_RUNTIME_EMAIL=admin@local.test OS_RUNTIME_PASSWORD=… os package install com.acme.crm',
102+
'$ os package install ./dist/objectstack.json --url https://app.example.com --token osk_…',
95103
];
96104

97105
static override args = {
@@ -102,12 +110,17 @@ export default class PackageInstall extends Command {
102110
};
103111

104112
static override flags = {
105-
runtime: Flags.string({
106-
char: 'r',
107-
description: 'Target runtime base URL (the instance to install INTO)',
113+
url: Flags.string({
114+
char: 'u',
115+
description: 'Server URL of the runtime to install INTO',
108116
env: 'OS_RUNTIME_URL',
109117
default: 'http://localhost:3000',
110118
}),
119+
token: Flags.string({
120+
char: 't',
121+
description: 'Authentication token issued by the target runtime (an API key); instead of --email/--password',
122+
env: 'OS_RUNTIME_TOKEN',
123+
}),
111124
version: Flags.string({
112125
char: 'v',
113126
description: "Version to install from the catalog (default: 'latest')",
@@ -169,7 +182,16 @@ export default class PackageInstall extends Command {
169182
const { args, flags } = await this.parse(PackageInstall);
170183

171184
printHeader('Install Package');
172-
const runtime = flags.runtime.replace(/\/+$/, '');
185+
const runtime = flags.url.replace(/\/+$/, '');
186+
187+
// One credential per call: a token and a password sign-in name two
188+
// identities, and picking one silently would install as whichever the
189+
// command happened to prefer.
190+
if (flags.token && (flags.email || flags.password)) {
191+
printError('Pass either --token or --email/--password, not both (OS_RUNTIME_TOKEN counts as --token).');
192+
this.exit(1);
193+
return;
194+
}
173195

174196
try {
175197
// ---- Resolve the source: inline artifact file vs catalog id ---------
@@ -218,7 +240,9 @@ export default class PackageInstall extends Command {
218240

219241
// ---- Authenticate against the TARGET runtime ------------------------
220242
let cookie: string | undefined;
221-
if (flags.email && flags.password) {
243+
if (flags.token) {
244+
printStep(`Authenticating to ${runtime} with a token`);
245+
} else if (flags.email && flags.password) {
222246
printStep(`Signing in to ${runtime} as ${flags.email}...`);
223247
// `Origin` is required: better-auth's CSRF check 403s origin-less
224248
// non-browser POSTs to the sign-in route.
@@ -252,6 +276,7 @@ export default class PackageInstall extends Command {
252276
headers: {
253277
'Content-Type': 'application/json',
254278
...(cookie ? { Cookie: cookie } : {}),
279+
...(flags.token ? { Authorization: `Bearer ${flags.token}` } : {}),
255280
},
256281
body: JSON.stringify(body),
257282
}, flags.timeout);
@@ -290,8 +315,9 @@ export default class PackageInstall extends Command {
290315
}
291316
if (res.status === 401) {
292317
printError(
293-
'The runtime rejected the call as unauthenticated. Pass --email/--password ' +
294-
'(or set OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an account on the target runtime.',
318+
'The runtime rejected the call as unauthenticated. Pass --token (or OS_RUNTIME_TOKEN) with a ' +
319+
'token the target runtime issued, or --email/--password (or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) ' +
320+
'for an account on the target runtime.',
295321
);
296322
} else if (res.status === 404) {
297323
printError(

‎packages/cli/test/exit-signal.pin.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -993,7 +993,7 @@ describe('the text-face members, driven — ONE error line, the exit status unch
993993

994994
it('os package install, a runtime with no install-local endpoint (refused in the try itself): ONE error line, exit 1', async () => {
995995
fetchStub.mockImplementation(async () => answer(404, { error: { message: 'not found' } }));
996-
const run = await driveText(PackageInstall, [ARTIFACT, '--runtime', RUNTIME]);
996+
const run = await driveText(PackageInstall, [ARTIFACT, '--url', RUNTIME]);
997997
expectOneRefusal(run, RUNTIME);
998998
expect(fetchStub).toHaveBeenCalledTimes(1);
999999
});

‎packages/cli/test/package-export.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ describe('os package export', () => {
136136
return new Response(JSON.stringify({ success: true, data: answered }), { status: 200 });
137137
}));
138138
const file = join(out, exportedPackageDirectory(i, 3, entry.id), EXPORTED_PACKAGE_FILE);
139-
const r = await run(PackageInstall, [file, '--runtime', 'http://target.test']);
139+
const r = await run(PackageInstall, [file, '--url', 'http://target.test']);
140140
expect(r.exit, r.out).toBeUndefined();
141141
expect(posted).toHaveLength(1);
142142
expect(posted[0].manifest).toEqual(entry);

‎packages/cli/test/package-install-access-upgrade.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number |
4040
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any);
4141
let exit: number | undefined;
4242
try {
43-
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]);
43+
await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test', ...args]);
4444
} catch (err: any) {
4545
exit = err?.oclif?.exit ?? err?.code ?? 1;
4646
}

‎packages/cli/test/package-install-customization-pair.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number |
4343
vi.spyOn(console, 'error').mockImplementation(capture);
4444
let exit: number | undefined;
4545
try {
46-
await PackageInstall.run([...args, '--runtime', 'http://runtime.test']);
46+
await PackageInstall.run([...args, '--url', 'http://runtime.test']);
4747
} catch (err: any) {
4848
exit = err?.oclif?.exit ?? err?.code ?? 1;
4949
}

‎packages/cli/test/package-install-hot-loaded.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async function runInstall(): Promise<{ out: string; exit: number | undefined }>
5050
vi.spyOn(console, 'error').mockImplementation(capture);
5151
let exit: number | undefined;
5252
try {
53-
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test']);
53+
await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test']);
5454
} catch (err: any) {
5555
exit = err?.oclif?.exit ?? err?.code;
5656
}

0 commit comments

Comments
 (0)