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
24 changes: 24 additions & 0 deletions .changeset/30126-package-install-url-token-setup-refusal-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/cli': patch
'@objectstack/rest': patch
'create-objectstack': patch
---

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

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- 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 -->

**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:

- FROM `os package install -r <url>` / `os package install --runtime <url>` TO `os package install -u <url>` / `os package install --url <url>`.

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.

`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.

The scaffold's `AGENTS.md` and blank template README now spell the install command with `--url`.

`@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.
17 changes: 12 additions & 5 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3245,7 +3245,7 @@ Two modes, chosen by the shape of the argument:

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

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

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

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

**Options:**

| Flag | Env equivalent | Purpose |
|---|---|---|
| `package` (positional, required) | — | Package manifest id (`com.acme.crm`) **or** a path to a compiled artifact JSON |
| `-r, --runtime <url>` | `OS_RUNTIME_URL` | Base URL of the runtime to install **into** (default `http://localhost:3000`) |
| `-u, --url <url>` | `OS_RUNTIME_URL` | Base URL of the runtime to install **into** (default `http://localhost:3000`) |
| `-v, --version <semver>` | — | Version to install in catalog mode (default `latest`). Air-gapped mode takes the version from the artifact |
| `-t, --token <token>` | `OS_RUNTIME_TOKEN` | A token the target runtime issued (an API key); instead of `--email` / `--password` |
| `--email <email>` | `OS_RUNTIME_EMAIL` | Account email on the target runtime |
| `--password <password>` | `OS_RUNTIME_PASSWORD` | Account password on the target runtime |
| `--confirm-global-uniques` | — | Affirm this app's installation-wide `unique` constraints are genuinely platform-wide — see below |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/publish-and-preview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ and caches the manifest on disk so the install survives a restart:

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

