@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.
Files changed (117) hide show
  1. package/LICENSE +82 -0
  2. package/dist/commands/auth.d.ts +6 -0
  3. package/dist/commands/auth.d.ts.map +1 -0
  4. package/dist/commands/auth.js +582 -0
  5. package/dist/commands/auth.js.map +1 -0
  6. package/dist/commands/completion.d.ts +5 -0
  7. package/dist/commands/completion.d.ts.map +1 -0
  8. package/dist/commands/completion.js +85 -0
  9. package/dist/commands/completion.js.map +1 -0
  10. package/dist/commands/demo.d.ts +12 -0
  11. package/dist/commands/demo.d.ts.map +1 -0
  12. package/dist/commands/demo.js +210 -0
  13. package/dist/commands/demo.js.map +1 -0
  14. package/dist/commands/files.d.ts +21 -0
  15. package/dist/commands/files.d.ts.map +1 -0
  16. package/dist/commands/files.js +178 -0
  17. package/dist/commands/files.js.map +1 -0
  18. package/dist/commands/projects.d.ts +38 -0
  19. package/dist/commands/projects.d.ts.map +1 -0
  20. package/dist/commands/projects.js +386 -0
  21. package/dist/commands/projects.js.map +1 -0
  22. package/dist/commands/teams.d.ts +46 -0
  23. package/dist/commands/teams.d.ts.map +1 -0
  24. package/dist/commands/teams.js +265 -0
  25. package/dist/commands/teams.js.map +1 -0
  26. package/dist/commands/workspaces.d.ts +22 -0
  27. package/dist/commands/workspaces.d.ts.map +1 -0
  28. package/dist/commands/workspaces.js +116 -0
  29. package/dist/commands/workspaces.js.map +1 -0
  30. package/dist/config.d.ts +29 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +54 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/context.d.ts +152 -0
  35. package/dist/context.d.ts.map +1 -0
  36. package/dist/context.js +456 -0
  37. package/dist/context.js.map +1 -0
  38. package/dist/identity.d.ts +36 -0
  39. package/dist/identity.d.ts.map +1 -0
  40. package/dist/identity.js +18 -0
  41. package/dist/identity.js.map +1 -0
  42. package/dist/index.d.ts +24 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +31 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/kit.d.ts +46 -0
  47. package/dist/kit.d.ts.map +1 -0
  48. package/dist/kit.js +2 -0
  49. package/dist/kit.js.map +1 -0
  50. package/dist/open.d.ts +27 -0
  51. package/dist/open.d.ts.map +1 -0
  52. package/dist/open.js +45 -0
  53. package/dist/open.js.map +1 -0
  54. package/dist/owner-session.d.ts +34 -0
  55. package/dist/owner-session.d.ts.map +1 -0
  56. package/dist/owner-session.js +106 -0
  57. package/dist/owner-session.js.map +1 -0
  58. package/dist/program.d.ts +5 -0
  59. package/dist/program.d.ts.map +1 -0
  60. package/dist/program.js +150 -0
  61. package/dist/program.js.map +1 -0
  62. package/dist/safe-write.d.ts +46 -0
  63. package/dist/safe-write.d.ts.map +1 -0
  64. package/dist/safe-write.js +127 -0
  65. package/dist/safe-write.js.map +1 -0
  66. package/dist/stdin.d.ts +4 -0
  67. package/dist/stdin.d.ts.map +1 -0
  68. package/dist/stdin.js +36 -0
  69. package/dist/stdin.js.map +1 -0
  70. package/dist/testing/index.d.ts +70 -0
  71. package/dist/testing/index.d.ts.map +1 -0
  72. package/dist/testing/index.js +67 -0
  73. package/dist/testing/index.js.map +1 -0
  74. package/dist/workspace.d.ts +79 -0
  75. package/dist/workspace.d.ts.map +1 -0
  76. package/dist/workspace.js +139 -0
  77. package/dist/workspace.js.map +1 -0
  78. package/package.json +43 -3
  79. package/src/commands/auth.ts +687 -0
  80. package/src/commands/completion.ts +102 -0
  81. package/src/commands/demo.ts +271 -0
  82. package/src/commands/files.ts +197 -0
  83. package/src/commands/projects.ts +460 -0
  84. package/src/commands/teams.ts +320 -0
  85. package/src/commands/workspaces.ts +140 -0
  86. package/src/config.ts +81 -0
  87. package/src/context.ts +616 -0
  88. package/src/identity.ts +48 -0
  89. package/src/index.ts +70 -0
  90. package/src/kit.ts +50 -0
  91. package/src/open.ts +48 -0
  92. package/src/owner-session.ts +152 -0
  93. package/src/program.ts +183 -0
  94. package/src/safe-write.ts +136 -0
  95. package/src/stdin.ts +39 -0
  96. package/src/testing/index.ts +129 -0
  97. package/src/workspace.ts +229 -0
  98. package/test/host/identity.test.ts +229 -0
  99. package/test/host/minimal-host.ts +16 -0
  100. package/test/host/minimal-tool.ts +40 -0
  101. package/test/suite/cli.test.ts +301 -0
  102. package/test/suite/config.test.ts +59 -0
  103. package/test/suite/demo.test.ts +467 -0
  104. package/test/suite/hub-profile-shared.test.ts +123 -0
  105. package/test/suite/login.test.ts +743 -0
  106. package/test/suite/logout.test.ts +247 -0
  107. package/test/suite/migration.test.ts +109 -0
  108. package/test/suite/open.test.ts +59 -0
  109. package/test/suite/profiles.test.ts +380 -0
  110. package/test/suite/projects.test.ts +550 -0
  111. package/test/suite/recovery.test.ts +348 -0
  112. package/test/suite/security.test.ts +199 -0
  113. package/test/suite/signin-fixtures.ts +204 -0
  114. package/test/suite/teams.test.ts +354 -0
  115. package/test/suite/whoami.test.ts +159 -0
  116. package/test/suite/workspace.test.ts +402 -0
  117. 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
+ }
@@ -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
+ }