@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
package/src/context.ts ADDED
@@ -0,0 +1,616 @@
1
+ import { PlatformApiError, type ChassisClient, type ClientOptions } from '@antasphere/chassis-sdk';
2
+ import type { Command } from 'commander';
3
+ import {
4
+ activeHubProfile,
5
+ CLOUD_PROFILE,
6
+ CliAuthClient,
7
+ CliUsageError,
8
+ connectOnDemand,
9
+ lookupConnectKey,
10
+ printJson as corePrintJson,
11
+ refreshConnectKey,
12
+ resolveApiKey,
13
+ resolveBaseUrl,
14
+ sameInstance,
15
+ selectProfile,
16
+ type CliIo as CoreCliIo,
17
+ type ConnectOnDemandOptions,
18
+ type ProfileSource
19
+ } from '@antasphere/cli-core';
20
+ import type { CliConfig, CliConfigStore, CliProfile } from './config.js';
21
+ import type { CliIdentity } from './identity.js';
22
+ import {
23
+ isWorkspaceId,
24
+ matchWorkspace,
25
+ type CliWorkspace,
26
+ type WorkspaceSelection,
27
+ type WorkspaceSource
28
+ } from './workspace.js';
29
+
30
+ // The injectable I/O seam, the usage-error class, and the resolution
31
+ // helpers live in @antasphere/cli-core (extracted from this CLI); re-exported
32
+ // so the command modules' imports stay unchanged.
33
+ export { CliUsageError } from '@antasphere/cli-core';
34
+
35
+ /**
36
+ * A refusal of the API that a command turned into a sentence (the project
37
+ * verbs do), thrown as the usage error it reads as, with the wire status
38
+ * kept: the runner adds what only it knows, the workspace a 404 was asked of.
39
+ */
40
+ export class CliApiRefusal extends CliUsageError {
41
+ constructor(
42
+ message: string,
43
+ public readonly status: number
44
+ ) {
45
+ super(message);
46
+ this.name = 'CliApiRefusal';
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The tool's I/O seam: cli-core's, plus an injectable stdin reader so the
52
+ * `--*-stdin` secret flags (stdin.ts) stay testable in-process. The bin
53
+ * leaves it unset and the default reads `process.stdin`.
54
+ */
55
+ export interface CliIo extends CoreCliIo {
56
+ readStdin?: () => Promise<string>;
57
+ /**
58
+ * `process.stdout` carries `isTTY`; a pipe does not. The only signal the
59
+ * CLI reads before opening a browser unasked (open.ts): a piped run is a
60
+ * script or an agent, and a browser popping up on a build agent is a bug.
61
+ */
62
+ out: CoreCliIo['out'] & { isTTY?: boolean };
63
+ /**
64
+ * The browser opener seam. The bin leaves it unset and the default spawns
65
+ * the platform command (open.ts); tests inject a recorder.
66
+ */
67
+ openUrl?: (url: string) => void;
68
+ /**
69
+ * The wait seam. The bin leaves it unset and the default is a timer; tests
70
+ * inject a recorder, so a command that waits out a rate wall is tested
71
+ * without the wait.
72
+ */
73
+ sleep?: (ms: number) => Promise<void>;
74
+ }
75
+
76
+ // ── Terminal-control sanitation ──────────────────────────────────────────────
77
+
78
+ /**
79
+ * Strip terminal control sequences from human output.
80
+ *
81
+ * Almost everything the CLI prints in its human mode is content someone
82
+ * ELSE wrote: annotation bodies and author names from a share-link
83
+ * recipient, the titles of the tool's resources, form-response payloads, stored
84
+ * filenames, and server error messages. A terminal renders ANSI escapes in
85
+ * all of it — colours and cursor moves are the mild end; OSC 8 hyperlinks
86
+ * hide a URL behind friendly text, OSC 52 writes the user's clipboard, and
87
+ * a CSI sequence can scrub the line above so the owner never sees what the
88
+ * command really did.
89
+ *
90
+ * So the human sinks are wrapped (`ttySafeIo`) and everything they print
91
+ * loses C0 (except tab / LF / CR, which the tables and the CSV need), the
92
+ * 8-bit C1 introducers, and complete CSI/OSC/two-character escapes. `--json`
93
+ * output deliberately bypasses this: it is data, not display, and
94
+ * `JSON.stringify` already escapes every C0 byte.
95
+ */
96
+ export function sanitizeForTty(s: string): string {
97
+ /* eslint-disable no-control-regex -- matching control characters IS the job here. */
98
+ return (
99
+ s
100
+ // OSC: ESC ] … terminated by BEL, ST (ESC \\) or the end of the string.
101
+ .replace(/\u001b\][\s\S]*?(?:\u0007|\u001b\\|$)/g, '')
102
+ // CSI: ESC [ params intermediates final.
103
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
104
+ // Any other two-character escape (charset switches, RIS, …).
105
+ .replace(/\u001b[@-Z\\-_]/g, '')
106
+ // Whatever is left: stray C0 (ESC included), DEL, and the 8-bit C1
107
+ // block whose 0x9b / 0x9d are CSI / OSC on their own. Tab (0x09),
108
+ // LF (0x0a) and CR (0x0d) survive — tables and CSV are built of them.
109
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
110
+ );
111
+ /* eslint-enable no-control-regex */
112
+ }
113
+
114
+ /** Raw sink behind a sanitizing one, so `--json` can still print verbatim. */
115
+ const rawSinks = new WeakMap<CliIo, CliIo>();
116
+
117
+ /** Wrap an io so every human write is sanitized (see sanitizeForTty). */
118
+ export function ttySafeIo(io: CliIo): CliIo {
119
+ const safe: CliIo = {
120
+ ...io,
121
+ // `isTTY` rides along: the open decision reads it through the wrapper.
122
+ out: {
123
+ write: (s: string) => io.out.write(sanitizeForTty(s)),
124
+ ...(io.out.isTTY !== undefined ? { isTTY: io.out.isTTY } : {})
125
+ },
126
+ err: { write: (s: string) => io.err.write(sanitizeForTty(s)) }
127
+ };
128
+ rawSinks.set(safe, io);
129
+ return safe;
130
+ }
131
+
132
+ /**
133
+ * The `--api-key-stdin` value, parked against this invocation's io because
134
+ * `resolveContext` is synchronous and cannot read stdin itself.
135
+ */
136
+ const stdinApiKeys = new WeakMap<CliIo, string>();
137
+
138
+ export function setStdinApiKey(io: CliIo, key: string): void {
139
+ stdinApiKeys.set(io, key);
140
+ }
141
+
142
+ /** The key `--api-key-stdin` already read, if any. */
143
+ export function stdinApiKey(io: CliIo): string | undefined {
144
+ return stdinApiKeys.get(io);
145
+ }
146
+
147
+ /**
148
+ * `--json` output goes to the RAW sink: machine output must stay
149
+ * byte-exact, and JSON.stringify already escapes the C0 range.
150
+ */
151
+ export function printJson(io: CliIo, value: unknown): void {
152
+ corePrintJson(rawSinks.get(io) ?? io, value);
153
+ }
154
+
155
+ /**
156
+ * Where this invocation's credential came from, in the order it resolves:
157
+ * the flag (or `--api-key-stdin`), the tool's key variable, the profile's own
158
+ * key (the tool's login), the key the hub exchange cached on the profile, a
159
+ * fresh hub exchange run by this very command, or nothing yet.
160
+ */
161
+ export type CredentialSource = 'flag' | 'env' | 'profile' | 'hub-cache' | 'hub-exchange' | 'none';
162
+
163
+ export interface CliContext<TClient extends ChassisClient<string> = ChassisClient<string>> {
164
+ client: TClient;
165
+ baseUrl: string;
166
+ apiKey: string | undefined;
167
+ /** Where `apiKey` came from (`whoami` says it; the recovery reads it). */
168
+ credentialSource: CredentialSource;
169
+ json: boolean;
170
+ io: CliIo;
171
+ /**
172
+ * The profile this run selected (cli-core `selectProfile`: --profile, the
173
+ * instance URL, the active profile, the implicit `cloud` profile). Always
174
+ * named on a tool with a cloud URL; `profileCreated` says it is not on
175
+ * disk yet.
176
+ */
177
+ profileName: string | undefined;
178
+ profileSource: ProfileSource | undefined;
179
+ profileCreated: boolean;
180
+ config: CliConfig;
181
+ /**
182
+ * What selects the workspace (workspace.ts), undefined = the server's
183
+ * default. Resolved here, APPLIED by `requireApiKey`: a name needs `/me`,
184
+ * and `resolveContext` is synchronous.
185
+ */
186
+ workspaceSelection: WorkspaceSelection | undefined;
187
+ /** The id `requireApiKey` put on the client; undefined until then, or with no selection. */
188
+ workspaceId: string | undefined;
189
+ }
190
+
191
+ interface GlobalOpts {
192
+ apiUrl?: string;
193
+ url?: string;
194
+ apiKey?: string;
195
+ profile?: string;
196
+ workspace?: string;
197
+ org?: string;
198
+ json?: boolean;
199
+ }
200
+
201
+ /** The context whose selection reached the wire, for the runner's error path. */
202
+ const appliedContexts = new WeakMap<CliIo, CliContext>();
203
+
204
+ /** What `createContext` hands back: the context functions of ONE tool. */
205
+ export interface CliContextKit<TClient extends ChassisClient<string>> {
206
+ resolveProfile(
207
+ config: CliConfig,
208
+ requested: string | undefined
209
+ ): { name: string | undefined; profile: CliProfile | undefined };
210
+ resolveContext(cmd: Command, io: CliIo): CliContext<TClient>;
211
+ requireApiKey(ctx: CliContext<TClient>, opts?: { workspace?: boolean }): Promise<string>;
212
+ workspaceSource(ctx: CliContext<TClient>): WorkspaceSource;
213
+ explainWorkspaceRefusal(io: CliIo, e: unknown): Promise<string | null>;
214
+ workspaceNotFoundHint(io: CliIo): string;
215
+ /** The instance a profile names: its `baseUrl`, else the tool's cloud URL. */
216
+ profileUrl(profile: CliProfile | undefined): string;
217
+ /** The `connectOnDemand` options of one context (the tool, the instance, the profile, the wire). */
218
+ connectOptions(ctx: CliContext<TClient>): ConnectOnDemandOptions;
219
+ }
220
+
221
+ /**
222
+ * The context functions, bound to one tool: its identity (identity.ts), the
223
+ * client it talks through, and the config and workspace functions already
224
+ * bound to the same identity.
225
+ */
226
+ export function createContext<TClient extends ChassisClient<string>>(input: {
227
+ identity: CliIdentity;
228
+ createClient: (options: ClientOptions) => TClient;
229
+ config: Pick<CliConfigStore, 'loadConfig' | 'removeConnectKey'>;
230
+ workspace: CliWorkspace;
231
+ }): CliContextKit<TClient> {
232
+ const { identity, createClient } = input;
233
+ const { loadConfig, removeConnectKey } = input.config;
234
+ const { describeSelection, explainRefusal, pickWorkspaceSelection } = input.workspace;
235
+ const unknownHint = `run \`${identity.bin} profiles\` to list them.`;
236
+
237
+ /** The named (or active) profile, erroring on an explicitly named missing one. */
238
+ function resolveProfile(
239
+ config: CliConfig,
240
+ requested: string | undefined
241
+ ): { name: string | undefined; profile: CliProfile | undefined } {
242
+ const { name, profile } = selectProfile({ config, requested, cloudUrl: identity.cloudUrl, unknownHint });
243
+ return { name, profile };
244
+ }
245
+
246
+ function profileUrl(profile: CliProfile | undefined): string {
247
+ return profile?.baseUrl ?? identity.cloudUrl;
248
+ }
249
+
250
+ /**
251
+ * Backend + credential resolution, in its documented order:
252
+ *
253
+ * profile: --profile → the profile of --api-url / <PREFIX>_URL (made in
254
+ * memory, named for the host, when none is saved) → the active
255
+ * profile (`<bin> use`) → the implicit `cloud` profile
256
+ * base URL: --api-url (or --url) → <PREFIX>_URL → profile baseUrl → the
257
+ * tool's cloud URL
258
+ * API key: --api-key → <PREFIX>_API_KEY → profile apiKey
259
+ * → the hub-connect key cached on the profile (cloud instances)
260
+ * workspace: --org / --workspace → <PREFIX>_ORG / <PREFIX>_WORKSPACE
261
+ * → none (the server's default; workspace.ts)
262
+ *
263
+ * A profile is an instance (cli-core 0.5.0): a cached hub key is read only
264
+ * when the profile names the instance this request goes to, so a key
265
+ * minted for the cloud can never be sent to some other instance named by
266
+ * --api-url / <PREFIX>_URL. A self-hosted CLI names its instance once, as a
267
+ * profile; the cloud needs no flag at all.
268
+ */
269
+ function resolveContext(cmd: Command, io: CliIo): CliContext<TClient> {
270
+ const opts = cmd.optsWithGlobals() as GlobalOpts;
271
+ const config = loadConfig(io.env);
272
+ const apiUrl = opts.apiUrl ?? opts.url ?? io.env[`${identity.envPrefix}_URL`];
273
+ const selected = selectProfile({
274
+ config,
275
+ requested: opts.profile,
276
+ apiUrl,
277
+ cloudUrl: identity.cloudUrl,
278
+ unknownHint
279
+ });
280
+ const { name: profileName, profile } = selected;
281
+
282
+ const baseUrl = resolveBaseUrl({
283
+ flag: apiUrl,
284
+ env: io.env,
285
+ envVar: `${identity.envPrefix}_URL`,
286
+ profile,
287
+ cloudUrl: identity.cloudUrl
288
+ });
289
+ // A profile's own key is sent only to the instance the profile names
290
+ // (verifier round 1, F5): `--profile a --api-url <b>` must never hand
291
+ // a's key to b, exactly as the connect cache below never does.
292
+ const sameProfileInstance = sameInstance(profileUrl(profile), baseUrl);
293
+ let apiKey = resolveApiKey({
294
+ flag: opts.apiKey ?? stdinApiKeys.get(io),
295
+ env: io.env,
296
+ envVar: `${identity.envPrefix}_API_KEY`,
297
+ profile: sameProfileInstance ? profile : undefined
298
+ });
299
+ let credentialSource: CredentialSource =
300
+ opts.apiKey !== undefined || stdinApiKeys.get(io) !== undefined
301
+ ? 'flag'
302
+ : io.env[`${identity.envPrefix}_API_KEY`]
303
+ ? 'env'
304
+ : apiKey
305
+ ? 'profile'
306
+ : 'none';
307
+ if (!apiKey && sameProfileInstance) {
308
+ // No direct key: the connect cache's slot is the ACTIVE hub profile
309
+ // (what `antasphere login` stored) — org-independent by design. A key
310
+ // cached for ANOTHER account than the login now stored on that slot
311
+ // (`antasphere login --email other` since) is never served: it is the
312
+ // other person's credential (PRDCT-3032); connectForKey retires it.
313
+ const hub = activeHubProfile(io.env);
314
+ if (hub.name && !cachedForAnotherAccount(profile, hub.name, hub.profile?.email)) {
315
+ apiKey = lookupConnectKey(profile, hub.name);
316
+ }
317
+ if (apiKey) credentialSource = 'hub-cache';
318
+ }
319
+ const fetchImpl = io.fetch ?? globalThis.fetch.bind(globalThis);
320
+ const ctx: CliContext<TClient> = {
321
+ client: createClient({ baseUrl, ...(apiKey ? { apiKey } : {}), fetch: fetchImpl }),
322
+ baseUrl,
323
+ apiKey,
324
+ credentialSource,
325
+ json: Boolean(opts.json),
326
+ io,
327
+ profileName,
328
+ profileSource: selected.source,
329
+ profileCreated: selected.created,
330
+ config,
331
+ workspaceSelection: pickWorkspaceSelection({
332
+ workspaceFlag: opts.workspace,
333
+ orgFlag: opts.org,
334
+ env: io.env
335
+ }),
336
+ workspaceId: undefined
337
+ };
338
+ if (credentialSource === 'hub-cache' && apiKey) {
339
+ // A cached key may have been revoked at the hub since it was minted
340
+ // (a swept membership, a logout elsewhere): the client's fetch recovers
341
+ // once, below, instead of stranding the person on a 401.
342
+ ctx.client = createClient({ baseUrl, apiKey, fetch: recoveringFetch(ctx, fetchImpl) });
343
+ }
344
+ return ctx;
345
+ }
346
+
347
+ /**
348
+ * The cached connect key on `slot` was minted for another Antasphere
349
+ * account than the one whose login the slot now holds. Both addresses must
350
+ * be known: a cache entry or a hub profile without one is trusted as before.
351
+ */
352
+ function cachedForAnotherAccount(
353
+ profile: CliProfile | undefined,
354
+ slot: string,
355
+ hubEmail: string | undefined
356
+ ): boolean {
357
+ const cached = profile?.connectKeys?.[slot]?.email;
358
+ if (!cached || !hubEmail) return false;
359
+ return cached.toLowerCase() !== hubEmail.toLowerCase();
360
+ }
361
+
362
+ const NO_KEY_MESSAGE =
363
+ `An API key is required. Sign in (\`${identity.bin} login\`), pass --api-key, or set ` +
364
+ `${identity.envPrefix}_API_KEY.`;
365
+
366
+ /** The tool's own copy for the cli-core seam: the one-command login is the remedy. */
367
+ const MISSING_HUB_LOGIN_MESSAGE =
368
+ `This ${identity.displayName} instance signs in through Antasphere. Run \`${identity.bin} login\` once ` +
369
+ `(or \`antasphere login\`), then retry — or pass --api-key <${identity.keyPrefix}_…> / set ` +
370
+ `${identity.envPrefix}_API_KEY.`;
371
+
372
+ function connectOptions(ctx: CliContext<TClient>): ConnectOnDemandOptions {
373
+ const { io } = ctx;
374
+ return {
375
+ tool: identity.tool,
376
+ toolBaseUrl: ctx.baseUrl,
377
+ // A profile with no baseUrl of its own is at the cloud URL (cli-core 0.5.0).
378
+ cloudUrl: identity.cloudUrl,
379
+ profileName: ctx.profileName ?? CLOUD_PROFILE,
380
+ env: io.env,
381
+ // Thread the injected fetch so the probe + exchange stay on the test
382
+ // harness wire (and any proxying the runner set up).
383
+ ...(io.fetch ? { fetch: io.fetch } : {}),
384
+ notify: (line) => io.err.write(line),
385
+ messages: { missingHubLogin: MISSING_HUB_LOGIN_MESSAGE }
386
+ };
387
+ }
388
+
389
+ /**
390
+ * The stranded-key recovery (PRDCT-2947): a fetch that, on the FIRST 401
391
+ * answered to a key served from the connect cache, evicts that key, runs
392
+ * the hub exchange again (cli-core `refreshConnectKey`) and replays the
393
+ * request once with the new key. A second 401 stands. A hub refusal on the
394
+ * way (a restricted tool, a swept account) is the hub's own sentence, exit
395
+ * 3, through the runner. An instance that is no longer cloud recovers
396
+ * nothing and the 401 stands as it is.
397
+ */
398
+ function recoveringFetch(ctx: CliContext<TClient>, base: typeof globalThis.fetch): typeof globalThis.fetch {
399
+ let recovered = false;
400
+ return async (input, init) => {
401
+ const res = await base(input, init);
402
+ if (res.status !== 401 || recovered || ctx.credentialSource !== 'hub-cache') return res;
403
+ recovered = true;
404
+ // A 401 under a workspace selection may be the SELECTION's refusal:
405
+ // the server answers a workspace the person does not belong to exactly
406
+ // like an unknown key (no oracle). Ask `/me` once without it; when the
407
+ // key answers there, it is alive and nothing is evicted, exchanged or
408
+ // replayed (verifier round 1, F2: a refused --org leaked a live key per
409
+ // command). The runner then explains the refusal.
410
+ if (ctx.workspaceId !== undefined && ctx.apiKey) {
411
+ const probe = await base(`${ctx.baseUrl}/api/v1/me`, {
412
+ headers: { authorization: `Bearer ${ctx.apiKey}` }
413
+ }).catch(() => null);
414
+ // Only a probe that ANSWERS 401 proves the key dead (verifier round 2,
415
+ // F10): a probe with no answer, or any other status, keeps the key and
416
+ // lets the 401 stand.
417
+ if (!probe || probe.status !== 401) return res;
418
+ }
419
+ const outcome = await refreshConnectKey(connectOptions(ctx));
420
+ if (outcome.outcome === 'not_cloud') return res;
421
+ ctx.apiKey = outcome.key;
422
+ ctx.credentialSource = 'hub-exchange';
423
+ ctx.io.err.write('The cached key was refused; signed in again through Antasphere.\n');
424
+ const headers = new Headers(init?.headers);
425
+ headers.set('authorization', `Bearer ${outcome.key}`);
426
+ return base(input, { ...init, headers });
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Requires an API key; throws a friendly message the runner turns into exit 1.
432
+ *
433
+ * When nothing resolved, this is the connect-on-demand seam (binding
434
+ * patterns §7, gcloud model), owned by @antasphere/cli-core since 0.3.0:
435
+ * if — and only if — discovery says the instance is an Antasphere-cloud
436
+ * one, the stored `antasphere login` credential is exchanged (hub → tool)
437
+ * for a USER-scoped tool-local API key, which is cached on the selected
438
+ * profile under the hub profile's name and used for this invocation — and
439
+ * served from that cache on every subsequent run (no re-exchange, no fresh
440
+ * mint). Self-hosted instances never take this branch: they get the
441
+ * classic error unchanged.
442
+ */
443
+ async function requireApiKey(
444
+ ctx: CliContext<TClient>,
445
+ opts: { workspace?: boolean } = {}
446
+ ): Promise<string> {
447
+ const key = ctx.apiKey ?? (await connectForKey(ctx));
448
+ if (opts.workspace !== false) await applyWorkspace(ctx);
449
+ return key;
450
+ }
451
+
452
+ /**
453
+ * Put the selected workspace on the client (PRDCT-2419). Every signed-in
454
+ * command calls `requireApiKey`, so this is the one place the selection is
455
+ * applied and no command names it. An id is sent as it is (a workspace id
456
+ * or a hub organization id: the server maps the latter and fails closed on
457
+ * one that is not the person's, and `explainWorkspaceRefusal` says so); a
458
+ * name costs one `/me` to find its id. `workspaces` and `workspace default`
459
+ * pass `workspace: false`: they are how a person repairs a selection that
460
+ * no longer works, so they must answer when it does not.
461
+ */
462
+ async function applyWorkspace(ctx: CliContext<TClient>): Promise<void> {
463
+ const selection = ctx.workspaceSelection;
464
+ if (!selection || ctx.workspaceId !== undefined) return;
465
+ const id = isWorkspaceId(selection.value)
466
+ ? selection.value
467
+ : matchWorkspace((await ctx.client.me()).workspaces, selection.value).id;
468
+ ctx.client.setWorkspace(id);
469
+ ctx.workspaceId = id;
470
+ appliedContexts.set(ctx.io, ctx);
471
+ }
472
+
473
+ /** How the workspace of this invocation was chosen (`whoami`). */
474
+ function workspaceSource(ctx: CliContext<TClient>): WorkspaceSource {
475
+ return ctx.workspaceSelection?.source ?? 'default';
476
+ }
477
+
478
+ /**
479
+ * The sentence for a refusal the SELECTION caused, or null (workspace.ts
480
+ * `explainRefusal`). Asks `/me` once WITHOUT the selection: when that
481
+ * answers, the key is good and the workspace is what was refused. Any
482
+ * failure of the probe means the plain error is the honest one.
483
+ */
484
+ async function explainWorkspaceRefusal(io: CliIo, e: unknown): Promise<string | null> {
485
+ const ctx = appliedContexts.get(io);
486
+ if (!ctx?.workspaceSelection || ctx.workspaceId === undefined || !ctx.apiKey) return null;
487
+ if (!(e instanceof PlatformApiError)) return null;
488
+ if (e.code !== 'workspace_mismatch' && e.code !== 'invalid_api_key') return null;
489
+ try {
490
+ const fetchImpl = io.fetch ?? globalThis.fetch.bind(globalThis);
491
+ const me = await createClient({ baseUrl: ctx.baseUrl, apiKey: ctx.apiKey, fetch: fetchImpl }).me();
492
+ return explainRefusal({
493
+ code: e.code,
494
+ status: e.status,
495
+ selection: ctx.workspaceSelection,
496
+ workspaceId: ctx.workspaceId,
497
+ me,
498
+ baseUrl: ctx.baseUrl
499
+ });
500
+ } catch {
501
+ return null;
502
+ }
503
+ }
504
+
505
+ /**
506
+ * The hint for a 404 under a selection: a resource id (a linked folder's, a
507
+ * pasted one) lives in ONE workspace, and the same id asked of another is
508
+ * "not found" — true, and useless without the workspace that was asked.
509
+ */
510
+ function workspaceNotFoundHint(io: CliIo): string {
511
+ const ctx = appliedContexts.get(io);
512
+ if (!ctx?.workspaceSelection || ctx.workspaceId === undefined) return '';
513
+ const { workspaceSelection: selection } = ctx;
514
+ const noun = selection.kind === 'org' ? 'organization' : 'workspace';
515
+ return ` (looked in the ${noun} "${selection.value}", selected by ${describeSelection(selection)})`;
516
+ }
517
+
518
+ async function connectForKey(ctx: CliContext<TClient>): Promise<string> {
519
+ const { io } = ctx;
520
+ // The cached key of another account (the hub login on the slot changed)
521
+ // is revoked on its instance and forgotten before the exchange runs for
522
+ // the account now signed in (PRDCT-3032). Best effort: a dead key
523
+ // answers 401 and is forgotten all the same.
524
+ const hub = activeHubProfile(io.env);
525
+ if (ctx.profileName && hub.name) {
526
+ const profile = loadConfig(io.env).profiles[ctx.profileName];
527
+ if (cachedForAnotherAccount(profile, hub.name, hub.profile?.email)) {
528
+ const stale = profile?.connectKeys?.[hub.name];
529
+ if (stale) {
530
+ io.err.write(
531
+ `The ${identity.displayName} key cached for ${stale.email ?? 'another Antasphere login'} is retired: ` +
532
+ `the Antasphere login is now ${hub.profile?.email ?? 'another account'}.\n`
533
+ );
534
+ const auth = new CliAuthClient({
535
+ baseUrl: profileUrl(profile),
536
+ ...(io.fetch ? { fetch: io.fetch } : {})
537
+ });
538
+ await auth.revoke(stale.apiKey).catch(() => undefined);
539
+ removeConnectKey(io.env, ctx.profileName, hub.name);
540
+ }
541
+ }
542
+ }
543
+ const outcome = await connectOnDemand(connectOptions(ctx));
544
+ if (outcome.outcome === 'not_cloud') {
545
+ // Self-hosted / unreachable: the classic error, byte-identical.
546
+ throw new CliUsageError(NO_KEY_MESSAGE);
547
+ }
548
+
549
+ // The minted key becomes this invocation's credential.
550
+ ctx.apiKey = outcome.key;
551
+ ctx.credentialSource = outcome.cached ? 'hub-cache' : 'hub-exchange';
552
+ const fetchImpl = io.fetch ?? globalThis.fetch.bind(globalThis);
553
+ ctx.client = createClient({
554
+ baseUrl: ctx.baseUrl,
555
+ apiKey: outcome.key,
556
+ fetch: outcome.cached ? recoveringFetch(ctx, fetchImpl) : fetchImpl
557
+ });
558
+ return outcome.key;
559
+ }
560
+
561
+ return {
562
+ resolveProfile,
563
+ resolveContext,
564
+ requireApiKey,
565
+ workspaceSource,
566
+ explainWorkspaceRefusal,
567
+ workspaceNotFoundHint,
568
+ profileUrl,
569
+ connectOptions
570
+ };
571
+ }
572
+
573
+ /**
574
+ * `--all` for a cursor-paginated list: the first page, then every next page
575
+ * while the server hands a cursor, the rows in order. The one loop behind
576
+ * every `--all`; a server that repeats a cursor is stopped here, once.
577
+ */
578
+ export async function drainPages<T>(
579
+ first: { rows: T[]; nextCursor: string | null },
580
+ fetchPage: (cursor: string) => Promise<{ rows: T[]; nextCursor: string | null }>
581
+ ): Promise<T[]> {
582
+ const rows = [...first.rows];
583
+ const seen = new Set<string>();
584
+ let cursor = first.nextCursor;
585
+ while (cursor && !seen.has(cursor)) {
586
+ seen.add(cursor);
587
+ const page = await fetchPage(cursor);
588
+ rows.push(...page.rows);
589
+ cursor = page.nextCursor;
590
+ }
591
+ return rows;
592
+ }
593
+
594
+ /** Human-readable size (B / KB / MB) for the listing commands. */
595
+ export function fmtBytes(n: number): string {
596
+ if (n < 1024) return `${n} B`;
597
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
598
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
599
+ }
600
+
601
+ /** Minimal aligned two-space table for human output. */
602
+ export function table(rows: string[][]): string {
603
+ if (rows.length === 0) return '';
604
+ const first = rows[0]!;
605
+ const widths = first.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? '').length)));
606
+ return (
607
+ rows
608
+ .map((r) =>
609
+ r
610
+ .map((cell, i) => (i === r.length - 1 ? cell : (cell ?? '').padEnd(widths[i] ?? 0)))
611
+ .join(' ')
612
+ .trimEnd()
613
+ )
614
+ .join('\n') + '\n'
615
+ );
616
+ }
@@ -0,0 +1,48 @@
1
+ import type { ToolIdentity } from '@antasphere/chassis-contract';
2
+
3
+ /**
4
+ * What a tool is called, in every place the generic CLI spells it. ONE object,
5
+ * passed by the tool to `defineCli` (index.ts): the chassis holds no product
6
+ * literal of its own, so each field below is read where the literal used to
7
+ * sit and the bytes a person or a script sees are the tool's own.
8
+ */
9
+ export interface CliIdentity {
10
+ /** The binary's name: `program.name()`, and every `\`<bin> …\`` hint. */
11
+ bin: string;
12
+ /** The tool's namespace in the shared config home, and its cli-core connect name. */
13
+ tool: string;
14
+ /** The pre-cli-core config directory (`$XDG_CONFIG_HOME/<dir>/config.json`), imported once. */
15
+ legacyConfigDir: string;
16
+ /** The product's name in a sentence. */
17
+ displayName: string;
18
+ /** The environment variables' prefix: `<PREFIX>_URL`, `<PREFIX>_API_KEY`, `<PREFIX>_WORKSPACE`. */
19
+ envPrefix: string;
20
+ /** The API key prefix, without the underscore: `<prefix>_…`. */
21
+ keyPrefix: string;
22
+ /** The scope a key needs for the full-workspace export, as `export --help` names it. */
23
+ exportScope: string;
24
+ /**
25
+ * The tool's cloud home (`identity.cloud.url`): the last step of the URL
26
+ * resolution and the instance of the implicit `cloud` profile, so a clean
27
+ * machine with an `antasphere login` needs no flag (PRDCT-2947).
28
+ */
29
+ cloudUrl: string;
30
+ }
31
+
32
+ /**
33
+ * The CLI's identity, BUILT from the tool's one definition (`ToolIdentity`,
34
+ * written in the tool's contract package): a tool passes
35
+ * `cliIdentity(IDENTITY)` to `defineCli` and spells nothing a second time.
36
+ */
37
+ export function cliIdentity(identity: ToolIdentity): CliIdentity {
38
+ return {
39
+ bin: identity.cli.bin,
40
+ tool: identity.slug,
41
+ legacyConfigDir: identity.cli.legacyConfigDir,
42
+ displayName: identity.displayName,
43
+ envPrefix: identity.cli.envPrefix,
44
+ keyPrefix: identity.apiKeyPrefix,
45
+ exportScope: identity.scopes.dataExport,
46
+ cloudUrl: identity.cloud.url
47
+ };
48
+ }