@antasphere/chassis-cli 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +82 -0
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +582 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/completion.d.ts +5 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +85 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/demo.d.ts +12 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +210 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/files.d.ts +21 -0
- package/dist/commands/files.d.ts.map +1 -0
- package/dist/commands/files.js +178 -0
- package/dist/commands/files.js.map +1 -0
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.d.ts.map +1 -0
- package/dist/commands/projects.js +386 -0
- package/dist/commands/projects.js.map +1 -0
- package/dist/commands/teams.d.ts +46 -0
- package/dist/commands/teams.d.ts.map +1 -0
- package/dist/commands/teams.js +265 -0
- package/dist/commands/teams.js.map +1 -0
- package/dist/commands/workspaces.d.ts +22 -0
- package/dist/commands/workspaces.d.ts.map +1 -0
- package/dist/commands/workspaces.js +116 -0
- package/dist/commands/workspaces.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +152 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +456 -0
- package/dist/context.js.map +1 -0
- package/dist/identity.d.ts +36 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +18 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/kit.d.ts +46 -0
- package/dist/kit.d.ts.map +1 -0
- package/dist/kit.js +2 -0
- package/dist/kit.js.map +1 -0
- package/dist/open.d.ts +27 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +45 -0
- package/dist/open.js.map +1 -0
- package/dist/owner-session.d.ts +34 -0
- package/dist/owner-session.d.ts.map +1 -0
- package/dist/owner-session.js +106 -0
- package/dist/owner-session.js.map +1 -0
- package/dist/program.d.ts +5 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +150 -0
- package/dist/program.js.map +1 -0
- package/dist/safe-write.d.ts +46 -0
- package/dist/safe-write.d.ts.map +1 -0
- package/dist/safe-write.js +127 -0
- package/dist/safe-write.js.map +1 -0
- package/dist/stdin.d.ts +4 -0
- package/dist/stdin.d.ts.map +1 -0
- package/dist/stdin.js +36 -0
- package/dist/stdin.js.map +1 -0
- package/dist/testing/index.d.ts +70 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +67 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/workspace.d.ts +79 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +139 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +43 -3
- package/src/commands/auth.ts +687 -0
- package/src/commands/completion.ts +102 -0
- package/src/commands/demo.ts +271 -0
- package/src/commands/files.ts +197 -0
- package/src/commands/projects.ts +460 -0
- package/src/commands/teams.ts +320 -0
- package/src/commands/workspaces.ts +140 -0
- package/src/config.ts +81 -0
- package/src/context.ts +616 -0
- package/src/identity.ts +48 -0
- package/src/index.ts +70 -0
- package/src/kit.ts +50 -0
- package/src/open.ts +48 -0
- package/src/owner-session.ts +152 -0
- package/src/program.ts +183 -0
- package/src/safe-write.ts +136 -0
- package/src/stdin.ts +39 -0
- package/src/testing/index.ts +129 -0
- package/src/workspace.ts +229 -0
- package/test/host/identity.test.ts +229 -0
- package/test/host/minimal-host.ts +16 -0
- package/test/host/minimal-tool.ts +40 -0
- package/test/suite/cli.test.ts +301 -0
- package/test/suite/config.test.ts +59 -0
- package/test/suite/demo.test.ts +467 -0
- package/test/suite/hub-profile-shared.test.ts +123 -0
- package/test/suite/login.test.ts +743 -0
- package/test/suite/logout.test.ts +247 -0
- package/test/suite/migration.test.ts +109 -0
- package/test/suite/open.test.ts +59 -0
- package/test/suite/profiles.test.ts +380 -0
- package/test/suite/projects.test.ts +550 -0
- package/test/suite/recovery.test.ts +348 -0
- package/test/suite/security.test.ts +199 -0
- package/test/suite/signin-fixtures.ts +204 -0
- package/test/suite/teams.test.ts +354 -0
- package/test/suite/whoami.test.ts +159 -0
- package/test/suite/workspace.test.ts +402 -0
- package/README.md +0 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { ChassisClient } from '@antasphere/chassis-sdk';
|
|
5
|
+
import type { CliIo } from '../context.js';
|
|
6
|
+
import type { Cli } from '../kit.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The test harness of a CLI built on this package, shared by the chassis suite
|
|
10
|
+
* and by the tool's own tests. `./testing` is a test-only entry: it is built
|
|
11
|
+
* (the tool's tests read it from `dist`) and kept out of the packed artefact
|
|
12
|
+
* (`files` in package.json) and out of the tool's bundle (nothing under a
|
|
13
|
+
* tool's `src` imports it).
|
|
14
|
+
*
|
|
15
|
+
* Router-style fake fetch: handlers matched by method + path regex, so
|
|
16
|
+
* order-insensitive flows (parallel uploads) stay assertable. Records every
|
|
17
|
+
* call with its parsed body (JSON / FormData summary).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface RecordedCall {
|
|
21
|
+
method: string;
|
|
22
|
+
path: string;
|
|
23
|
+
body?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The full wire view of a call — origin + authorization header included.
|
|
28
|
+
* Kept in a SEPARATE array (`wire`) so the legacy strict `toEqual` asserts
|
|
29
|
+
* on `calls` keep their exact shape; the connect tests use this to prove
|
|
30
|
+
* which credential went to which host.
|
|
31
|
+
*/
|
|
32
|
+
export interface WireCall {
|
|
33
|
+
method: string;
|
|
34
|
+
origin: string;
|
|
35
|
+
path: string;
|
|
36
|
+
auth: string | undefined;
|
|
37
|
+
/** The `x-workspace-id` header, present only when the request carried one. */
|
|
38
|
+
workspace?: string;
|
|
39
|
+
body?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Route {
|
|
43
|
+
method: string;
|
|
44
|
+
path: RegExp;
|
|
45
|
+
reply: (call: { path: string; body?: unknown; form?: FormData; headers: Headers }) => {
|
|
46
|
+
status?: number;
|
|
47
|
+
body?: unknown;
|
|
48
|
+
raw?: Response;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function routedHarness(routes: Route[], env: Record<string, string | undefined> = {}) {
|
|
53
|
+
const calls: RecordedCall[] = [];
|
|
54
|
+
const wire: WireCall[] = [];
|
|
55
|
+
const out: string[] = [];
|
|
56
|
+
const err: string[] = [];
|
|
57
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
58
|
+
const url = new URL(input.toString());
|
|
59
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
60
|
+
const path = url.pathname + url.search;
|
|
61
|
+
let body: unknown;
|
|
62
|
+
let form: FormData | undefined;
|
|
63
|
+
if (typeof init?.body === 'string') {
|
|
64
|
+
try {
|
|
65
|
+
body = JSON.parse(init.body);
|
|
66
|
+
} catch {
|
|
67
|
+
body = init.body;
|
|
68
|
+
}
|
|
69
|
+
} else if (init?.body instanceof FormData) {
|
|
70
|
+
form = init.body;
|
|
71
|
+
body = { sha256: form.get('sha256') };
|
|
72
|
+
}
|
|
73
|
+
calls.push({ method, path, ...(body !== undefined ? { body } : {}) });
|
|
74
|
+
const headers = new Headers(init?.headers);
|
|
75
|
+
const auth = headers.get('authorization') ?? undefined;
|
|
76
|
+
const workspace = headers.get('x-workspace-id') ?? undefined;
|
|
77
|
+
wire.push({
|
|
78
|
+
method,
|
|
79
|
+
origin: url.origin,
|
|
80
|
+
path,
|
|
81
|
+
auth,
|
|
82
|
+
...(workspace !== undefined ? { workspace } : {}),
|
|
83
|
+
...(body !== undefined ? { body } : {})
|
|
84
|
+
});
|
|
85
|
+
const route = routes.find((r) => r.method === method && r.path.test(url.pathname));
|
|
86
|
+
if (!route) {
|
|
87
|
+
return new Response(
|
|
88
|
+
JSON.stringify({ error: { code: 'not_found', message: `no fake route ${method} ${path}` } }),
|
|
89
|
+
{
|
|
90
|
+
status: 404,
|
|
91
|
+
headers: { 'content-type': 'application/json' }
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const result = route.reply({ path, body, headers, ...(form ? { form } : {}) });
|
|
96
|
+
if (result.raw) return result.raw;
|
|
97
|
+
return new Response(JSON.stringify(result.body ?? {}), {
|
|
98
|
+
status: result.status ?? 200,
|
|
99
|
+
headers: { 'content-type': 'application/json' }
|
|
100
|
+
});
|
|
101
|
+
}) as typeof globalThis.fetch;
|
|
102
|
+
|
|
103
|
+
const io: CliIo = {
|
|
104
|
+
env,
|
|
105
|
+
out: { write: (s) => out.push(s) },
|
|
106
|
+
err: { write: (s) => err.push(s) },
|
|
107
|
+
fetch
|
|
108
|
+
};
|
|
109
|
+
return { io, calls, wire, out: () => out.join(''), err: () => err.join('') };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A temp HOME-like config root, isolated per test. */
|
|
113
|
+
export async function tempConfigEnv(): Promise<Record<string, string>> {
|
|
114
|
+
const dir = await mkdtemp(join(tmpdir(), 'cli-cfg-'));
|
|
115
|
+
return { XDG_CONFIG_HOME: dir };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What the chassis suite (`test/suite`) reads from `@chassis-cli-test/host`:
|
|
120
|
+
* one instantiated CLI and its runner, with the tool's groups and version
|
|
121
|
+
* already bound. The chassis package aliases the specifier to its minimal
|
|
122
|
+
* `things` tool; a tool's own CLI package aliases it to its real kit, and the
|
|
123
|
+
* same files run a second time there. Every literal that spells the tool in
|
|
124
|
+
* the suite is read from `cli.identity`.
|
|
125
|
+
*/
|
|
126
|
+
export interface CliTestHost<TClient extends ChassisClient<string>> {
|
|
127
|
+
cli: Cli<TClient>;
|
|
128
|
+
run(argv: string[], io: CliIo): Promise<number>;
|
|
129
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { CliUsageError } from '@antasphere/cli-core';
|
|
2
|
+
import type { ChassisClient } from '@antasphere/chassis-sdk';
|
|
3
|
+
import type { CliEnv } from './config.js';
|
|
4
|
+
import type { CliIdentity } from './identity.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which workspace a command runs in (PRDCT-2419, PRDCT-2947).
|
|
8
|
+
*
|
|
9
|
+
* A credential identifies a USER (ADR 014, ADR 019): the workspace is a
|
|
10
|
+
* per-request `X-Workspace-Id`, and the SDK sends it once told which. There
|
|
11
|
+
* is ONE organization notion in the tool family: the server's default
|
|
12
|
+
* organization applies when a command names none, and a command may name
|
|
13
|
+
* one of two ways:
|
|
14
|
+
*
|
|
15
|
+
* --org <hub organization id or name> or <PREFIX>_ORG
|
|
16
|
+
* --workspace <workspace id or name> or <PREFIX>_WORKSPACE
|
|
17
|
+
*
|
|
18
|
+
* `--org` names the organization the Antasphere way (the id the account
|
|
19
|
+
* site shows, or its name); the instance maps a hub organization id to its
|
|
20
|
+
* own workspace through `central_account_id`. `--workspace` names a
|
|
21
|
+
* workspace of this instance (a self-hosted instance has no hub ids). Both
|
|
22
|
+
* at once is a usage error; a flag wins over its variable. Nothing is saved
|
|
23
|
+
* on this machine: the default is the server's (`workspace default` on a
|
|
24
|
+
* self-hosted instance, the Antasphere account on the cloud).
|
|
25
|
+
*
|
|
26
|
+
* A value that is a uuid is sent as it is (the server accepts a local id or a
|
|
27
|
+
* hub organization id, and fails closed on one that is not the person's); a
|
|
28
|
+
* name is looked up in `/me`'s `workspaces[]` first, by id, by hub
|
|
29
|
+
* organization id, then by name. Pure functions only: the wiring (the client,
|
|
30
|
+
* the header) lives in context.ts.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** `/me`'s answer, read off the client (the SDK does not export the type by name). */
|
|
34
|
+
export type MeResponse = Awaited<ReturnType<ChassisClient<string>['me']>>;
|
|
35
|
+
export type MeWorkspace = MeResponse['workspaces'][number];
|
|
36
|
+
|
|
37
|
+
/** Which flag or variable named the selection. */
|
|
38
|
+
export type SelectionKind = 'org' | 'workspace';
|
|
39
|
+
|
|
40
|
+
/** Where the workspace of this invocation came from; `default` = nothing selected. */
|
|
41
|
+
export type WorkspaceSource = 'flag' | 'env' | 'default';
|
|
42
|
+
|
|
43
|
+
export interface WorkspaceSelection {
|
|
44
|
+
/** What the person wrote: an id or a name, trimmed. */
|
|
45
|
+
value: string;
|
|
46
|
+
source: Exclude<WorkspaceSource, 'default'>;
|
|
47
|
+
/** `--org` / `<PREFIX>_ORG`, or `--workspace` / `<PREFIX>_WORKSPACE`. */
|
|
48
|
+
kind: SelectionKind;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The server's own selector shape (resolve-membership.ts): anything else is a name. */
|
|
52
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
53
|
+
|
|
54
|
+
export function isWorkspaceId(value: string): boolean {
|
|
55
|
+
return UUID_RE.test(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The hub organization id of a listed workspace, when it projects one (an older server sends none). */
|
|
59
|
+
function orgIdOf(w: MeWorkspace): string | null {
|
|
60
|
+
const id = (w as { centralAccountId?: unknown }).centralAccountId;
|
|
61
|
+
return typeof id === 'string' && id ? id : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One line per workspace, for the error messages that list the candidates. */
|
|
65
|
+
export function workspaceLines(workspaces: readonly MeWorkspace[]): string {
|
|
66
|
+
if (workspaces.length === 0) return ' (none)';
|
|
67
|
+
return workspaces
|
|
68
|
+
.map((w) => {
|
|
69
|
+
const org = orgIdOf(w);
|
|
70
|
+
return ` ${w.id} ${w.role.padEnd(6)} ${w.name}${org ? ` (organization ${org})` : ''}`;
|
|
71
|
+
})
|
|
72
|
+
.join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The membership a value names, or null: the exact workspace id first, then
|
|
77
|
+
* the exact hub organization id, else the ONE workspace whose name matches
|
|
78
|
+
* without regard to case. `ambiguous` carries the several a name matched.
|
|
79
|
+
*/
|
|
80
|
+
export function findWorkspace(
|
|
81
|
+
workspaces: readonly MeWorkspace[],
|
|
82
|
+
value: string
|
|
83
|
+
): { match: MeWorkspace | null; ambiguous: MeWorkspace[] } {
|
|
84
|
+
const wanted = value.trim().toLowerCase();
|
|
85
|
+
const byId = workspaces.find((w) => w.id.toLowerCase() === wanted);
|
|
86
|
+
if (byId) return { match: byId, ambiguous: [] };
|
|
87
|
+
const byOrg = workspaces.find((w) => orgIdOf(w)?.toLowerCase() === wanted);
|
|
88
|
+
if (byOrg) return { match: byOrg, ambiguous: [] };
|
|
89
|
+
const byName = workspaces.filter((w) => w.name.trim().toLowerCase() === wanted);
|
|
90
|
+
if (byName.length === 1) return { match: byName[0]!, ambiguous: [] };
|
|
91
|
+
return { match: null, ambiguous: byName };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `findWorkspace`, erroring with the candidates on an unknown or ambiguous value. */
|
|
95
|
+
export function matchWorkspace(workspaces: readonly MeWorkspace[], value: string): MeWorkspace {
|
|
96
|
+
const { match, ambiguous } = findWorkspace(workspaces, value);
|
|
97
|
+
if (match) return match;
|
|
98
|
+
if (ambiguous.length > 1) {
|
|
99
|
+
throw new CliUsageError(
|
|
100
|
+
`Several of your workspaces are named "${value}". Name one by its id:\n${workspaceLines(ambiguous)}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
throw new CliUsageError(`"${value}" is not one of your workspaces. Yours:\n${workspaceLines(workspaces)}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The workspace functions that spell the tool: its environment variables, its binary. */
|
|
107
|
+
export interface CliWorkspace {
|
|
108
|
+
WORKSPACE_ENV: string;
|
|
109
|
+
ORG_ENV: string;
|
|
110
|
+
pickWorkspaceSelection(input: {
|
|
111
|
+
workspaceFlag: string | undefined;
|
|
112
|
+
orgFlag: string | undefined;
|
|
113
|
+
env: CliEnv;
|
|
114
|
+
}): WorkspaceSelection | undefined;
|
|
115
|
+
describeSelection(selection: Pick<WorkspaceSelection, 'source' | 'kind'>): string;
|
|
116
|
+
describeSource(source: WorkspaceSource, kind: SelectionKind | undefined): string;
|
|
117
|
+
explainRefusal(input: {
|
|
118
|
+
code: string;
|
|
119
|
+
status: number;
|
|
120
|
+
selection: WorkspaceSelection;
|
|
121
|
+
workspaceId: string;
|
|
122
|
+
me: MeResponse;
|
|
123
|
+
baseUrl: string;
|
|
124
|
+
}): string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createWorkspace(identity: CliIdentity): CliWorkspace {
|
|
128
|
+
const WORKSPACE_ENV = `${identity.envPrefix}_WORKSPACE`;
|
|
129
|
+
const ORG_ENV = `${identity.envPrefix}_ORG`;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The selection of this invocation, or undefined when nothing selects one.
|
|
133
|
+
*
|
|
134
|
+
* An empty `--org` or `--workspace` is a usage error (somebody meant to
|
|
135
|
+
* name one); an empty variable is an unset one (a CI variable that
|
|
136
|
+
* expanded to nothing) and falls through. Two flags, or two variables, at
|
|
137
|
+
* once is a usage error: one command runs in one place.
|
|
138
|
+
*/
|
|
139
|
+
function pickWorkspaceSelection(input: {
|
|
140
|
+
workspaceFlag: string | undefined;
|
|
141
|
+
orgFlag: string | undefined;
|
|
142
|
+
env: CliEnv;
|
|
143
|
+
}): WorkspaceSelection | undefined {
|
|
144
|
+
if (input.orgFlag !== undefined && input.workspaceFlag !== undefined) {
|
|
145
|
+
throw new CliUsageError('Pass --org or --workspace, not both.');
|
|
146
|
+
}
|
|
147
|
+
if (input.orgFlag !== undefined) {
|
|
148
|
+
const value = input.orgFlag.trim();
|
|
149
|
+
if (!value) throw new CliUsageError('--org needs an organization id or name.');
|
|
150
|
+
return { value, source: 'flag', kind: 'org' };
|
|
151
|
+
}
|
|
152
|
+
if (input.workspaceFlag !== undefined) {
|
|
153
|
+
const value = input.workspaceFlag.trim();
|
|
154
|
+
if (!value) throw new CliUsageError('--workspace needs a workspace id or name.');
|
|
155
|
+
return { value, source: 'flag', kind: 'workspace' };
|
|
156
|
+
}
|
|
157
|
+
const org = input.env[ORG_ENV]?.trim();
|
|
158
|
+
const workspace = input.env[WORKSPACE_ENV]?.trim();
|
|
159
|
+
if (org && workspace) {
|
|
160
|
+
throw new CliUsageError(`Set ${ORG_ENV} or ${WORKSPACE_ENV}, not both.`);
|
|
161
|
+
}
|
|
162
|
+
if (org) return { value: org, source: 'env', kind: 'org' };
|
|
163
|
+
if (workspace) return { value: workspace, source: 'env', kind: 'workspace' };
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** How a selection reads in a sentence: `the --org flag`, `<PREFIX>_WORKSPACE`. */
|
|
168
|
+
function describeSelection(selection: Pick<WorkspaceSelection, 'source' | 'kind'>): string {
|
|
169
|
+
if (selection.source === 'flag')
|
|
170
|
+
return selection.kind === 'org' ? 'the --org flag' : 'the --workspace flag';
|
|
171
|
+
return selection.kind === 'org' ? ORG_ENV : WORKSPACE_ENV;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The same, for `whoami`'s `chosen by` line. */
|
|
175
|
+
function describeSource(source: WorkspaceSource, kind: SelectionKind | undefined): string {
|
|
176
|
+
if (source === 'default' || kind === undefined) return "the server's default (nothing selected)";
|
|
177
|
+
return describeSelection({ source, kind });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What a refused request really meant, once `/me` answered WITHOUT the
|
|
182
|
+
* selection (so the key itself is good). Two refusals are the selection's:
|
|
183
|
+
*
|
|
184
|
+
* - 403 `workspace_mismatch`: the key is pinned, and a selector-less `/me`
|
|
185
|
+
* always resolves a pinned key to its pin — so `me.workspace` IS the pin.
|
|
186
|
+
* - 401 `invalid_api_key`: the server answers a workspace the person does
|
|
187
|
+
* not belong to exactly like an unknown key (no oracle, resolve-
|
|
188
|
+
* membership.ts), which alone would send the person to check a good key.
|
|
189
|
+
*
|
|
190
|
+
* null = the selection does not explain it; the caller prints the plain error.
|
|
191
|
+
*/
|
|
192
|
+
function explainRefusal(input: {
|
|
193
|
+
code: string;
|
|
194
|
+
status: number;
|
|
195
|
+
selection: WorkspaceSelection;
|
|
196
|
+
workspaceId: string;
|
|
197
|
+
me: MeResponse;
|
|
198
|
+
baseUrl: string;
|
|
199
|
+
}): string | null {
|
|
200
|
+
const { selection, me } = input;
|
|
201
|
+
const by = describeSelection(selection);
|
|
202
|
+
const noun = selection.kind === 'org' ? 'organization' : 'workspace';
|
|
203
|
+
const selected = findWorkspace(me.workspaces, input.workspaceId).match;
|
|
204
|
+
if (input.code === 'workspace_mismatch' && input.status === 403) {
|
|
205
|
+
const pin = me.workspace ? `"${me.workspace.name}" (${me.workspace.id})` : 'another workspace';
|
|
206
|
+
const asked = selected ? `"${selected.name}" (${selected.id})` : `"${selection.value}"`;
|
|
207
|
+
return (
|
|
208
|
+
`This API key is pinned to the workspace ${pin}, and ${by} selects ${asked}. ` +
|
|
209
|
+
'A pinned key only ever acts in its own workspace: drop the selection, or use a key that is not pinned.'
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (input.code === 'invalid_api_key' && input.status === 401 && !selected) {
|
|
213
|
+
return (
|
|
214
|
+
`The ${noun} "${selection.value}" (selected by ${by}) is not one of yours on ${input.baseUrl}; ` +
|
|
215
|
+
`the API key itself works. Yours:\n${workspaceLines(me.workspaces)}`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
WORKSPACE_ENV,
|
|
223
|
+
ORG_ENV,
|
|
224
|
+
pickWorkspaceSelection,
|
|
225
|
+
describeSelection,
|
|
226
|
+
describeSource,
|
|
227
|
+
explainRefusal
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { routedHarness, tempConfigEnv, type Route } from '@antasphere/chassis-cli/testing';
|
|
5
|
+
import { run } from './minimal-host.js';
|
|
6
|
+
import { cli, registerTool, VERSION } from './minimal-tool.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The minimal `things` tool, pinned by RAW BYTES (PRDCT-2530, verifier F-4 /
|
|
10
|
+
* F-2 / M18). This file is HOST-SPECIFIC: it sits beside the host, outside
|
|
11
|
+
* `test/suite`, so it runs in this package only and never under a tool.
|
|
12
|
+
*
|
|
13
|
+
* The suite reads the identity from the kit under test, so it cannot tell a
|
|
14
|
+
* wrong identity VALUE from a right one. Here nothing reads `cli.identity`:
|
|
15
|
+
* every expectation is a literal ('things', 'Things', 'THINGS', 'thk'), which
|
|
16
|
+
* proves from the chassis side that the six parameters are live. The tool's
|
|
17
|
+
* CLI package carries the mirror with its own literals
|
|
18
|
+
* (`packages/cli/test/identity.test.ts`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const CLOUD_INSTANCE = {
|
|
22
|
+
name: 'Things Cloud',
|
|
23
|
+
instanceId: 'i1',
|
|
24
|
+
edition: 'cloud',
|
|
25
|
+
version: '1.0.0',
|
|
26
|
+
apiVersion: 'v1',
|
|
27
|
+
setupRequired: false,
|
|
28
|
+
auth: {
|
|
29
|
+
methods: ['antasphere', 'api-key', 'oauth'],
|
|
30
|
+
passwordReset: false,
|
|
31
|
+
emailChange: false,
|
|
32
|
+
twoFactor: false
|
|
33
|
+
},
|
|
34
|
+
features: { mcp: true, oauth: true, files: true }
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ME = {
|
|
38
|
+
user: { id: 'u1', name: 'Ada', email: 'ada@x.co' },
|
|
39
|
+
workspace: { id: 'w1', name: 'Acme' },
|
|
40
|
+
workspaces: [
|
|
41
|
+
{
|
|
42
|
+
id: 'w1',
|
|
43
|
+
name: 'Acme',
|
|
44
|
+
role: 'owner',
|
|
45
|
+
hubOrigin: false,
|
|
46
|
+
centralAccountId: null,
|
|
47
|
+
look: { theme: null, pattern: null, field: null, grain: null },
|
|
48
|
+
suspended: false,
|
|
49
|
+
default: true
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
activeWorkspaceId: 'w1',
|
|
53
|
+
role: 'owner',
|
|
54
|
+
via: 'api_key',
|
|
55
|
+
scopes: ['items:read'],
|
|
56
|
+
apiKeyExpiresAt: null
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const meRoute: Route = { method: 'GET', path: /\/api\/v1\/me$/, reply: () => ({ body: ME }) };
|
|
60
|
+
|
|
61
|
+
const LEGACY = {
|
|
62
|
+
activeProfile: 'work',
|
|
63
|
+
profiles: { work: { apiKey: 'thk_abcdefgh_0123456789abcdef', baseUrl: 'http://legacy-inst' } }
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe('the `things` identity, by its observable bytes', () => {
|
|
67
|
+
it('legacyConfigDir: a config left at $XDG_CONFIG_HOME/things/config.json is imported', async () => {
|
|
68
|
+
const env = await tempConfigEnv();
|
|
69
|
+
const dir = join(env.XDG_CONFIG_HOME!, 'things');
|
|
70
|
+
await mkdir(dir, { recursive: true });
|
|
71
|
+
await writeFile(join(dir, 'config.json'), JSON.stringify(LEGACY, null, 2));
|
|
72
|
+
const h = routedHarness([meRoute], env);
|
|
73
|
+
const code = await run(['whoami'], h.io);
|
|
74
|
+
expect(h.err()).toBe('');
|
|
75
|
+
expect(code).toBe(0);
|
|
76
|
+
expect(h.out()).toContain('http://legacy-inst');
|
|
77
|
+
expect(h.wire).toEqual([
|
|
78
|
+
{
|
|
79
|
+
method: 'GET',
|
|
80
|
+
origin: 'http://legacy-inst',
|
|
81
|
+
path: '/api/v1/me',
|
|
82
|
+
auth: 'Bearer thk_abcdefgh_0123456789abcdef'
|
|
83
|
+
}
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('tool: the profiles live in $XDG_CONFIG_HOME/antasphere/tools/things.json', async () => {
|
|
88
|
+
const env = await tempConfigEnv();
|
|
89
|
+
const dir = join(env.XDG_CONFIG_HOME!, 'things');
|
|
90
|
+
await mkdir(dir, { recursive: true });
|
|
91
|
+
await writeFile(join(dir, 'config.json'), JSON.stringify(LEGACY, null, 2));
|
|
92
|
+
const h = routedHarness([], env);
|
|
93
|
+
expect(await run(['profiles'], h.io)).toBe(0);
|
|
94
|
+
const saved = await readFile(join(env.XDG_CONFIG_HOME!, 'antasphere', 'tools', 'things.json'), 'utf8');
|
|
95
|
+
expect(JSON.parse(saved)).toEqual(LEGACY);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('bin: the program is called `things`', async () => {
|
|
99
|
+
const h = routedHarness([]);
|
|
100
|
+
expect(await run(['--help'], h.io)).toBe(0);
|
|
101
|
+
expect(h.out().split('\n')[0]).toBe('Usage: things [options] [command]');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('envPrefix: THINGS_URL and THINGS_API_KEY are the variables that are read', async () => {
|
|
105
|
+
const env = await tempConfigEnv();
|
|
106
|
+
const h = routedHarness([meRoute], {
|
|
107
|
+
...env,
|
|
108
|
+
THINGS_URL: 'http://from-env',
|
|
109
|
+
THINGS_API_KEY: 'thk_envkey12_0123456789abcdef'
|
|
110
|
+
});
|
|
111
|
+
expect(await run(['whoami'], h.io)).toBe(0);
|
|
112
|
+
expect(h.wire).toEqual([
|
|
113
|
+
{
|
|
114
|
+
method: 'GET',
|
|
115
|
+
origin: 'http://from-env',
|
|
116
|
+
path: '/api/v1/me',
|
|
117
|
+
auth: 'Bearer thk_envkey12_0123456789abcdef'
|
|
118
|
+
}
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('displayName, keyPrefix, envPrefix: the missing-hub-login sentence, whole', async () => {
|
|
123
|
+
const env = await tempConfigEnv();
|
|
124
|
+
const h = routedHarness(
|
|
125
|
+
[{ method: 'GET', path: /\/api\/v1\/instance$/, reply: () => ({ body: CLOUD_INSTANCE }) }],
|
|
126
|
+
env
|
|
127
|
+
);
|
|
128
|
+
expect(await run(['files', 'list', '--api-url', 'http://tool'], h.io)).toBe(1);
|
|
129
|
+
expect(h.err()).toBe(
|
|
130
|
+
'Error: This Things instance signs in through Antasphere. Run `things login` once (or `antasphere login`), ' +
|
|
131
|
+
'then retry — or pass --api-key <thk_…> / set THINGS_API_KEY.\n'
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('bin, envPrefix: the no-key sentence, whole', async () => {
|
|
136
|
+
const env = await tempConfigEnv();
|
|
137
|
+
const h = routedHarness([], env);
|
|
138
|
+
expect(await run(['files', 'list', '--api-url', 'http://x'], h.io)).toBe(1);
|
|
139
|
+
expect(h.err()).toBe(
|
|
140
|
+
'Error: An API key is required. Sign in (`things login`), pass --api-key, or set THINGS_API_KEY.\n'
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('cloudUrl: with nothing configured, a command probes https://things.antasphere.test', async () => {
|
|
145
|
+
const env = await tempConfigEnv();
|
|
146
|
+
const h = routedHarness([], env);
|
|
147
|
+
expect(await run(['files', 'list'], h.io)).toBe(1);
|
|
148
|
+
expect(h.wire).toEqual([
|
|
149
|
+
{
|
|
150
|
+
method: 'GET',
|
|
151
|
+
origin: 'https://things.antasphere.test',
|
|
152
|
+
path: '/api/v1/instance',
|
|
153
|
+
auth: undefined
|
|
154
|
+
}
|
|
155
|
+
]);
|
|
156
|
+
expect(h.err()).toBe(
|
|
157
|
+
'Error: An API key is required. Sign in (`things login`), pass --api-key, or set THINGS_API_KEY.\n'
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('bin: `things auth login-request` no longer exists', async () => {
|
|
162
|
+
const h = routedHarness([]);
|
|
163
|
+
expect(await run(['auth', 'login-request', '--email', 'a@x.co'], h.io)).toBe(1);
|
|
164
|
+
expect(h.err()).toContain("unknown command 'auth'");
|
|
165
|
+
expect(h.wire).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('bin again: the 401 hint names `things verify` (M18)', async () => {
|
|
169
|
+
const h = routedHarness([
|
|
170
|
+
{
|
|
171
|
+
method: 'GET',
|
|
172
|
+
path: /\/api\/v1\/me$/,
|
|
173
|
+
reply: () => ({ status: 401, body: { error: { code: 'invalid_api_key', message: 'bad key' } } })
|
|
174
|
+
}
|
|
175
|
+
]);
|
|
176
|
+
expect(await run(['whoami', '--url', 'http://x', '--api-key', 'thk_k_s'], h.io)).toBe(1);
|
|
177
|
+
expect(h.err()).toBe('Error: bad key (check the key: `things verify`)\n');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** Every top-level command, in the order `--help` and the completion scripts print them. */
|
|
182
|
+
const COMMANDS = [
|
|
183
|
+
// generic: identity and profiles, then the workspace selection
|
|
184
|
+
'login',
|
|
185
|
+
'logout',
|
|
186
|
+
'whoami',
|
|
187
|
+
'verify',
|
|
188
|
+
'use',
|
|
189
|
+
'profiles',
|
|
190
|
+
'config',
|
|
191
|
+
'workspaces',
|
|
192
|
+
'workspace',
|
|
193
|
+
// generic: the subgroups of the workspace, its named groups of people, then
|
|
194
|
+
// the owner's demo links
|
|
195
|
+
'projects',
|
|
196
|
+
'teams',
|
|
197
|
+
'demo',
|
|
198
|
+
// the tool's own group, at the seam
|
|
199
|
+
'things',
|
|
200
|
+
// generic: the platform substrate, then completion LAST
|
|
201
|
+
'instance',
|
|
202
|
+
'export',
|
|
203
|
+
'files',
|
|
204
|
+
'completion'
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
describe('the order of the top-level commands (F-2)', () => {
|
|
208
|
+
it('the built program registers them in this order', () => {
|
|
209
|
+
const h = routedHarness([]);
|
|
210
|
+
expect(cli.buildProgram(h.io, registerTool, VERSION).commands.map((c) => c.name())).toEqual(COMMANDS);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('`--help` lists them in this order, `help` last', async () => {
|
|
214
|
+
const h = routedHarness([]);
|
|
215
|
+
expect(await run(['--help'], h.io)).toBe(0);
|
|
216
|
+
const block = h.out().split('Commands:\n')[1]!;
|
|
217
|
+
const names = block
|
|
218
|
+
.split('\n')
|
|
219
|
+
.filter((line) => /^ {2}\S/.test(line))
|
|
220
|
+
.map((line) => line.trim().split(/[ |]/)[0]);
|
|
221
|
+
expect(names).toEqual([...COMMANDS, 'help']);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('`completion bash` offers them in this order', async () => {
|
|
225
|
+
const h = routedHarness([]);
|
|
226
|
+
expect(await run(['completion', 'bash'], h.io)).toBe(0);
|
|
227
|
+
expect(h.out()).toContain(`compgen -W "${COMMANDS.join(' ')}" -- "$cur"`);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ChassisClient } from '@antasphere/chassis-sdk';
|
|
2
|
+
import type { CliIo } from '@antasphere/chassis-cli';
|
|
3
|
+
import type { CliTestHost } from '@antasphere/chassis-cli/testing';
|
|
4
|
+
import { cli as thingsCli, registerTool, VERSION, type ThingsScope } from './minimal-tool.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The host of the chassis suite in THIS package: the minimal `things` tool.
|
|
8
|
+
* `@chassis-cli-test/host` resolves here under `vitest.config.ts`; the tool's
|
|
9
|
+
* CLI package aliases the same specifier to its own kit.
|
|
10
|
+
*/
|
|
11
|
+
const host: CliTestHost<ChassisClient<ThingsScope>> = {
|
|
12
|
+
cli: thingsCli,
|
|
13
|
+
run: (argv: string[], io: CliIo) => thingsCli.run(argv, io, registerTool, VERSION)
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const { cli, run } = host;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ChassisClient } from '@antasphere/chassis-sdk';
|
|
2
|
+
import { defineCli, type CliIo } from '@antasphere/chassis-cli';
|
|
3
|
+
import type { Command } from 'commander';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The minimal tool of the chassis suite: another identity than any product's
|
|
7
|
+
* (so a literal left behind in the generic CLI shows), the bare chassis client
|
|
8
|
+
* over its own scope names, and ONE trivial command group registered at the
|
|
9
|
+
* seam, so the tool's positional hook in the program is exercised on every
|
|
10
|
+
* run. It imports nothing from a tool.
|
|
11
|
+
*/
|
|
12
|
+
export type ThingsScope = 'things:read' | 'things:write' | 'things:export';
|
|
13
|
+
|
|
14
|
+
export const VERSION = '0.0.1';
|
|
15
|
+
|
|
16
|
+
export const cli = defineCli({
|
|
17
|
+
identity: {
|
|
18
|
+
bin: 'things',
|
|
19
|
+
tool: 'things',
|
|
20
|
+
legacyConfigDir: 'things',
|
|
21
|
+
displayName: 'Things',
|
|
22
|
+
envPrefix: 'THINGS',
|
|
23
|
+
keyPrefix: 'thk',
|
|
24
|
+
exportScope: 'things:export',
|
|
25
|
+
cloudUrl: 'https://things.antasphere.test'
|
|
26
|
+
},
|
|
27
|
+
description: 'Command-line client for a Things instance',
|
|
28
|
+
createClient: (options) => new ChassisClient<ThingsScope>(options)
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** The tool's one group: `things list`, a command that touches nothing. */
|
|
32
|
+
export function registerTool(program: Command, io: CliIo): void {
|
|
33
|
+
const things = program.command('things').description('the things of this tool');
|
|
34
|
+
things
|
|
35
|
+
.command('list')
|
|
36
|
+
.description('list the things')
|
|
37
|
+
.action(() => {
|
|
38
|
+
io.out.write('No things.\n');
|
|
39
|
+
});
|
|
40
|
+
}
|