# air-gapped mode: the artifact is read locally and sent inline, no catalog
os package install ./dist/objectstack.json
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,9 @@ Two stored sessions exist, and each command authenticates with one of them:
| `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 |

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

### Plugin Management

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

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

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

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

static override flags = {
runtime: Flags.string({
char: 'r',
description: 'Target runtime base URL (the instance to install INTO)',
url: Flags.string({
char: 'u',
description: 'Server URL of the runtime to install INTO',
env: 'OS_RUNTIME_URL',
default: 'http://localhost:3000',
}),
token: Flags.string({
char: 't',
description: 'Authentication token issued by the target runtime (an API key); instead of --email/--password',
env: 'OS_RUNTIME_TOKEN',
}),
version: Flags.string({
char: 'v',
description: "Version to install from the catalog (default: 'latest')",
Expand Down Expand Up @@ -169,7 +182,16 @@ export default class PackageInstall extends Command {
const { args, flags } = await this.parse(PackageInstall);

printHeader('Install Package');
const runtime = flags.runtime.replace(/\/+$/, '');
const runtime = flags.url.replace(/\/+$/, '');

// One credential per call: a token and a password sign-in name two
// identities, and picking one silently would install as whichever the
// command happened to prefer.
if (flags.token && (flags.email || flags.password)) {
printError('Pass either --token or --email/--password, not both (OS_RUNTIME_TOKEN counts as --token).');
this.exit(1);
return;
}

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

// ---- Authenticate against the TARGET runtime ------------------------
let cookie: string | undefined;
if (flags.email && flags.password) {
if (flags.token) {
printStep(`Authenticating to ${runtime} with a token`);
} else if (flags.email && flags.password) {
printStep(`Signing in to ${runtime} as ${flags.email}...`);
// `Origin` is required: better-auth's CSRF check 403s origin-less
// non-browser POSTs to the sign-in route.
Expand Down Expand Up @@ -252,6 +276,7 @@ export default class PackageInstall extends Command {
headers: {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
...(flags.token ? { Authorization: `Bearer ${flags.token}` } : {}),
},
body: JSON.stringify(body),
}, flags.timeout);
Expand Down Expand Up @@ -290,8 +315,9 @@ export default class PackageInstall extends Command {
}
if (res.status === 401) {
printError(
'The runtime rejected the call as unauthenticated. Pass --email/--password ' +
'(or set OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an account on the target runtime.',
'The runtime rejected the call as unauthenticated. Pass --token (or OS_RUNTIME_TOKEN) with a ' +
'token the target runtime issued, or --email/--password (or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) ' +
'for an account on the target runtime.',
);
} else if (res.status === 404) {
printError(
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/exit-signal.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,7 @@ describe('the text-face members, driven — ONE error line, the exit status unch

it('os package install, a runtime with no install-local endpoint (refused in the try itself): ONE error line, exit 1', async () => {
fetchStub.mockImplementation(async () => answer(404, { error: { message: 'not found' } }));
const run = await driveText(PackageInstall, [ARTIFACT, '--runtime', RUNTIME]);
const run = await driveText(PackageInstall, [ARTIFACT, '--url', RUNTIME]);
expectOneRefusal(run, RUNTIME);
expect(fetchStub).toHaveBeenCalledTimes(1);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/package-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ describe('os package export', () => {
return new Response(JSON.stringify({ success: true, data: answered }), { status: 200 });
}));
const file = join(out, exportedPackageDirectory(i, 3, entry.id), EXPORTED_PACKAGE_FILE);
const r = await run(PackageInstall, [file, '--runtime', 'http://target.test']);
const r = await run(PackageInstall, [file, '--url', 'http://target.test']);
expect(r.exit, r.out).toBeUndefined();
expect(posted).toHaveLength(1);
expect(posted[0].manifest).toEqual(entry);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/package-install-access-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number |
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any);
let exit: number | undefined;
try {
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]);
await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test', ...args]);
} catch (err: any) {
exit = err?.oclif?.exit ?? err?.code ?? 1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number |
vi.spyOn(console, 'error').mockImplementation(capture);
let exit: number | undefined;
try {
await PackageInstall.run([...args, '--runtime', 'http://runtime.test']);
await PackageInstall.run([...args, '--url', 'http://runtime.test']);
} catch (err: any) {
exit = err?.oclif?.exit ?? err?.code ?? 1;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/package-install-hot-loaded.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async function runInstall(): Promise<{ out: string; exit: number | undefined }>
vi.spyOn(console, 'error').mockImplementation(capture);
let exit: number | undefined;
try {
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test']);
await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test']);
} catch (err: any) {
exit = err?.oclif?.exit ?? err?.code;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ async function authenticate(live: LiveStart): Promise<string> {
*/
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
return new Promise((done) => {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], {
cwd: appDir,
env: childEnv({ NO_COLOR: '1' }),
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ async function authenticate(live: LiveStart): Promise<string> {
*/
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
return new Promise((done) => {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], {
cwd: appDir,
env: childEnv({ NO_COLOR: '1' }),
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ async function authenticate(live: LiveStart): Promise<string> {
*/
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
return new Promise((done) => {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], {
cwd: appDir,
env: childEnv({ NO_COLOR: '1' }),
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ async function authenticate(live: LiveStart): Promise<string> {
*/
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
return new Promise((done) => {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], {
cwd: appDir,
env: childEnv({ NO_COLOR: '1' }),
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ interface InstallRun { exit: number | null; output: string; elapsedMs: number }
function packageInstall(appDir: string, live: LiveStart, deadline: number): Promise<InstallRun> {
return new Promise((done, fail) => {
const started = Date.now();
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], {
cwd: appDir,
env: childEnv({ NO_COLOR: '1' }),
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ async function authenticate(live: LiveStart): Promise<string> {
*/
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
return new Promise((done) => {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], {
cwd: appDir,
env: childEnv({ NO_COLOR: '1' }),
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ async function authenticate(live: LiveStart): Promise<Session> {
*/
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
return new Promise((done) => {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], {
cwd: appDir,
env: childEnv({ NO_COLOR: '1' }),
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/package-install-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number |
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any);
let exit: number | undefined;
try {
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]);
await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test', ...args]);
} catch (err: any) {
exit = err?.oclif?.exit ?? err?.code ?? 1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async function runInstall(): Promise<{ out: string; exit: number | undefined }>
vi.spyOn(console, 'error').mockImplementation(capture);
let exit: number | undefined;
try {
await PackageInstall.run(['com.example.handlerjobs', '--runtime', 'http://runtime.test']);
await PackageInstall.run(['com.example.handlerjobs', '--url', 'http://runtime.test']);
} catch (err: any) {
exit = err?.oclif?.exit ?? err?.code;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/package-install-storage-dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function runInstall(): Promise<string> {
const capture = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); };
vi.spyOn(console, 'log').mockImplementation(capture);
vi.spyOn(console, 'error').mockImplementation(capture);
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test']);
await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test']);
return lines.join('\n');
}

Expand Down
Loading
Loading