@antasphere/chassis-cli 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +82 -0
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +582 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/completion.d.ts +5 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +85 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/demo.d.ts +12 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +210 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/files.d.ts +21 -0
- package/dist/commands/files.d.ts.map +1 -0
- package/dist/commands/files.js +178 -0
- package/dist/commands/files.js.map +1 -0
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.d.ts.map +1 -0
- package/dist/commands/projects.js +386 -0
- package/dist/commands/projects.js.map +1 -0
- package/dist/commands/teams.d.ts +46 -0
- package/dist/commands/teams.d.ts.map +1 -0
- package/dist/commands/teams.js +265 -0
- package/dist/commands/teams.js.map +1 -0
- package/dist/commands/workspaces.d.ts +22 -0
- package/dist/commands/workspaces.d.ts.map +1 -0
- package/dist/commands/workspaces.js +116 -0
- package/dist/commands/workspaces.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +152 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +456 -0
- package/dist/context.js.map +1 -0
- package/dist/identity.d.ts +36 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +18 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/kit.d.ts +46 -0
- package/dist/kit.d.ts.map +1 -0
- package/dist/kit.js +2 -0
- package/dist/kit.js.map +1 -0
- package/dist/open.d.ts +27 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +45 -0
- package/dist/open.js.map +1 -0
- package/dist/owner-session.d.ts +34 -0
- package/dist/owner-session.d.ts.map +1 -0
- package/dist/owner-session.js +106 -0
- package/dist/owner-session.js.map +1 -0
- package/dist/program.d.ts +5 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +150 -0
- package/dist/program.js.map +1 -0
- package/dist/safe-write.d.ts +46 -0
- package/dist/safe-write.d.ts.map +1 -0
- package/dist/safe-write.js +127 -0
- package/dist/safe-write.js.map +1 -0
- package/dist/stdin.d.ts +4 -0
- package/dist/stdin.d.ts.map +1 -0
- package/dist/stdin.js +36 -0
- package/dist/stdin.js.map +1 -0
- package/dist/testing/index.d.ts +70 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +67 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/workspace.d.ts +79 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +139 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +43 -3
- package/src/commands/auth.ts +687 -0
- package/src/commands/completion.ts +102 -0
- package/src/commands/demo.ts +271 -0
- package/src/commands/files.ts +197 -0
- package/src/commands/projects.ts +460 -0
- package/src/commands/teams.ts +320 -0
- package/src/commands/workspaces.ts +140 -0
- package/src/config.ts +81 -0
- package/src/context.ts +616 -0
- package/src/identity.ts +48 -0
- package/src/index.ts +70 -0
- package/src/kit.ts +50 -0
- package/src/open.ts +48 -0
- package/src/owner-session.ts +152 -0
- package/src/program.ts +183 -0
- package/src/safe-write.ts +136 -0
- package/src/stdin.ts +39 -0
- package/src/testing/index.ts +129 -0
- package/src/workspace.ts +229 -0
- package/test/host/identity.test.ts +229 -0
- package/test/host/minimal-host.ts +16 -0
- package/test/host/minimal-tool.ts +40 -0
- package/test/suite/cli.test.ts +301 -0
- package/test/suite/config.test.ts +59 -0
- package/test/suite/demo.test.ts +467 -0
- package/test/suite/hub-profile-shared.test.ts +123 -0
- package/test/suite/login.test.ts +743 -0
- package/test/suite/logout.test.ts +247 -0
- package/test/suite/migration.test.ts +109 -0
- package/test/suite/open.test.ts +59 -0
- package/test/suite/profiles.test.ts +380 -0
- package/test/suite/projects.test.ts +550 -0
- package/test/suite/recovery.test.ts +348 -0
- package/test/suite/security.test.ts +199 -0
- package/test/suite/signin-fixtures.ts +204 -0
- package/test/suite/teams.test.ts +354 -0
- package/test/suite/whoami.test.ts +159 -0
- package/test/suite/workspace.test.ts +402 -0
- package/README.md +0 -3
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { CliUsageError, type CliIo } from '../context.js';
|
|
3
|
+
import type { CliIdentity } from '../identity.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shell completion scripts, generated from the live command tree (so a new
|
|
7
|
+
* command is picked up automatically). Word-level completion: top-level
|
|
8
|
+
* commands, then subcommands where they exist.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface CmdInfo {
|
|
12
|
+
name: string;
|
|
13
|
+
subs: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function commandTree(program: Command): CmdInfo[] {
|
|
17
|
+
return program.commands
|
|
18
|
+
.filter((c) => !c.name().startsWith('help'))
|
|
19
|
+
.map((c) => ({
|
|
20
|
+
name: c.name(),
|
|
21
|
+
subs: c.commands.map((s) => s.name())
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bashScript(bin: string, tree: CmdInfo[]): string {
|
|
26
|
+
const top = tree.map((c) => c.name).join(' ');
|
|
27
|
+
const cases = tree
|
|
28
|
+
.filter((c) => c.subs.length > 0)
|
|
29
|
+
.map((c) => ` ${c.name}) COMPREPLY=( $(compgen -W "${c.subs.join(' ')}" -- "$cur") ); return ;;`)
|
|
30
|
+
.join('\n');
|
|
31
|
+
return `# bash completion for ${bin} — eval "$(${bin} completion bash)"
|
|
32
|
+
_${bin}_completions() {
|
|
33
|
+
local cur prev
|
|
34
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
35
|
+
prev="\${COMP_WORDS[1]}"
|
|
36
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
37
|
+
COMPREPLY=( $(compgen -W "${top}" -- "$cur") )
|
|
38
|
+
return
|
|
39
|
+
fi
|
|
40
|
+
case "$prev" in
|
|
41
|
+
${cases}
|
|
42
|
+
esac
|
|
43
|
+
}
|
|
44
|
+
complete -F _${bin}_completions ${bin}
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function zshScript(bin: string, tree: CmdInfo[]): string {
|
|
49
|
+
const top = tree.map((c) => c.name).join(' ');
|
|
50
|
+
const cases = tree
|
|
51
|
+
.filter((c) => c.subs.length > 0)
|
|
52
|
+
.map((c) => ` ${c.name}) compadd ${c.subs.join(' ')} ;;`)
|
|
53
|
+
.join('\n');
|
|
54
|
+
return `#compdef ${bin}
|
|
55
|
+
# zsh completion for ${bin} — eval "$(${bin} completion zsh)"
|
|
56
|
+
_${bin}() {
|
|
57
|
+
if (( CURRENT == 2 )); then
|
|
58
|
+
compadd ${top}
|
|
59
|
+
return
|
|
60
|
+
fi
|
|
61
|
+
case "\${words[2]}" in
|
|
62
|
+
${cases}
|
|
63
|
+
esac
|
|
64
|
+
}
|
|
65
|
+
compdef _${bin} ${bin}
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fishScript(bin: string, tree: CmdInfo[]): string {
|
|
70
|
+
const lines = [
|
|
71
|
+
`# fish completion for ${bin} — ${bin} completion fish | source`,
|
|
72
|
+
`complete -c ${bin} -f -n "__fish_use_subcommand" -a "${tree.map((c) => c.name).join(' ')}"`
|
|
73
|
+
];
|
|
74
|
+
for (const c of tree) {
|
|
75
|
+
if (c.subs.length > 0) {
|
|
76
|
+
lines.push(`complete -c ${bin} -f -n "__fish_seen_subcommand_from ${c.name}" -a "${c.subs.join(' ')}"`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return `${lines.join('\n')}\n`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerCompletionCommand(identity: CliIdentity, program: Command, io: CliIo): void {
|
|
83
|
+
program
|
|
84
|
+
.command('completion <shell>')
|
|
85
|
+
.description('Print a completion script for bash, zsh, or fish')
|
|
86
|
+
.action(async (shell: string) => {
|
|
87
|
+
const tree = commandTree(program);
|
|
88
|
+
switch (shell) {
|
|
89
|
+
case 'bash':
|
|
90
|
+
io.out.write(bashScript(identity.bin, tree));
|
|
91
|
+
return;
|
|
92
|
+
case 'zsh':
|
|
93
|
+
io.out.write(zshScript(identity.bin, tree));
|
|
94
|
+
return;
|
|
95
|
+
case 'fish':
|
|
96
|
+
io.out.write(fishScript(identity.bin, tree));
|
|
97
|
+
return;
|
|
98
|
+
default:
|
|
99
|
+
throw new CliUsageError(`Unsupported shell "${shell}" — bash, zsh, or fish.`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { PlatformApiError, type ChassisClient } from '@antasphere/chassis-sdk';
|
|
3
|
+
import { DEMO_PASS_MAX_MINUTES, type DemoPass } from '@antasphere/chassis-contract';
|
|
4
|
+
import { CliApiRefusal, CliUsageError, printJson, table, type CliIo } from '../context.js';
|
|
5
|
+
import type { CliKit } from '../kit.js';
|
|
6
|
+
import { withOwnerSession } from '../owner-session.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The demo commands: an owner mints a link that signs one member in without
|
|
10
|
+
* a password, for a demonstration (the demo pass spec, section 8). The
|
|
11
|
+
* routes exist only while the instance's DEMO_SIGN_IN switch is on, on the
|
|
12
|
+
* self-hosted edition, and they refuse every machine credential: these
|
|
13
|
+
* commands sign in as the owner (owner-session.ts) and never send a key.
|
|
14
|
+
*
|
|
15
|
+
* The secret is in the mint's answer only, and leaves this process in one
|
|
16
|
+
* place: inside the links. `--json` is `{ passes: [{ pass, links }], refused }`
|
|
17
|
+
* through `printJson` (the raw sink); every human line goes through the sanitizing sinks the
|
|
18
|
+
* runner installed, since a member's name is somebody else's text.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The instance's unknown-path answer: what all three routes are while the switch is off. */
|
|
22
|
+
const SWITCH_OFF =
|
|
23
|
+
'Demo sign-in is off on this instance (DEMO_SIGN_IN), or this is the cloud edition, where demo links are minted at the Antasphere hub.';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The server's refusals of the demo routes. Their sentences are the ones to
|
|
27
|
+
* read, so they reach the runner as worded refusals: its generic hints speak
|
|
28
|
+
* of API keys, and these commands hold none.
|
|
29
|
+
*/
|
|
30
|
+
/** The role gate's refusal: the person who signed in is an admin or a member. */
|
|
31
|
+
const NOT_AN_OWNER =
|
|
32
|
+
'Only an owner of this workspace manages demo links: the account that signed in is not one.';
|
|
33
|
+
|
|
34
|
+
const REFUSALS = new Set([
|
|
35
|
+
'no_such_member',
|
|
36
|
+
'owner_target',
|
|
37
|
+
'demo_address_required',
|
|
38
|
+
'two_factor_enrolled',
|
|
39
|
+
'guest_target',
|
|
40
|
+
'cross_workspace_target',
|
|
41
|
+
'invalid_demo_path',
|
|
42
|
+
'sessions_only',
|
|
43
|
+
'demo_session',
|
|
44
|
+
'not_found'
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** A refusal about ONE member: the others named on the command are still served. */
|
|
48
|
+
class MemberRefusal extends CliApiRefusal {
|
|
49
|
+
constructor(
|
|
50
|
+
message: string,
|
|
51
|
+
status: number,
|
|
52
|
+
public readonly code: string
|
|
53
|
+
) {
|
|
54
|
+
super(message, status);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function explained<T>(run: () => Promise<T>): Promise<T> {
|
|
59
|
+
try {
|
|
60
|
+
return await run();
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (e instanceof PlatformApiError) {
|
|
63
|
+
if (e.status === 404 && e.code === 'not_found' && e.message === 'Not found') {
|
|
64
|
+
throw new CliApiRefusal(SWITCH_OFF, 404);
|
|
65
|
+
}
|
|
66
|
+
// The role gate answers the generic `forbidden`; the runner's hint for
|
|
67
|
+
// a 403 speaks of an API key, which this command never holds.
|
|
68
|
+
if (e.status === 403 && e.code === 'forbidden') throw new CliApiRefusal(NOT_AN_OWNER, 403);
|
|
69
|
+
if (REFUSALS.has(e.code)) throw new MemberRefusal(e.message, e.status, e.code);
|
|
70
|
+
}
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The server's page rule (`isSafeDemoPath`), checked here too so a bad
|
|
77
|
+
* `--path` costs no sign-in: 1 to 2048 characters, one leading `/` (never
|
|
78
|
+
* `//` nor `/\`), no `#`, no space or control character.
|
|
79
|
+
*/
|
|
80
|
+
export function isSafeDemoPath(path: string): boolean {
|
|
81
|
+
if (path.length < 1 || path.length > 2048) return false;
|
|
82
|
+
if (!path.startsWith('/') || path.startsWith('//') || path.startsWith('/\\')) return false;
|
|
83
|
+
for (let i = 0; i < path.length; i++) {
|
|
84
|
+
const code = path.charCodeAt(i);
|
|
85
|
+
if (code < 0x21 || code === 0x7f) return false;
|
|
86
|
+
}
|
|
87
|
+
return !path.includes('#');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `--hours` / `--minutes` as the mint's `expiresInMinutes`, or undefined for the server's default. */
|
|
91
|
+
function lifetimeMinutes(opts: { hours?: string; minutes?: string }): number | undefined {
|
|
92
|
+
if (opts.hours !== undefined && opts.minutes !== undefined) {
|
|
93
|
+
throw new CliUsageError('Pass either --hours or --minutes, not both.');
|
|
94
|
+
}
|
|
95
|
+
const raw = opts.hours ?? opts.minutes;
|
|
96
|
+
if (raw === undefined) return undefined;
|
|
97
|
+
const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
98
|
+
const minutes = opts.hours !== undefined ? n * 60 : n;
|
|
99
|
+
if (!Number.isSafeInteger(minutes) || minutes < 1 || minutes > DEMO_PASS_MAX_MINUTES) {
|
|
100
|
+
throw new CliUsageError(
|
|
101
|
+
`A demo link lives from one minute to a week (${DEMO_PASS_MAX_MINUTES} minutes): ` +
|
|
102
|
+
`--${opts.hours !== undefined ? 'hours' : 'minutes'} takes a whole number in that range.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return minutes;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The link for one page: the fragment never reaches a server log. */
|
|
109
|
+
function demoLink(baseUrl: string, secret: string, path: string): string {
|
|
110
|
+
return `${baseUrl.replace(/\/+$/, '')}/demo#pass=${secret}&to=${encodeURIComponent(path)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const collect = (value: string, previous: string[]): string[] => [...previous, value];
|
|
114
|
+
|
|
115
|
+
interface OwnerOpts {
|
|
116
|
+
ownerEmail?: string;
|
|
117
|
+
ownerPasswordStdin: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function registerDemoCommands<TClient extends ChassisClient<string>>(
|
|
121
|
+
kit: CliKit<TClient>,
|
|
122
|
+
program: Command,
|
|
123
|
+
io: CliIo
|
|
124
|
+
): void {
|
|
125
|
+
const { identity, resolveContext } = kit;
|
|
126
|
+
const prefix = identity.envPrefix;
|
|
127
|
+
|
|
128
|
+
const demo = program
|
|
129
|
+
.command('demo')
|
|
130
|
+
.description(
|
|
131
|
+
'Demo links: sign a member in without a password, for a demonstration (owner; DEMO_SIGN_IN on)'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
/** The owner's sign-in flags every demo command carries. */
|
|
135
|
+
const ownerFlags = (cmd: Command): Command =>
|
|
136
|
+
cmd
|
|
137
|
+
.option('--owner-email <address>', `the owner who signs in (or ${prefix}_OWNER_EMAIL)`)
|
|
138
|
+
.option(
|
|
139
|
+
'--owner-password-stdin',
|
|
140
|
+
`read the owner's password from the first line of stdin (or ${prefix}_OWNER_PASSWORD)`,
|
|
141
|
+
false
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const session = (opts: OwnerOpts) => ({
|
|
145
|
+
identity,
|
|
146
|
+
...(opts.ownerEmail !== undefined ? { ownerEmail: opts.ownerEmail } : {}),
|
|
147
|
+
ownerPasswordStdin: opts.ownerPasswordStdin
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── demo link ─────────────────────────────────────────────────────────────
|
|
151
|
+
ownerFlags(
|
|
152
|
+
demo
|
|
153
|
+
.command('link')
|
|
154
|
+
.description('Mint one demo pass per member and print one link per page')
|
|
155
|
+
.requiredOption(
|
|
156
|
+
'--email <address>',
|
|
157
|
+
'a member the links sign in (repeatable: one sign-in, one pass per member)',
|
|
158
|
+
collect,
|
|
159
|
+
[]
|
|
160
|
+
)
|
|
161
|
+
.option('--path <p>', 'a page the link lands on (repeatable; the first is the default, /)', collect, [])
|
|
162
|
+
.option('--hours <n>', 'how long the link lives, in hours (default: a day; at most a week)')
|
|
163
|
+
.option('--minutes <n>', 'how long the link lives, in minutes (instead of --hours)')
|
|
164
|
+
).action(
|
|
165
|
+
async (
|
|
166
|
+
opts: OwnerOpts & { email: string[]; path: string[]; hours?: string; minutes?: string },
|
|
167
|
+
cmd: Command
|
|
168
|
+
) => {
|
|
169
|
+
const paths = opts.path.length > 0 ? opts.path : ['/'];
|
|
170
|
+
// Everything the command can refuse by itself, before any sign-in.
|
|
171
|
+
for (const path of paths) {
|
|
172
|
+
if (!isSafeDemoPath(path)) {
|
|
173
|
+
throw new CliUsageError(
|
|
174
|
+
`--path "${path}" is not a page of this instance: a path starting with a single /, ` +
|
|
175
|
+
'with no #, no space and no control character, at most 2048 characters.'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const expiresInMinutes = lifetimeMinutes(opts);
|
|
180
|
+
const ctx = resolveContext(cmd, io);
|
|
181
|
+
// ONE sign-in for every member named: a script that makes the links of
|
|
182
|
+
// a whole demonstration calls the command once. A member the server
|
|
183
|
+
// refuses does not stop the others; the command says who and why, and
|
|
184
|
+
// exits 1.
|
|
185
|
+
const { passes, refused } = await withOwnerSession(ctx, session(opts), async (client) => {
|
|
186
|
+
const passes: { pass: DemoPass; links: { path: string; url: string }[] }[] = [];
|
|
187
|
+
const refused: { email: string; code: string; message: string }[] = [];
|
|
188
|
+
for (const email of opts.email) {
|
|
189
|
+
try {
|
|
190
|
+
const minted = await explained(() =>
|
|
191
|
+
client.mintDemoPass({
|
|
192
|
+
email,
|
|
193
|
+
path: paths[0]!,
|
|
194
|
+
...(expiresInMinutes !== undefined ? { expiresInMinutes } : {})
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
passes.push({
|
|
198
|
+
pass: minted.pass,
|
|
199
|
+
links: paths.map((path) => ({ path, url: demoLink(ctx.baseUrl, minted.secret, path) }))
|
|
200
|
+
});
|
|
201
|
+
} catch (e) {
|
|
202
|
+
// Anything else (the switch off, the signed-in account not an
|
|
203
|
+
// owner, the network) is the same for everyone: no point going on.
|
|
204
|
+
if (!(e instanceof MemberRefusal)) throw e;
|
|
205
|
+
refused.push({ email, code: e.code, message: e.message });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { passes, refused };
|
|
209
|
+
});
|
|
210
|
+
if (ctx.json) {
|
|
211
|
+
printJson(io, { passes, refused });
|
|
212
|
+
} else {
|
|
213
|
+
for (const { pass, links } of passes) {
|
|
214
|
+
io.out.write(
|
|
215
|
+
`Demo link for ${pass.name} <${pass.email}>, valid until ${pass.expiresAt}:\n` +
|
|
216
|
+
links.map((l) => `${l.url}\n`).join('')
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (refused.length > 0) {
|
|
221
|
+
throw new CliApiRefusal(
|
|
222
|
+
refused.map((r) => `${r.email}: ${r.message}`).join('\n'),
|
|
223
|
+
refused.length === opts.email.length ? 403 : 207
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// ── demo list ─────────────────────────────────────────────────────────────
|
|
230
|
+
ownerFlags(demo.command('list').description('List the demo passes of the workspace (newest first)')).action(
|
|
231
|
+
async (opts: OwnerOpts, cmd: Command) => {
|
|
232
|
+
const ctx = resolveContext(cmd, io);
|
|
233
|
+
const { passes } = await withOwnerSession(ctx, session(opts), (client) =>
|
|
234
|
+
explained(() => client.demoPasses())
|
|
235
|
+
);
|
|
236
|
+
if (ctx.json) return printJson(io, { passes });
|
|
237
|
+
if (passes.length === 0) {
|
|
238
|
+
io.out.write('No demo passes.\n');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
io.out.write(
|
|
242
|
+
table([
|
|
243
|
+
['ID', 'PERSON', 'PAGE', 'EXPIRES', 'REVOKED', 'LAST USED', 'USES'],
|
|
244
|
+
...passes.map((p: DemoPass) => [
|
|
245
|
+
p.id,
|
|
246
|
+
p.email,
|
|
247
|
+
p.targetPath,
|
|
248
|
+
p.expiresAt,
|
|
249
|
+
p.revokedAt ?? '-',
|
|
250
|
+
p.lastUsedAt ?? 'never',
|
|
251
|
+
String(p.useCount)
|
|
252
|
+
])
|
|
253
|
+
])
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// ── demo revoke ───────────────────────────────────────────────────────────
|
|
259
|
+
ownerFlags(
|
|
260
|
+
demo.command('revoke <id>').description('Revoke a demo pass; the sessions it opened end with it')
|
|
261
|
+
).action(async (id: string, opts: OwnerOpts, cmd: Command) => {
|
|
262
|
+
const ctx = resolveContext(cmd, io);
|
|
263
|
+
const pass = await withOwnerSession(ctx, session(opts), (client) =>
|
|
264
|
+
explained(() => client.revokeDemoPass(id))
|
|
265
|
+
);
|
|
266
|
+
if (ctx.json) return printJson(io, pass);
|
|
267
|
+
io.out.write(
|
|
268
|
+
`Revoked the demo pass ${pass.id} for ${pass.email}. The sessions it opened are signed out.\n`
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, resolve, win32 } from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
|
|
7
|
+
import type { Command } from 'commander';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
10
|
+
PlatformApiError,
|
|
11
|
+
type ChassisClient,
|
|
12
|
+
type ListParams
|
|
13
|
+
} from '@antasphere/chassis-sdk';
|
|
14
|
+
import { CliUsageError, fmtBytes, printJson, type CliIo } from '../context.js';
|
|
15
|
+
import type { CliKit } from '../kit.js';
|
|
16
|
+
import { writeContained } from '../safe-write.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The platform substrate commands inherited from the template: instance
|
|
20
|
+
* discovery, the raw workspace-files surface, and the full export download.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Turn a server-chosen `originalName` into a plain filename, or refuse.
|
|
25
|
+
*
|
|
26
|
+
* The stored name is whatever the uploader (possibly a collaborator, on a
|
|
27
|
+
* shared instance) typed: `../../.bashrc`, `/etc/cron.d/x`, `.npmrc`, and
|
|
28
|
+
* on Windows `..\\..\\x` are all valid under the wire schema. Both POSIX
|
|
29
|
+
* and Windows separators are stripped, then anything that is not an inert
|
|
30
|
+
* plain filename is refused rather than silently rewritten — the caller
|
|
31
|
+
* always has `--out <path>` to name the destination themselves.
|
|
32
|
+
*/
|
|
33
|
+
export function safeDownloadName(originalName: string): string {
|
|
34
|
+
const name = basename(win32.basename(originalName));
|
|
35
|
+
if (name === '' || name === '.' || name === '..' || name.startsWith('.')) {
|
|
36
|
+
throw new CliUsageError(
|
|
37
|
+
`Refusing to derive a filename from the stored name ${JSON.stringify(originalName)} — ` +
|
|
38
|
+
'pass --out <path> to choose where it goes.'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function registerFileCommands<TClient extends ChassisClient<string>>(
|
|
45
|
+
kit: CliKit<TClient>,
|
|
46
|
+
program: Command,
|
|
47
|
+
io: CliIo
|
|
48
|
+
): void {
|
|
49
|
+
const { requireApiKey, resolveContext } = kit;
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.command('instance')
|
|
53
|
+
.description('Show instance discovery (public: name, version, auth methods)')
|
|
54
|
+
.action(async (_opts, cmd: Command) => {
|
|
55
|
+
const ctx = resolveContext(cmd, io);
|
|
56
|
+
const info = await ctx.client.instance();
|
|
57
|
+
if (ctx.json) return printJson(io, info);
|
|
58
|
+
io.out.write(
|
|
59
|
+
`${info.name} v${info.version} (${info.edition})\n` +
|
|
60
|
+
` setup required: ${info.setupRequired}\n` +
|
|
61
|
+
` auth methods: ${info.auth.methods.join(', ')}\n`
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
program
|
|
66
|
+
.command('export')
|
|
67
|
+
.description(`Download the full workspace export as a zip (key needs ${kit.identity.exportScope})`)
|
|
68
|
+
.option('-o, --out <path>', 'write to this path (defaults to export-<date>.zip)')
|
|
69
|
+
.action(async (opts: { out?: string }, cmd: Command) => {
|
|
70
|
+
const ctx = resolveContext(cmd, io);
|
|
71
|
+
await requireApiKey(ctx);
|
|
72
|
+
const res = await ctx.client.downloadExport();
|
|
73
|
+
if (!res.body) {
|
|
74
|
+
throw new Error('The export response carried no body');
|
|
75
|
+
}
|
|
76
|
+
const out = opts.out ?? `export-${new Date().toISOString().slice(0, 10)}.zip`;
|
|
77
|
+
// Stream to disk — exports can be large, never buffer (unlike files
|
|
78
|
+
// download, whose objects are capped by MAX_FILE_SIZE_MB).
|
|
79
|
+
await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(out));
|
|
80
|
+
if (ctx.json) return printJson(io, { path: out });
|
|
81
|
+
io.out.write(`Export written to ${out}\n`);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const files = program.command('files').description('Manage workspace files');
|
|
85
|
+
|
|
86
|
+
files
|
|
87
|
+
.command('list')
|
|
88
|
+
.description('List files in the workspace (newest first, cursor-paginated)')
|
|
89
|
+
.option('--cursor <cursor>', 'resume from a previous nextCursor')
|
|
90
|
+
.option('--limit <n>', 'page size (1-100)', (v: string) => parseInt(v, 10))
|
|
91
|
+
.option('--all', 'follow nextCursor until every page is fetched', false)
|
|
92
|
+
.action(async (opts: { cursor?: string; limit?: number; all: boolean }, cmd: Command) => {
|
|
93
|
+
const ctx = resolveContext(cmd, io);
|
|
94
|
+
await requireApiKey(ctx);
|
|
95
|
+
const params: ListParams = {};
|
|
96
|
+
if (opts.cursor) params.cursor = opts.cursor;
|
|
97
|
+
if (opts.limit !== undefined) params.limit = opts.limit;
|
|
98
|
+
const first = await ctx.client.files(params);
|
|
99
|
+
const rows = [...first.files];
|
|
100
|
+
if (opts.all) {
|
|
101
|
+
let cursor = first.nextCursor;
|
|
102
|
+
while (cursor) {
|
|
103
|
+
const page = await ctx.client.files({ ...params, cursor });
|
|
104
|
+
rows.push(...page.files);
|
|
105
|
+
cursor = page.nextCursor;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const nextCursor = opts.all ? null : first.nextCursor;
|
|
109
|
+
// The wire shape, so scripts can thread nextCursor (--all drains it to null).
|
|
110
|
+
if (ctx.json) return printJson(io, { files: rows, nextCursor });
|
|
111
|
+
if (rows.length === 0) {
|
|
112
|
+
io.out.write('No files.\n');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const f of rows) {
|
|
116
|
+
io.out.write(`${f.id} ${fmtBytes(f.sizeBytes).padStart(9)} ${f.originalName}\n`);
|
|
117
|
+
}
|
|
118
|
+
if (nextCursor) {
|
|
119
|
+
io.out.write(`More available: rerun with --cursor ${nextCursor} or --all\n`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
files
|
|
124
|
+
.command('upload <path>')
|
|
125
|
+
.description('Upload a local file')
|
|
126
|
+
.option('--name <name>', 'stored file name (defaults to the local basename)')
|
|
127
|
+
.option('--content-type <type>', 'content type', 'application/octet-stream')
|
|
128
|
+
.action(async (path: string, opts: { name?: string; contentType: string }, cmd: Command) => {
|
|
129
|
+
const ctx = resolveContext(cmd, io);
|
|
130
|
+
await requireApiKey(ctx);
|
|
131
|
+
const bytes = await readFile(path);
|
|
132
|
+
const name = opts.name ?? basename(path);
|
|
133
|
+
const result = await ctx.client.uploadFile(name, new Uint8Array(bytes), opts.contentType);
|
|
134
|
+
if (ctx.json) return printJson(io, result);
|
|
135
|
+
io.out.write(
|
|
136
|
+
`Uploaded ${result.file.originalName} (${fmtBytes(result.file.sizeBytes)})` +
|
|
137
|
+
`${result.deduplicated ? ' [deduplicated]' : ''}\n id: ${result.file.id}\n`
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
files
|
|
142
|
+
.command('download <id>')
|
|
143
|
+
.description('Download a file by id')
|
|
144
|
+
.option('--out <path>', 'write to this exact path (your path, used verbatim)')
|
|
145
|
+
.option('--dir <path>', 'directory to write the stored name into (default: .)')
|
|
146
|
+
.action(async (id: string, opts: { out?: string; dir?: string }, cmd: Command) => {
|
|
147
|
+
const ctx = resolveContext(cmd, io);
|
|
148
|
+
const apiKey = await requireApiKey(ctx);
|
|
149
|
+
if (opts.out && opts.dir) {
|
|
150
|
+
throw new CliUsageError('Pass either --out <path> or --dir <path>, not both.');
|
|
151
|
+
}
|
|
152
|
+
const meta = await ctx.client.file(id);
|
|
153
|
+
const fetchImpl = io.fetch ?? globalThis.fetch.bind(globalThis);
|
|
154
|
+
const res = await fetchImpl(ctx.client.fileContentUrl(id), {
|
|
155
|
+
// This one bypasses the SDK, so it carries the SDK's headers itself:
|
|
156
|
+
// the key, and the selected workspace (a download follows it too).
|
|
157
|
+
headers: {
|
|
158
|
+
authorization: `Bearer ${apiKey}`,
|
|
159
|
+
...(ctx.workspaceId !== undefined ? { 'x-workspace-id': ctx.workspaceId } : {})
|
|
160
|
+
},
|
|
161
|
+
// …and the SDK's deadline.
|
|
162
|
+
signal: AbortSignal.timeout(DEFAULT_DOWNLOAD_TIMEOUT_MS)
|
|
163
|
+
});
|
|
164
|
+
if (!res.ok) {
|
|
165
|
+
throw new PlatformApiError(res.status, 'download_failed', `Download failed with ${res.status}`);
|
|
166
|
+
}
|
|
167
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
168
|
+
let out: string;
|
|
169
|
+
if (opts.out) {
|
|
170
|
+
// The caller typed this path — honour it verbatim.
|
|
171
|
+
out = opts.out;
|
|
172
|
+
await writeFile(out, buf);
|
|
173
|
+
} else {
|
|
174
|
+
// `originalName` is SERVER-controlled and unconstrained beyond
|
|
175
|
+
// 1-255 chars: it may be `../../.ssh/authorized_keys` or an
|
|
176
|
+
// absolute path. Never join it — take its basename, refuse the
|
|
177
|
+
// names that are not a plain filename, and write it contained
|
|
178
|
+
// inside the chosen directory (symlinks refused, mode forced).
|
|
179
|
+
const dir = resolve(opts.dir ?? '.');
|
|
180
|
+
const name = safeDownloadName(meta.originalName);
|
|
181
|
+
await mkdir(dir, { recursive: true });
|
|
182
|
+
out = await writeContained(dir, name, buf);
|
|
183
|
+
}
|
|
184
|
+
io.out.write(`Downloaded ${meta.originalName} → ${out} (${fmtBytes(buf.length)})\n`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
files
|
|
188
|
+
.command('rm <id>')
|
|
189
|
+
.description('Delete a file by id')
|
|
190
|
+
.action(async (id: string, _opts, cmd: Command) => {
|
|
191
|
+
const ctx = resolveContext(cmd, io);
|
|
192
|
+
await requireApiKey(ctx);
|
|
193
|
+
await ctx.client.deleteFile(id);
|
|
194
|
+
if (ctx.json) return printJson(io, { deleted: id });
|
|
195
|
+
io.out.write(`Deleted ${id}\n`);
|
|
196
|
+
});
|
|
197
|
+
}
|