@antasphere/chassis-cli 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +82 -0
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +582 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/completion.d.ts +5 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +85 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/demo.d.ts +12 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +210 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/files.d.ts +21 -0
- package/dist/commands/files.d.ts.map +1 -0
- package/dist/commands/files.js +178 -0
- package/dist/commands/files.js.map +1 -0
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.d.ts.map +1 -0
- package/dist/commands/projects.js +386 -0
- package/dist/commands/projects.js.map +1 -0
- package/dist/commands/teams.d.ts +46 -0
- package/dist/commands/teams.d.ts.map +1 -0
- package/dist/commands/teams.js +265 -0
- package/dist/commands/teams.js.map +1 -0
- package/dist/commands/workspaces.d.ts +22 -0
- package/dist/commands/workspaces.d.ts.map +1 -0
- package/dist/commands/workspaces.js +116 -0
- package/dist/commands/workspaces.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +152 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +456 -0
- package/dist/context.js.map +1 -0
- package/dist/identity.d.ts +36 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +18 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/kit.d.ts +46 -0
- package/dist/kit.d.ts.map +1 -0
- package/dist/kit.js +2 -0
- package/dist/kit.js.map +1 -0
- package/dist/open.d.ts +27 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +45 -0
- package/dist/open.js.map +1 -0
- package/dist/owner-session.d.ts +34 -0
- package/dist/owner-session.d.ts.map +1 -0
- package/dist/owner-session.js +106 -0
- package/dist/owner-session.js.map +1 -0
- package/dist/program.d.ts +5 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +150 -0
- package/dist/program.js.map +1 -0
- package/dist/safe-write.d.ts +46 -0
- package/dist/safe-write.d.ts.map +1 -0
- package/dist/safe-write.js +127 -0
- package/dist/safe-write.js.map +1 -0
- package/dist/stdin.d.ts +4 -0
- package/dist/stdin.d.ts.map +1 -0
- package/dist/stdin.js +36 -0
- package/dist/stdin.js.map +1 -0
- package/dist/testing/index.d.ts +70 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +67 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/workspace.d.ts +79 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +139 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +43 -3
- package/src/commands/auth.ts +687 -0
- package/src/commands/completion.ts +102 -0
- package/src/commands/demo.ts +271 -0
- package/src/commands/files.ts +197 -0
- package/src/commands/projects.ts +460 -0
- package/src/commands/teams.ts +320 -0
- package/src/commands/workspaces.ts +140 -0
- package/src/config.ts +81 -0
- package/src/context.ts +616 -0
- package/src/identity.ts +48 -0
- package/src/index.ts +70 -0
- package/src/kit.ts +50 -0
- package/src/open.ts +48 -0
- package/src/owner-session.ts +152 -0
- package/src/program.ts +183 -0
- package/src/safe-write.ts +136 -0
- package/src/stdin.ts +39 -0
- package/src/testing/index.ts +129 -0
- package/src/workspace.ts +229 -0
- package/test/host/identity.test.ts +229 -0
- package/test/host/minimal-host.ts +16 -0
- package/test/host/minimal-tool.ts +40 -0
- package/test/suite/cli.test.ts +301 -0
- package/test/suite/config.test.ts +59 -0
- package/test/suite/demo.test.ts +467 -0
- package/test/suite/hub-profile-shared.test.ts +123 -0
- package/test/suite/login.test.ts +743 -0
- package/test/suite/logout.test.ts +247 -0
- package/test/suite/migration.test.ts +109 -0
- package/test/suite/open.test.ts +59 -0
- package/test/suite/profiles.test.ts +380 -0
- package/test/suite/projects.test.ts +550 -0
- package/test/suite/recovery.test.ts +348 -0
- package/test/suite/security.test.ts +199 -0
- package/test/suite/signin-fixtures.ts +204 -0
- package/test/suite/teams.test.ts +354 -0
- package/test/suite/whoami.test.ts +159 -0
- package/test/suite/workspace.test.ts +402 -0
- package/README.md +0 -3
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
|
+
}
|