@antasphere/chassis-cli 0.0.0-stage → 1.0.0

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/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { ChassisClient } from '@antasphere/chassis-sdk';
2
+ import { createConfig } from './config.js';
3
+ import { createContext } from './context.js';
4
+ import type { Cli, CliDefinition, CliKit } from './kit.js';
5
+ import { createProgram } from './program.js';
6
+ import { createWorkspace } from './workspace.js';
7
+
8
+ /**
9
+ * `@antasphere/chassis-cli`: the generic half of a tool's command line. The
10
+ * profiles and the config file, the context (instance, key, workspace), the
11
+ * workspace selection, secrets on stdin, the contained writes, the browser
12
+ * opener, the generic command groups, the program and the runner. A tool
13
+ * instantiates it ONCE (`defineCli`) with its identity and its client, and
14
+ * its command modules read the kit that comes back.
15
+ */
16
+
17
+ export { cliIdentity, type CliIdentity } from './identity.js';
18
+ export { redactKey, type CliConfigStore } from './config.js';
19
+ export type { CliConfig, CliConnectKey, CliEnv, CliProfile } from './config.js';
20
+ export {
21
+ CliUsageError,
22
+ fmtBytes,
23
+ printJson,
24
+ sanitizeForTty,
25
+ setStdinApiKey,
26
+ stdinApiKey,
27
+ table,
28
+ ttySafeIo,
29
+ type CliContext,
30
+ type CliContextKit,
31
+ type CliIo,
32
+ type CredentialSource
33
+ } from './context.js';
34
+ export {
35
+ findWorkspace,
36
+ isWorkspaceId,
37
+ matchWorkspace,
38
+ workspaceLines,
39
+ type CliWorkspace,
40
+ type MeResponse,
41
+ type MeWorkspace,
42
+ type SelectionKind,
43
+ type WorkspaceSelection,
44
+ type WorkspaceSource
45
+ } from './workspace.js';
46
+ export { readSecretFromStdin } from './stdin.js';
47
+ export {
48
+ resolveInside,
49
+ streamContained,
50
+ UnsafeWriteError,
51
+ writeContained,
52
+ writeNoFollow
53
+ } from './safe-write.js';
54
+ export { isInteractive, openInBrowser, platformOpener } from './open.js';
55
+
56
+ export { safeDownloadName } from './commands/files.js';
57
+ export { explainProjectRefusal, roleNamedBy, memberRef, type ProjectLookup } from './commands/projects.js';
58
+ export { CliApiRefusal, drainPages } from './context.js';
59
+ export type { Cli, CliDefinition, CliKit, RegisterTool } from './kit.js';
60
+
61
+ export function defineCli<TClient extends ChassisClient<string>>(
62
+ definition: CliDefinition<TClient>
63
+ ): Cli<TClient> {
64
+ const { identity, createClient } = definition;
65
+ const config = createConfig(identity);
66
+ const workspace = createWorkspace(identity);
67
+ const context = createContext({ identity, createClient, config, workspace });
68
+ const kit: CliKit<TClient> = { identity, createClient, ...config, workspace, ...context };
69
+ return { ...kit, ...createProgram(kit, definition) };
70
+ }
package/src/kit.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { CliAuthError } from '@antasphere/cli-core';
2
+ import type { ChassisClient, ClientOptions, PlatformApiError } from '@antasphere/chassis-sdk';
3
+ import type { Command } from 'commander';
4
+ import type { CliConfigStore } from './config.js';
5
+ import type { CliContextKit, CliIo } from './context.js';
6
+ import type { CliIdentity } from './identity.js';
7
+ import type { CliWorkspace } from './workspace.js';
8
+
9
+ /** The tool's hook in the command tree: it registers its own groups on the program. */
10
+ export type RegisterTool = (program: Command, io: CliIo) => void;
11
+
12
+ export interface CliDefinition<TClient extends ChassisClient<string>> {
13
+ /** What the tool is called, everywhere the generic CLI spells it (identity.ts). */
14
+ identity: CliIdentity;
15
+ /** The program's one-line description (`--help`'s first line after the usage). */
16
+ description: string;
17
+ /** The tool's client: every place the generic CLI builds one goes through this. */
18
+ createClient: (options: ClientOptions) => TClient;
19
+ /**
20
+ * The tool's own hint for a refused request, as it is appended to the error
21
+ * line (leading space and parentheses included), or undefined for none. The
22
+ * runner asks after the 403 hint and before the 401 one.
23
+ */
24
+ errorHint?: (e: PlatformApiError | CliAuthError) => string | undefined;
25
+ }
26
+
27
+ /** The generic functions bound to one tool, as the command modules read them. */
28
+ export interface CliKit<TClient extends ChassisClient<string>>
29
+ extends CliConfigStore, CliContextKit<TClient> {
30
+ identity: CliIdentity;
31
+ createClient: (options: ClientOptions) => TClient;
32
+ /** The workspace functions that spell the tool (workspace.ts). */
33
+ workspace: CliWorkspace;
34
+ }
35
+
36
+ /** What `defineCli` hands back: the kit, plus the program and the runner. */
37
+ export interface Cli<TClient extends ChassisClient<string>> extends CliKit<TClient> {
38
+ /**
39
+ * The command tree, built once per run: the global options, the auth,
40
+ * workspace, project and demo groups, THE TOOL'S GROUPS, then
41
+ * instance/export/files, and the completion command last (it prints the
42
+ * tree, so it must see all of it).
43
+ */
44
+ buildProgram(io: CliIo, registerTool: RegisterTool, version: string): Command;
45
+ /**
46
+ * Run the CLI with injected I/O and return a process exit code. The bin calls
47
+ * this with real process streams; tests call it in-process.
48
+ */
49
+ run(argv: string[], rawIo: CliIo, registerTool: RegisterTool, version: string): Promise<number>;
50
+ }
package/src/open.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { CliIo } from './context.js';
3
+
4
+ /**
5
+ * Opening a URL in the person's default browser (PRDCT-2280): the first push
6
+ * of a folder opens the pushed resource's page, the tool's `open` command opens
7
+ * it on demand, its `dev` command opens the local preview.
8
+ *
9
+ * The URL is always ONE argv item handed to the platform's opener binary,
10
+ * never a string a shell interprets: a resource title or an instance URL is
11
+ * somebody else's text, and `sh -c "open $url"` would run whatever it
12
+ * carries. On Windows `start` is a cmd.exe builtin that `spawn` cannot run
13
+ * without a shell, so the URL handler is invoked through `rundll32`
14
+ * instead, which is a real executable taking the URL as an argument.
15
+ */
16
+ export function platformOpener(platform: NodeJS.Platform = process.platform): {
17
+ command: string;
18
+ args: string[];
19
+ } {
20
+ if (platform === 'darwin') return { command: 'open', args: [] };
21
+ if (platform === 'win32') return { command: 'rundll32', args: ['url.dll,FileProtocolHandler'] };
22
+ return { command: 'xdg-open', args: [] };
23
+ }
24
+
25
+ /**
26
+ * Best effort: a missing opener (a headless box) must never fail the
27
+ * command that already did its real work. The runner can inject `io.openUrl`
28
+ * (tests record the call; nothing is spawned).
29
+ */
30
+ export function openInBrowser(io: CliIo, url: string, spawnImpl: typeof spawn = spawn): void {
31
+ if (io.openUrl) {
32
+ io.openUrl(url);
33
+ return;
34
+ }
35
+ const { command, args } = platformOpener();
36
+ try {
37
+ const child = spawnImpl(command, [...args, url], { stdio: 'ignore', detached: true });
38
+ child.on('error', () => undefined);
39
+ child.unref();
40
+ } catch {
41
+ // best-effort
42
+ }
43
+ }
44
+
45
+ /** A stdout that is a terminal: the only place a browser may pop up unasked. */
46
+ export function isInteractive(io: CliIo): boolean {
47
+ return io.out.isTTY === true;
48
+ }
@@ -0,0 +1,152 @@
1
+ import { ChassisClient, DEFAULT_TIMEOUT_MS } from '@antasphere/chassis-sdk';
2
+ import { CliApiRefusal, CliUsageError, type CliContext } from './context.js';
3
+ import type { CliIdentity } from './identity.js';
4
+ import { readSecretFromStdin } from './stdin.js';
5
+ import { isWorkspaceId, matchWorkspace } from './workspace.js';
6
+
7
+ /**
8
+ * A command that acts as a SIGNED-IN OWNER, never with an API key.
9
+ *
10
+ * Some owner acts are refused to every machine credential by design (the
11
+ * demo pass routes: nothing in the scope allowlist opens them, and the
12
+ * handler checks `via === 'session'` again). The CLI reaches them the way a
13
+ * browser does: one password sign-in, the session cookie held in memory for
14
+ * the length of the command, a sign-out when it ends, success or failure.
15
+ *
16
+ * The address comes from `--owner-email` or `<PREFIX>_OWNER_EMAIL`, the
17
+ * password from `<PREFIX>_OWNER_PASSWORD` or, with `--owner-password-stdin`,
18
+ * the first line of stdin. There is no password flag: a value on the command
19
+ * line is visible in `ps` and lands in the shell history. None of the three
20
+ * (address, password, cookie) is ever written to the profile, a file, a log
21
+ * or the terminal: they live in this function's locals and in the one
22
+ * client's header map, and die with the command.
23
+ */
24
+
25
+ export interface OwnerSessionOptions {
26
+ /** The tool's identity: `<PREFIX>_OWNER_EMAIL` / `<PREFIX>_OWNER_PASSWORD` are built from it. */
27
+ identity: Pick<CliIdentity, 'envPrefix'>;
28
+ /** `--owner-email`. */
29
+ ownerEmail?: string;
30
+ /** `--owner-password-stdin`. */
31
+ ownerPasswordStdin?: boolean;
32
+ }
33
+
34
+ /**
35
+ * The sign-in library allows three sign-ins per ten seconds per address, and
36
+ * a script that makes links for several people signs in once per command:
37
+ * the fourth command in a row meets the wall. It is a wall of seconds, so the
38
+ * command waits it out, three times at most, and says so on stderr.
39
+ */
40
+ const SIGN_IN_WAITS_MS = [4_000, 8_000, 12_000] as const;
41
+
42
+ /** The session cookie the sign-in library sets, whatever prefix the instance gives it. */
43
+ const SESSION_COOKIE = /session_token$/;
44
+
45
+ /** `name=value` of the session cookie among the answer's `set-cookie` lines, or null. */
46
+ function sessionCookie(res: Response): string | null {
47
+ for (const line of res.headers.getSetCookie()) {
48
+ const pair = line.split(';', 1)[0]!.trim();
49
+ const eq = pair.indexOf('=');
50
+ if (eq > 0 && SESSION_COOKIE.test(pair.slice(0, eq))) return pair;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** The sign-in library's own refusals are `{ code, message }`; the API's are `{ error: {…} }`. */
56
+ async function refusalMessage(res: Response): Promise<string | null> {
57
+ const body = (await res.json().catch(() => null)) as {
58
+ message?: unknown;
59
+ error?: { message?: unknown };
60
+ } | null;
61
+ const message = body?.error?.message ?? body?.message;
62
+ return typeof message === 'string' && message ? message : null;
63
+ }
64
+
65
+ async function ownerCredentials(
66
+ ctx: CliContext,
67
+ options: OwnerSessionOptions
68
+ ): Promise<{ email: string; password: string }> {
69
+ const { envPrefix } = options.identity;
70
+ const emailVar = `${envPrefix}_OWNER_EMAIL`;
71
+ const passwordVar = `${envPrefix}_OWNER_PASSWORD`;
72
+ const email = (options.ownerEmail ?? ctx.io.env[emailVar] ?? '').trim();
73
+ const password = options.ownerPasswordStdin
74
+ ? await readSecretFromStdin(ctx.io, 'owner password')
75
+ : (ctx.io.env[passwordVar] ?? '');
76
+ if (!email || !password) {
77
+ throw new CliUsageError(
78
+ `This command signs in as an owner of the workspace, never with an API key. Set ${emailVar} ` +
79
+ `(or pass --owner-email) and ${passwordVar} (or pipe it with --owner-password-stdin).`
80
+ );
81
+ }
82
+ return { email, password };
83
+ }
84
+
85
+ /**
86
+ * Sign in as the owner, run `run` with a client that carries the session
87
+ * cookie and the origin (and no authorization header), then sign out.
88
+ */
89
+ export async function withOwnerSession<T>(
90
+ ctx: CliContext,
91
+ options: OwnerSessionOptions,
92
+ run: (client: ChassisClient<string>) => Promise<T>
93
+ ): Promise<T> {
94
+ const base = ctx.baseUrl.replace(/\/+$/, '');
95
+ const origin = new URL(base).origin;
96
+ const fetchImpl = ctx.io.fetch ?? globalThis.fetch.bind(globalThis);
97
+ const { email, password } = await ownerCredentials(ctx, options);
98
+
99
+ // The server refuses a sign-in that carries no Origin (the cross-site guard).
100
+ const signIn = () =>
101
+ fetchImpl(`${base}/api/v1/auth/sign-in/email`, {
102
+ method: 'POST',
103
+ headers: { origin, 'content-type': 'application/json' },
104
+ body: JSON.stringify({ email, password }),
105
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
106
+ });
107
+ const sleep = ctx.io.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
108
+ let res = await signIn();
109
+ for (const wait of SIGN_IN_WAITS_MS) {
110
+ if (res.status !== 429) break;
111
+ ctx.io.err.write(`The instance limits sign-ins: waiting ${wait / 1000} seconds.\n`);
112
+ await sleep(wait);
113
+ res = await signIn();
114
+ }
115
+ if (res.status === 429) {
116
+ throw new CliApiRefusal('Too many sign-in attempts for now: wait a few minutes, then try again.', 429);
117
+ }
118
+ if (!res.ok) {
119
+ const message = (await refusalMessage(res)) ?? `The sign-in failed (HTTP ${res.status}).`;
120
+ throw new CliApiRefusal(message, res.status);
121
+ }
122
+ const answer = (await res.json().catch(() => null)) as { twoFactorRedirect?: unknown } | null;
123
+ if (answer?.twoFactorRedirect === true) {
124
+ throw new CliUsageError('This owner has a second factor: mint demo links from the dashboard.');
125
+ }
126
+ const cookie = sessionCookie(res);
127
+ if (!cookie) throw new CliUsageError('The instance answered the sign-in without a session.');
128
+
129
+ const headers = { cookie, origin };
130
+ const client = new ChassisClient<string>({ baseUrl: base, fetch: fetchImpl, headers });
131
+ try {
132
+ // The workspace selection applies here as it does to every command
133
+ // (context.ts `applyWorkspace`): an id as it is, a name through `/me`.
134
+ const selection = ctx.workspaceSelection;
135
+ if (selection) {
136
+ client.setWorkspace(
137
+ isWorkspaceId(selection.value)
138
+ ? selection.value
139
+ : matchWorkspace((await client.me()).workspaces, selection.value).id
140
+ );
141
+ }
142
+ return await run(client);
143
+ } finally {
144
+ // The session ends with the command; its outcome changes nothing.
145
+ await fetchImpl(`${base}/api/v1/auth/sign-out`, {
146
+ method: 'POST',
147
+ headers: { ...headers, 'content-type': 'application/json' },
148
+ body: '{}',
149
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
150
+ }).catch(() => undefined);
151
+ }
152
+ }
package/src/program.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { Command, CommanderError } from 'commander';
2
+ import { CliAuthError, CliConnectError } from '@antasphere/cli-core';
3
+ import { PlatformApiError, type ChassisClient } from '@antasphere/chassis-sdk';
4
+ import { CliApiRefusal, CliUsageError, setStdinApiKey, ttySafeIo, type CliIo } from './context.js';
5
+ import type { Cli, CliDefinition, CliKit, RegisterTool } from './kit.js';
6
+ import { readSecretFromStdin } from './stdin.js';
7
+ import { registerAuthCommands } from './commands/auth.js';
8
+ import { registerWorkspaceCommands } from './commands/workspaces.js';
9
+ import { registerProjectCommands } from './commands/projects.js';
10
+ import { registerTeamCommands } from './commands/teams.js';
11
+ import { registerDemoCommands } from './commands/demo.js';
12
+ import { registerFileCommands } from './commands/files.js';
13
+ import { registerCompletionCommand } from './commands/completion.js';
14
+
15
+ /** The price and the balance of a credit refusal, each only when it is a number (the code review). */
16
+ function creditFigures(credit: { credits?: unknown; balance?: unknown } | undefined): string {
17
+ const parts = [
18
+ typeof credit?.credits === 'number' ? `this needs ${credit.credits} credits` : null,
19
+ typeof credit?.balance === 'number' ? `the organization holds ${credit.balance}` : null
20
+ ].filter((x): x is string => x !== null);
21
+ return parts.length > 0 ? `${parts.join(' and ')}; ` : '';
22
+ }
23
+
24
+ /** The program and the runner of one tool, over its kit (kit.ts). */
25
+ export function createProgram<TClient extends ChassisClient<string>>(
26
+ kit: CliKit<TClient>,
27
+ definition: Pick<CliDefinition<TClient>, 'description' | 'errorHint'>
28
+ ): Pick<Cli<TClient>, 'buildProgram' | 'run'> {
29
+ const { identity, explainWorkspaceRefusal, workspaceNotFoundHint } = kit;
30
+ const { description, errorHint } = definition;
31
+
32
+ function buildProgram(io: CliIo, registerTool: RegisterTool, version: string): Command {
33
+ const program = new Command();
34
+ program
35
+ .name(identity.bin)
36
+ .description(description)
37
+ .version(version)
38
+ .option(
39
+ '--api-url <url>',
40
+ `instance base URL (or ${identity.envPrefix}_URL / the profile's; default: the cloud, ${identity.cloudUrl})`
41
+ )
42
+ .option('--url <url>', 'alias of --api-url')
43
+ .option('--api-key <key>', `API key (or ${identity.envPrefix}_API_KEY / profile apiKey)`)
44
+ .option(
45
+ '--api-key-stdin',
46
+ 'read the API key from the first line of stdin (keeps it out of argv)',
47
+ false
48
+ )
49
+ .option('--profile <name>', 'use this saved profile (an instance) instead of the active one')
50
+ .option(
51
+ '--org <id-or-name>',
52
+ `run in this Antasphere organization, by its id or name (or ${identity.envPrefix}_ORG; default: your default organization)`
53
+ )
54
+ .option(
55
+ '--workspace <id-or-name>',
56
+ `run in this workspace of the instance, by its id or name (or ${identity.envPrefix}_WORKSPACE; default: the server's)`
57
+ )
58
+ .option('--json', 'machine-readable JSON output', false);
59
+
60
+ // Identity + profiles: login, logout, whoami, verify, use, profiles,
61
+ // config show/clear.
62
+ registerAuthCommands(kit, program, io);
63
+ // Which workspace the commands run in: workspaces, workspace default.
64
+ registerWorkspaceCommands(kit, program, io);
65
+ // The subgroups of the workspace: projects *, projects members *.
66
+ registerProjectCommands(kit, program, io);
67
+ // The named groups of the workspace's people: teams *.
68
+ registerTeamCommands(kit, program, io);
69
+ // Demo links, as a signed-in owner (never a key): demo link, list, revoke.
70
+ registerDemoCommands(kit, program, io);
71
+ // The tool's own groups, where they have always sat in `--help`.
72
+ registerTool(program, io);
73
+ // Platform substrate (template heritage): instance, export, files *.
74
+ registerFileCommands(kit, program, io);
75
+ // Shell completion — registered LAST so the tree it prints is complete.
76
+ registerCompletionCommand(identity, program, io);
77
+
78
+ return program;
79
+ }
80
+
81
+ async function run(
82
+ argv: string[],
83
+ rawIo: CliIo,
84
+ registerTool: RegisterTool,
85
+ version: string
86
+ ): Promise<number> {
87
+ // Every human sink is wrapped ONCE, here: resource titles, annotation bodies,
88
+ // stored filenames and server error messages are all somebody else's text
89
+ // heading for a terminal (context.ts `sanitizeForTty`). `--json` keeps the
90
+ // raw sink through `printJson`.
91
+ const io = ttySafeIo(rawIo);
92
+
93
+ // `--api-key-stdin` is resolved BEFORE commander parses: `resolveContext`
94
+ // is synchronous, so the key must already be parked against this io by
95
+ // the time a command asks for it. Reading stdin is the whole point — an
96
+ // argv-borne secret is visible in `ps` and lands in the shell history.
97
+ if (argv.includes('--api-key-stdin')) {
98
+ try {
99
+ setStdinApiKey(io, await readSecretFromStdin(io, 'API key'));
100
+ } catch (e) {
101
+ io.err.write(`Error: ${e instanceof Error ? e.message : String(e)}\n`);
102
+ return 1;
103
+ }
104
+ }
105
+
106
+ const program = buildProgram(io, registerTool, version);
107
+ program.exitOverride();
108
+ program.configureOutput({
109
+ writeOut: (s) => io.out.write(s),
110
+ writeErr: (s) => io.err.write(s)
111
+ });
112
+ try {
113
+ await program.parseAsync(argv, { from: 'user' });
114
+ return 0;
115
+ } catch (e) {
116
+ if (e instanceof CommanderError) {
117
+ // --help / --version and usage errors already wrote their output.
118
+ return e.exitCode;
119
+ }
120
+ if (e instanceof CliConnectError && e.refusal) {
121
+ // The hub (or the instance's connect door) refused the PERSON, not
122
+ // the credential: the server's own sentence, as it is, and exit 3,
123
+ // the code the docs promise for a refusal. No hint: no key fixes it.
124
+ io.err.write(`Error: ${e.message}\n`);
125
+ return e.exitCode;
126
+ }
127
+ if (e instanceof PlatformApiError || e instanceof CliAuthError) {
128
+ // A refusal the workspace SELECTION caused reads as a bad key (401) or
129
+ // a forbidden one (403) on the wire; say what it really was.
130
+ const explained = await explainWorkspaceRefusal(io, e);
131
+ if (explained) {
132
+ io.err.write(`Error: ${explained}\n`);
133
+ return 1;
134
+ }
135
+ // A plan refusal (the billing rail, 403 `plan_required`) is not a
136
+ // key's fault: the sentence carries the upgrade link, the hub's
137
+ // organization page, where a human raises the plan.
138
+ const upgradeUrl = (
139
+ e instanceof PlatformApiError && e.code === 'plan_required'
140
+ ? (e.details as { upgradeUrl?: unknown } | undefined)?.upgradeUrl
141
+ : undefined
142
+ ) as string | undefined;
143
+ // A credit refusal (402 `entitlement_denied`, PRDCT-2664) carries the
144
+ // price, the balance and the top-up link, the hub's billing page. The
145
+ // self-hosted cap's 413 has no details and keeps its message alone.
146
+ const credit = (
147
+ e instanceof PlatformApiError && e.code === 'entitlement_denied' ? e.details : undefined
148
+ ) as { credits?: unknown; balance?: unknown; topUpUrl?: unknown } | undefined;
149
+ const topUpUrl = typeof credit?.topUpUrl === 'string' && credit.topUpUrl ? credit.topUpUrl : null;
150
+ // The gate's own message already spells the price, the balance and
151
+ // the link; the hint repeats them only for a message that does not
152
+ // (verifier round 1: the URL was printed twice).
153
+ const hint =
154
+ typeof upgradeUrl === 'string' && upgradeUrl
155
+ ? ` — upgrade the plan at ${upgradeUrl}`
156
+ : topUpUrl && !e.message.includes(topUpUrl)
157
+ ? ` — ${creditFigures(credit)}top up at ${topUpUrl}`
158
+ : e.status === 403
159
+ ? ' (this API key is not allowed to do that)'
160
+ : (errorHint?.(e) ??
161
+ (e.status === 401
162
+ ? ` (check the key: \`${identity.bin} verify\`)`
163
+ : e.status === 404
164
+ ? workspaceNotFoundHint(io)
165
+ : ''));
166
+ io.err.write(`Error: ${e.message}${hint}\n`);
167
+ return 1;
168
+ }
169
+ if (e instanceof CliUsageError) {
170
+ // A refusal a command already worded keeps the one thing the command
171
+ // cannot know: the workspace a 404 was asked of. The thrower says the
172
+ // exit code (cli-core: 1 a usage or wire error, 3 a refusal).
173
+ const hint = e instanceof CliApiRefusal && e.status === 404 ? workspaceNotFoundHint(io) : '';
174
+ io.err.write(`Error: ${e.message}${hint}\n`);
175
+ return e.exitCode;
176
+ }
177
+ io.err.write(`Error: ${e instanceof Error ? e.message : String(e)}\n`);
178
+ return 1;
179
+ }
180
+ }
181
+
182
+ return { buildProgram, run };
183
+ }
@@ -0,0 +1,136 @@
1
+ import { constants as FS } from 'node:fs';
2
+ import { mkdir, open, realpath, unlink } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
4
+ import type { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+
7
+ /**
8
+ * Writing SERVER-CHOSEN paths to a developer's disk, safely.
9
+ *
10
+ * The tool's `pull` and the generic `files download` both materialize names the
11
+ * instance picked (a manifest path, a stored filename). That makes the local
12
+ * filesystem the last line of defence, and a lexical `resolve()` +
13
+ * `startsWith()` is not one: it says nothing about symlinks. Three rules,
14
+ * enforced here so both call sites get the same ones:
15
+ *
16
+ * 1. the path must be relative and stay inside the destination LEXICALLY;
17
+ * 2. the parent directory, after `realpath()`, must still be inside the
18
+ * destination (a symlinked sub-directory is a way out);
19
+ * 3. the final component is opened `O_NOFOLLOW`, so an existing symlink is
20
+ * refused rather than written through, and the file's mode is forced to
21
+ * 0644 — `O_TRUNC` on an existing file PRESERVES its mode, which is how
22
+ * an already-executable path stays executable after a hostile pull.
23
+ */
24
+
25
+ /** Thrown when a write target fails containment; the CLI turns it into exit 1. */
26
+ export class UnsafeWriteError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = 'UnsafeWriteError';
30
+ }
31
+ }
32
+
33
+ /** Lexical containment: `relPath` must resolve to something under `root`. */
34
+ export function resolveInside(root: string, relPath: string): string {
35
+ if (isAbsolute(relPath)) {
36
+ throw new UnsafeWriteError(`Refusing to write an absolute path: ${relPath}`);
37
+ }
38
+ const target = resolve(root, relPath);
39
+ const rel = relative(root, target);
40
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
41
+ throw new UnsafeWriteError(`Refusing to write outside ${root}: ${relPath}`);
42
+ }
43
+ return target;
44
+ }
45
+
46
+ /**
47
+ * Create (recursively) the parent of `target` and prove that the REAL
48
+ * directory it lands in is still inside `root`. `mkdir -p` happily walks
49
+ * through an existing symlinked directory, so this must run after it.
50
+ */
51
+ async function prepareParent(root: string, target: string): Promise<void> {
52
+ const parent = dirname(target);
53
+ await mkdir(parent, { recursive: true });
54
+ const realRoot = await realpath(root);
55
+ const realParent = await realpath(parent);
56
+ const rel = relative(realRoot, realParent);
57
+ if (rel !== '' && (rel.startsWith('..') || isAbsolute(rel))) {
58
+ throw new UnsafeWriteError(
59
+ `Refusing to write through a symlinked directory: ${parent} resolves to ${realParent}, ` +
60
+ `outside ${realRoot}`
61
+ );
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Write `bytes` at `root`/`relPath` under all three rules above. Returns the
67
+ * absolute path written.
68
+ */
69
+ export async function writeContained(root: string, relPath: string, bytes: Uint8Array): Promise<string> {
70
+ const target = resolveInside(root, relPath);
71
+ await prepareParent(root, target);
72
+ await writeNoFollow(target, bytes);
73
+ return target;
74
+ }
75
+
76
+ /**
77
+ * Write `bytes` to an exact path, refusing to follow a symlink there and
78
+ * forcing mode 0644 (never inherit an existing file's executable bit).
79
+ */
80
+ export async function writeNoFollow(target: string, bytes: Uint8Array): Promise<void> {
81
+ let handle;
82
+ try {
83
+ handle = await open(target, FS.O_WRONLY | FS.O_CREAT | FS.O_TRUNC | FS.O_NOFOLLOW, 0o644);
84
+ } catch (e) {
85
+ const code = (e as NodeJS.ErrnoException).code;
86
+ // ELOOP (POSIX) / EMLINK (some BSDs) — the target exists and is a symlink.
87
+ if (code === 'ELOOP' || code === 'EMLINK') {
88
+ throw new UnsafeWriteError(`Refusing to write through the symlink at ${target}`);
89
+ }
90
+ throw e;
91
+ }
92
+ try {
93
+ await handle.writeFile(bytes);
94
+ // O_TRUNC keeps the existing mode; re-assert it so a pull can never
95
+ // leave a file executable (or group/world-writable) behind.
96
+ await handle.chmod(0o644).catch(() => undefined);
97
+ } finally {
98
+ await handle.close();
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Stream `source` to `root`/`relPath` under the same three rules as
104
+ * `writeContained`, for a body too large to buffer (a zip of respondent
105
+ * uploads). Returns the absolute path and the byte count. A failed stream
106
+ * removes the partial file — a truncated archive must not look like a
107
+ * finished one.
108
+ */
109
+ export async function streamContained(
110
+ root: string,
111
+ relPath: string,
112
+ source: Readable
113
+ ): Promise<{ path: string; sizeBytes: number }> {
114
+ const target = resolveInside(root, relPath);
115
+ await prepareParent(root, target);
116
+ let handle;
117
+ try {
118
+ handle = await open(target, FS.O_WRONLY | FS.O_CREAT | FS.O_TRUNC | FS.O_NOFOLLOW, 0o644);
119
+ } catch (e) {
120
+ const code = (e as NodeJS.ErrnoException).code;
121
+ if (code === 'ELOOP' || code === 'EMLINK') {
122
+ throw new UnsafeWriteError(`Refusing to write through the symlink at ${target}`);
123
+ }
124
+ throw e;
125
+ }
126
+ try {
127
+ await handle.chmod(0o644).catch(() => undefined);
128
+ const sink = handle.createWriteStream();
129
+ await pipeline(source, sink);
130
+ return { path: target, sizeBytes: sink.bytesWritten };
131
+ } catch (e) {
132
+ await handle.close().catch(() => undefined);
133
+ await unlink(target).catch(() => undefined);
134
+ throw e;
135
+ }
136
+ }
package/src/stdin.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { CliUsageError, type CliIo } from './context.js';
2
+
3
+ /**
4
+ * Secrets that arrive on stdin instead of the command line.
5
+ *
6
+ * A value passed as `--api-key <prefix>_…` or `--password hunter2` is visible to
7
+ * every process on the box (`ps`, `/proc/<pid>/cmdline`) and lands verbatim
8
+ * in the shell history file. The flags stay — they are what scripts already
9
+ * use — but each one now has a `-stdin` sibling and, for the share
10
+ * password, an environment variable, so nothing forces a secret through
11
+ * argv.
12
+ *
13
+ * stdin can only be spent once, so it is claimed: the second claimant in one
14
+ * invocation gets a usage error instead of silently receiving the first
15
+ * one's value.
16
+ */
17
+
18
+ const claims = new WeakMap<CliIo, string>();
19
+
20
+ async function readAllStdin(): Promise<string> {
21
+ const chunks: Buffer[] = [];
22
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
23
+ return Buffer.concat(chunks).toString('utf8');
24
+ }
25
+
26
+ /** First line of stdin, claimed for exactly one purpose per invocation. */
27
+ export async function readSecretFromStdin(io: CliIo, claimant: string): Promise<string> {
28
+ const holder = claims.get(io);
29
+ if (holder !== undefined) {
30
+ throw new CliUsageError(
31
+ `Only one secret can be read from stdin per command — ${holder} already claimed it.`
32
+ );
33
+ }
34
+ claims.set(io, claimant);
35
+ const raw = await (io.readStdin ?? readAllStdin)();
36
+ const value = raw.split('\n')[0]?.trim() ?? '';
37
+ if (!value) throw new CliUsageError(`Nothing on stdin to read the ${claimant} from.`);
38
+ return value;
39
+ }