@standchat/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,164 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { Transform, Writable } from 'node:stream';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { parser } from 'stream-json/parser.js';
5
+ import { CliError } from './errors.js';
6
+ const ROOT_TYPES = new Map([
7
+ ['exportedAt', 'startString'], ['filters', 'startObject'],
8
+ ['conversations', 'startArray'], ['totalElements', 'startNumber'],
9
+ ['exportedElements', 'startNumber'], ['exportLimit', 'startNumber'],
10
+ ['failedSessionIds', 'startArray'],
11
+ ]);
12
+ const VALUE_STARTS = new Set(['startObject', 'startArray', 'startString', 'startNumber', 'nullValue', 'trueValue', 'falseValue']);
13
+ const COUNT_FIELDS = new Set(['totalElements', 'exportedElements', 'exportLimit']);
14
+ const invalid = () => new CliError('EXPORT_INVALID', 'Export is not a complete valid history document. No file was published.', 5);
15
+ /** Validate syntax with stream-json; retain only bounded top-level metadata.
16
+ * Never assemble a conversation, string value, failed-ID list, or full file.
17
+ */
18
+ export async function validateHistoryExport(path, signal) {
19
+ let depth = 0;
20
+ let started = false;
21
+ let ended = false;
22
+ let collectingKey = false;
23
+ let key = '';
24
+ let field = '';
25
+ let expectsValue = false;
26
+ let number = '';
27
+ let countingNumber = false;
28
+ let conversations = 0;
29
+ let failed = 0;
30
+ const seen = new Set();
31
+ const counts = new Map();
32
+ function consume(token) {
33
+ const name = token.name;
34
+ if (!started) {
35
+ if (name !== 'startObject')
36
+ throw invalid();
37
+ started = true;
38
+ depth = 1;
39
+ return;
40
+ }
41
+ if (ended)
42
+ throw invalid();
43
+ if (depth === 1 && name === 'startKey') {
44
+ collectingKey = true;
45
+ key = '';
46
+ return;
47
+ }
48
+ if (collectingKey) {
49
+ if (name === 'stringChunk') {
50
+ key += token.value;
51
+ // Only the small documented top-level field names are retained.
52
+ if (key.length > 128)
53
+ throw invalid();
54
+ }
55
+ else if (name === 'endKey') {
56
+ collectingKey = false;
57
+ field = key;
58
+ expectsValue = true;
59
+ if (ROOT_TYPES.has(field)) {
60
+ if (seen.has(field))
61
+ throw invalid();
62
+ seen.add(field);
63
+ }
64
+ }
65
+ return;
66
+ }
67
+ if (depth === 1 && expectsValue && VALUE_STARTS.has(name)) {
68
+ const expected = ROOT_TYPES.get(field);
69
+ if (expected && name !== expected)
70
+ throw invalid();
71
+ expectsValue = false;
72
+ if (COUNT_FIELDS.has(field)) {
73
+ countingNumber = true;
74
+ number = '';
75
+ }
76
+ }
77
+ if (countingNumber) {
78
+ if (name === 'numberChunk') {
79
+ number += token.value;
80
+ if (number.length > 64)
81
+ throw invalid();
82
+ }
83
+ else if (name === 'endNumber') {
84
+ const value = Number(number);
85
+ if (!Number.isSafeInteger(value) || value < 0)
86
+ throw invalid();
87
+ counts.set(field, value);
88
+ countingNumber = false;
89
+ }
90
+ }
91
+ // Count only direct array elements. Arbitrary nested conversation data is
92
+ // consumed as tokens and is never buffered by this validation layer.
93
+ if (depth === 2 && VALUE_STARTS.has(name)) {
94
+ if (field === 'conversations') {
95
+ if (name !== 'startObject')
96
+ throw invalid();
97
+ conversations++;
98
+ }
99
+ else if (field === 'failedSessionIds') {
100
+ if (name !== 'startString')
101
+ throw invalid();
102
+ failed++;
103
+ }
104
+ }
105
+ if (name === 'startObject' || name === 'startArray') {
106
+ if (++depth > 128)
107
+ throw invalid();
108
+ }
109
+ else if (name === 'endObject' || name === 'endArray') {
110
+ depth--;
111
+ if (depth === 0)
112
+ ended = true;
113
+ }
114
+ }
115
+ // Node's normal string decoder replaces malformed UTF-8. Validate it with
116
+ // the built-in fatal decoder before handing the original bytes to the parser.
117
+ const decoder = new TextDecoder('utf-8', { fatal: true });
118
+ const utf8 = new Transform({
119
+ transform(chunk, _encoding, callback) {
120
+ try {
121
+ decoder.decode(chunk, { stream: true });
122
+ callback(null, chunk);
123
+ }
124
+ catch {
125
+ callback(invalid());
126
+ }
127
+ },
128
+ flush(callback) { try {
129
+ decoder.decode();
130
+ callback();
131
+ }
132
+ catch {
133
+ callback(invalid());
134
+ } },
135
+ });
136
+ const sink = new Writable({ objectMode: true, write(token, _encoding, callback) {
137
+ try {
138
+ consume(token);
139
+ callback();
140
+ }
141
+ catch (error) {
142
+ callback(error);
143
+ }
144
+ } });
145
+ try {
146
+ await pipeline(createReadStream(path, { highWaterMark: 64 * 1024 }), utf8, parser.asStream({ packValues: false }), sink, { signal });
147
+ }
148
+ catch (error) {
149
+ if (signal?.aborted)
150
+ throw new CliError('CANCELLED', 'Export cancelled. No file was published.', 130);
151
+ if (error instanceof CliError)
152
+ throw error;
153
+ // Parser diagnostics can contain customer content; never surface them.
154
+ throw invalid();
155
+ }
156
+ if (!ended || depth !== 0 || [...ROOT_TYPES.keys()].some(name => !seen.has(name)))
157
+ throw invalid();
158
+ const total = counts.get('totalElements');
159
+ const exported = counts.get('exportedElements');
160
+ if (failed !== 0 || conversations !== exported || total !== exported || counts.get('exportLimit') !== total) {
161
+ throw new CliError('EXPORT_INCOMPLETE', `Export incomplete: ${conversations} conversations received, ${exported} reported exported, ${total} expected, ${failed} failed. No file was published.`, 1);
162
+ }
163
+ return { complete: true, totalElements: total, exportedElements: exported, failedElements: 0 };
164
+ }
package/dist/index.js ADDED
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { realpathSync } from 'node:fs';
6
+ import { CLI_VERSION } from './version.js';
7
+ import { AuthSession, ALLOWED_SCOPES, DEFAULT_SCOPES, SETUP_SCOPES, deviceLogin, scopesForLogin } from './auth.js';
8
+ import { ConfigStore, CredentialVault, profileName } from './config.js';
9
+ import { COMMANDS, executeCommand, findCommand, integerFlag, readInput, readSchema, requestBody, stringFlag } from './commands.js';
10
+ import { CliError, isObject, terminalJson, terminalText, usage } from './errors.js';
11
+ import { multipartBody } from './multipart.js';
12
+ import { publicOperation } from './contract.js';
13
+ import { setupStatus } from './setup.js';
14
+ import { ApiClient, apiUrl, safeUrl, sleep } from './transport.js';
15
+ const GLOBAL_OPTIONS = ['profile', 'json', 'no-input', 'yes', 'help', 'timeout', 'retries'];
16
+ const STRING_OPTIONS = ['profile', 'timeout', 'retries', 'api-url', 'issuer', 'scopes', 'credential-store', 'body-file', 'domain', 'name', 'enabled', 'language', 'query', 'page', 'size', 'type', 'scope', 'site', 'contact', 'label', 'closed-from', 'closed-to', 'sort-by', 'sort-dir', 'max-pages', 'interval', 'count', 'email', 'output', 'max-bytes', 'app-url'];
17
+ const BOOLEAN_OPTIONS = ['json', 'no-input', 'yes', 'help', 'all', 'watch', 'token-stdin', 'local-only', 'check', 'version', 'setup'];
18
+ const HELP = `Stand Chat CLI (beta)\n\nUsage: stand <resource> <command> [arguments] [options]\n\n` +
19
+ COMMANDS.map(command => ` ${`${command.command}${command.args.map(arg => ` <${arg}>`).join(' ')}`.padEnd(34)} ${command.description}`).join('\n') +
20
+ `\n\n auth login Sign in using a browser and device code; read-only scopes by default.\n` +
21
+ ` auth login --setup Request site, Stand-in and knowledge setup permissions together; onboarding stays in the browser.\n` +
22
+ ` auth import --token-stdin Import a token from stdin for this API profile.\n` +
23
+ ` auth status [--check] Show credential metadata; --check verifies API access.\n` +
24
+ ` auth logout [--local-only] Revoke the refresh token and remove the local credential.\n` +
25
+ ` config set --api-url URL --issuer URL\n` +
26
+ ` config list List API profiles (never credentials).\n` +
27
+ ` api METHOD /v1/path Send a request to the configured API.\n` +
28
+ ` schema [operationId] Print the bundled OpenAPI contract or one operation.\n` +
29
+ ` commands Print machine-readable command metadata.\n` +
30
+ ` setup status [--app-url URL] Check browser onboarding readiness without changing your account.\n\n` +
31
+ `Global options: --profile NAME, --json, --no-input, --yes, --timeout SECONDS, --retries 0..3\n` +
32
+ `Timeout: 30 seconds by default (maximum 120); history export uses 600 seconds (maximum 3600), including validation.\n` +
33
+ `Input: --body-file FILE or --body-file - reads JSON; --form-string name=value and --form-file name=PATH send published multipart fields. No token argument is accepted.\n` +
34
+ `Automation: output is JSON when stdout is not a terminal; diagnostics/errors go to stderr.\n` +
35
+ `Safety: write confirmations require --yes without a TTY. Writes are never automatically retried.\n` +
36
+ `See stand commands --json for flags, required scopes, and confirmation requirements.\n`;
37
+ function checkOptions(flags, allowed) {
38
+ const unknown = Object.keys(flags).find(key => !GLOBAL_OPTIONS.includes(key) && !allowed.includes(key));
39
+ if (unknown)
40
+ usage(`--${unknown} is not supported by this command.`);
41
+ }
42
+ function output(value, json) {
43
+ if (json) {
44
+ process.stdout.write(`${terminalJson(value)}\n`);
45
+ return;
46
+ }
47
+ const rows = Array.isArray(value) ? value
48
+ : isObject(value) && Array.isArray(value.sites) ? value.sites
49
+ : isObject(value) && Array.isArray(value.sessions) ? value.sessions
50
+ : undefined;
51
+ if (rows && rows.every(row => isObject(row))) {
52
+ if (!rows.length) {
53
+ process.stdout.write('No results.\n');
54
+ return;
55
+ }
56
+ const preferred = ['siteId', 'sessionId', 'domain', 'name', 'enabled', 'status', 'createdAt', 'closedAt', 'messageCount'];
57
+ const columns = preferred.filter(key => rows.some(row => isObject(row) && row[key] !== undefined)).slice(0, 6);
58
+ if (columns.length) {
59
+ const cells = rows.map(row => columns.map(column => terminalText(String(row[column] ?? '')).slice(0, 70)));
60
+ const widths = columns.map((column, index) => Math.max(column.length, ...cells.map(row => row[index].length)));
61
+ process.stdout.write(`${columns.map((column, i) => column.padEnd(widths[i])).join(' ')}\n`);
62
+ for (const row of cells)
63
+ process.stdout.write(`${row.map((cell, i) => cell.padEnd(widths[i])).join(' ')}\n`);
64
+ if (isObject(value) && value.complete === false)
65
+ process.stderr.write('More pages are available; continue with --page nextPage or increase --max-pages.\n');
66
+ return;
67
+ }
68
+ }
69
+ // Escape C1 and bidi controls as well as JSON control characters in nested content.
70
+ process.stdout.write(`${terminalJson(value, true)}\n`);
71
+ }
72
+ const LEGACY_EMBED_WARNING = 'Legacy embedOptions merges option keys but replaces all behavior rules and custom JavaScript and resets advancedBehaviorEnabled and allowStandAdminBehavior to false, even for {}. A valid behaviorConfig in the same request is applied afterward, subject to current owner consent. Review the complete replacement before continuing.';
73
+ function legacyEmbedWarning(body) {
74
+ return body && isObject(body.embedOptions) ? LEGACY_EMBED_WARNING : undefined;
75
+ }
76
+ async function confirm(label, flags, signal, warning) {
77
+ if (flags.yes) {
78
+ if (warning)
79
+ process.stderr.write(Boolean(flags.json) || !process.stdout.isTTY
80
+ ? `${JSON.stringify({ warning: { code: 'LEGACY_EMBED_OPTIONS_CONVERSION', message: warning } })}\n`
81
+ : `Warning: ${warning}\n`);
82
+ return;
83
+ }
84
+ if (warning)
85
+ label = `${label}. ${warning}`;
86
+ if (flags['no-input'] || !process.stdin.isTTY || !process.stderr.isTTY || flags['body-file'] === '-') {
87
+ throw new CliError('CONFIRMATION_REQUIRED', `${label} requires --yes. No request was sent.`, 2);
88
+ }
89
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
90
+ const cancellation = new AbortController();
91
+ const cancel = () => cancellation.abort();
92
+ prompt.on('SIGINT', cancel);
93
+ prompt.on('close', cancel);
94
+ signal?.addEventListener('abort', cancel, { once: true });
95
+ if (signal?.aborted)
96
+ cancel();
97
+ try {
98
+ const answer = await prompt.question(`${terminalText(label)}? Type yes to continue: `, { signal: cancellation.signal });
99
+ if (answer !== 'yes')
100
+ throw new CliError('CANCELLED', 'Operation cancelled. No request was sent.', 130);
101
+ }
102
+ catch (error) {
103
+ if (cancellation.signal.aborted)
104
+ throw new CliError('CANCELLED', 'Operation cancelled. No request was sent.', 130);
105
+ throw error;
106
+ }
107
+ finally {
108
+ signal?.removeEventListener('abort', cancel);
109
+ prompt.removeListener('SIGINT', cancel);
110
+ prompt.removeListener('close', cancel);
111
+ prompt.close();
112
+ }
113
+ }
114
+ function storeFlag(flags) {
115
+ const value = stringFlag(flags, 'credential-store');
116
+ if (value !== undefined && value !== 'file' && value !== 'keychain')
117
+ usage('--credential-store must be keychain or file.');
118
+ return value;
119
+ }
120
+ export async function main(args = process.argv.slice(2), env = process.env, signal) {
121
+ let positionals;
122
+ let flags;
123
+ try {
124
+ const parsed = parseArgs({ args, allowPositionals: true, strict: true, options: {
125
+ ...Object.fromEntries(STRING_OPTIONS.map(key => [key, { type: 'string' }])),
126
+ ...Object.fromEntries(BOOLEAN_OPTIONS.map(key => [key, { type: 'boolean' }])),
127
+ 'query-param': { type: 'string', multiple: true },
128
+ 'form-string': { type: 'string', multiple: true },
129
+ 'form-file': { type: 'string', multiple: true },
130
+ } });
131
+ positionals = parsed.positionals;
132
+ flags = parsed.values;
133
+ }
134
+ catch {
135
+ return usage('Unknown option or missing option value. Run stand --help. Token arguments are never accepted.');
136
+ }
137
+ const json = Boolean(flags.json) || !process.stdout.isTTY;
138
+ if (flags.version) {
139
+ output({ name: '@standchat/cli', version: CLI_VERSION, apiVersion: 'v1' }, json);
140
+ return;
141
+ }
142
+ if (flags.help || positionals[0] === 'help' || positionals.length === 0) {
143
+ process.stdout.write(HELP);
144
+ return;
145
+ }
146
+ if (positionals[0] === 'commands') {
147
+ checkOptions(flags, []);
148
+ if (positionals.length !== 1)
149
+ usage('Usage: stand commands.');
150
+ output({ version: 1, commands: COMMANDS, authCommands: [
151
+ { command: 'auth login', options: ['scopes', 'setup', 'credential-store'], browserApproval: true, progressEvent: 'device_authorization' },
152
+ { command: 'auth import', options: ['token-stdin', 'credential-store'] },
153
+ { command: 'auth status', options: ['check'] },
154
+ { command: 'auth logout', options: ['local-only'] },
155
+ ], apiCommand: { command: 'api METHOD /v1/path', options: ['body-file', 'query-param', 'form-string', 'form-file'], requestFormats: ['application/json', 'multipart/form-data'], multipartFieldsFrom: 'stand schema OPERATION_ID', writesRequireConfirmation: true }, globalOptions: GLOBAL_OPTIONS, loginScopes: { default: DEFAULT_SCOPES, setup: SETUP_SCOPES, supported: ALLOWED_SCOPES },
156
+ setupCommand: { command: 'setup status', options: ['app-url'], scopes: ['profile:read', 'org:read'], writes: false },
157
+ exits: { success: 0, api: 1, usage: 2, notFound: 3, authenticationOrPermission: 4, network: 5, cancelled: 130 } }, true);
158
+ return;
159
+ }
160
+ if (positionals[0] === 'schema') {
161
+ checkOptions(flags, []);
162
+ if (positionals.length > 2)
163
+ usage('Usage: stand schema [operationId].');
164
+ output(await readSchema(positionals[1]), true);
165
+ return;
166
+ }
167
+ const name = profileName(stringFlag(flags, 'profile') ?? env.STAND_PROFILE ?? 'default');
168
+ const config = new ConfigStore(env.STAND_CONFIG_HOME);
169
+ if (positionals[0] === 'config') {
170
+ if (positionals[1] === 'list' && positionals.length === 2) {
171
+ checkOptions(flags, []);
172
+ const saved = await config.read();
173
+ output({ selectedProfile: name, profiles: saved.profiles, defaults: { apiUrl: 'https://api.stand.chat', issuer: 'https://auth.stand.chat/realms/stand' } }, json);
174
+ return;
175
+ }
176
+ if (positionals[1] !== 'set' || positionals.length !== 2)
177
+ usage('Usage: stand config set --profile NAME --api-url URL --issuer URL.');
178
+ checkOptions(flags, ['api-url', 'issuer', 'credential-store']);
179
+ const profile = await config.update(name, current => {
180
+ const api = stringFlag(flags, 'api-url') ?? current?.apiUrl;
181
+ const issuer = stringFlag(flags, 'issuer') ?? current?.issuer;
182
+ if (!api || !issuer)
183
+ usage('New profiles require --api-url and --issuer.');
184
+ return { apiUrl: safeUrl(api).origin, issuer: safeUrl(issuer, 'issuer').href.replace(/\/$/, ''), credentialStore: storeFlag(flags) ?? current?.credentialStore };
185
+ }, signal);
186
+ output({ profile: name, ...profile }, json);
187
+ return;
188
+ }
189
+ // Capture saved endpoints once. A runtime override changes this invocation,
190
+ // while a later credential-store publication must preserve the saved target.
191
+ const savedProfile = await config.profile(name, {});
192
+ const profile = { ...savedProfile,
193
+ apiUrl: safeUrl(env.STAND_API_URL ?? savedProfile.apiUrl).origin,
194
+ issuer: safeUrl(env.STAND_ISSUER ?? savedProfile.issuer, 'issuer').href.replace(/\/$/, ''),
195
+ };
196
+ const publishCredentialStore = () => config.update(name, current => {
197
+ const latest = current ?? savedProfile;
198
+ if (latest.apiUrl !== savedProfile.apiUrl || latest.issuer !== savedProfile.issuer
199
+ || (latest.credentialStore !== savedProfile.credentialStore && latest.credentialStore !== profile.credentialStore)) {
200
+ throw new CliError('CONFIG_CHANGED', 'Credentials were saved for the selected connection, but its saved profile changed during sign-in/import. The newer configuration was preserved. Run stand config list, then sign in or import again for the intended connection. Logout still includes the saved credential location.', 4);
201
+ }
202
+ return { ...latest, credentialStore: profile.credentialStore };
203
+ }, signal);
204
+ if (positionals[0] === 'auth' && ['login', 'import'].includes(positionals[1]))
205
+ profile.credentialStore = storeFlag(flags) ?? profile.credentialStore;
206
+ const vault = new CredentialVault(config.root, name, profile);
207
+ const auth = new AuthSession(profile, vault, env, signal);
208
+ const client = new ApiClient(profile.apiUrl, () => auth.accessToken(), scope => auth.loginHint(scope));
209
+ const exporting = positionals[0] === 'history' && positionals[1] === 'export';
210
+ const options = { timeoutMs: integerFlag(flags, 'timeout', exporting ? 600 : 30, 1, exporting ? 3600 : 120) * 1000, retries: integerFlag(flags, 'retries', 2, 0, 3), signal };
211
+ if (positionals[0] === 'auth') {
212
+ if (positionals.length !== 2)
213
+ usage('Usage: stand auth login|import|status|logout.');
214
+ switch (positionals[1]) {
215
+ case 'login': {
216
+ checkOptions(flags, ['scopes', 'setup', 'credential-store']);
217
+ if (flags.setup && flags.scopes !== undefined)
218
+ usage('--setup and --scopes are mutually exclusive. Choose the setup preset or an explicit permission list.');
219
+ if (flags['no-input'])
220
+ usage('Device login requires browser interaction. First run stand auth login interactively and approve it in the browser; later commands can use that saved session without input.');
221
+ await vault.preflight();
222
+ const explicitScopes = flags.setup ? SETUP_SCOPES.join(' ') : stringFlag(flags, 'scopes');
223
+ const saved = await deviceLogin(profile, await scopesForLogin(vault, explicitScopes), {
224
+ display: info => process.stderr.write(json
225
+ ? `${JSON.stringify({ event: 'device_authorization', ...info })}\n`
226
+ : `Open ${info.verificationUri}\nEnter code: ${info.userCode}\nWaiting for approval (expires in ${info.expiresIn} seconds). Press Ctrl-C to cancel.\n`),
227
+ }, signal);
228
+ await vault.locked(() => vault.save(saved), signal);
229
+ // Environment URL overrides apply to this invocation, never to saved endpoints.
230
+ await publishCredentialStore();
231
+ output({ profile: name, apiUrl: profile.apiUrl, credentialStored: true, expiresAt: new Date(saved.expiresAt).toISOString(), scopes: saved.scopes }, json);
232
+ return;
233
+ }
234
+ case 'import': {
235
+ checkOptions(flags, ['token-stdin', 'credential-store']);
236
+ if (!flags['token-stdin'] || process.stdin.isTTY)
237
+ usage('Import requires --token-stdin and a pipe. No token argument or echoed terminal input is accepted.');
238
+ const token = (await readInput(process.stdin, 64 * 1024, signal)).trim();
239
+ if (!token || /\s/.test(token))
240
+ usage('stdin must contain one nonempty access token.');
241
+ await vault.locked(() => vault.save({ version: 1, apiUrl: profile.apiUrl, issuer: profile.issuer, clientId: 'stand-cli', accessToken: token }), signal);
242
+ // Environment URL overrides apply to this invocation, never to saved endpoints.
243
+ await publishCredentialStore();
244
+ output({ profile: name, apiUrl: profile.apiUrl, credentialStored: true, source: 'import', verified: false }, json);
245
+ return;
246
+ }
247
+ case 'status': {
248
+ checkOptions(flags, ['check']);
249
+ const provided = auth.environmentToken();
250
+ const verified = flags.check ? await client.request('/v1/reps/me', options) : undefined;
251
+ const saved = provided ? undefined : await vault.load();
252
+ if (signal?.aborted)
253
+ throw new CliError('CANCELLED', 'Operation cancelled.', 130);
254
+ output({ profile: name, apiUrl: profile.apiUrl, issuer: profile.issuer, credentialPresent: Boolean(provided || saved),
255
+ source: provided ? 'environment' : profile.credentialStore ?? 'keychain',
256
+ expiresAt: saved?.expiresAt === undefined ? null : new Date(saved.expiresAt).toISOString(),
257
+ scopes: saved?.scopes ?? null, verified: Boolean(flags.check), ...(verified === undefined ? {} : { user: verified }) }, json);
258
+ return;
259
+ }
260
+ case 'logout':
261
+ checkOptions(flags, ['local-only']);
262
+ output({ profile: name, ...await auth.logout(Boolean(flags['local-only'])), environmentTokenRemoved: false }, json);
263
+ return;
264
+ default: return usage('Usage: stand auth login|import|status|logout.');
265
+ }
266
+ }
267
+ if (positionals[0] === 'setup') {
268
+ checkOptions(flags, ['app-url']);
269
+ if (positionals.length !== 2 || positionals[1] !== 'status')
270
+ usage('Usage: stand setup status [--app-url URL].');
271
+ output(await setupStatus(client, profile, options, stringFlag(flags, 'app-url')), json);
272
+ return;
273
+ }
274
+ if (positionals[0] === 'api') {
275
+ checkOptions(flags, ['body-file', 'query-param', 'form-string', 'form-file']);
276
+ if (positionals.length !== 3)
277
+ usage('Usage: stand api METHOD /v1/path [--body-file FILE] [--query-param key=value].');
278
+ const method = positionals[1].toUpperCase();
279
+ if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method))
280
+ usage('Unsupported HTTP method.');
281
+ const url = apiUrl(profile.apiUrl, positionals[2]);
282
+ if (!publicOperation(url.pathname, method))
283
+ usage('This operation is not in the public API contract. Run stand schema.');
284
+ for (const pair of flags['query-param'] ?? []) {
285
+ const separator = pair.indexOf('=');
286
+ if (separator <= 0)
287
+ usage('--query-param must be key=value.');
288
+ url.searchParams.append(pair.slice(0, separator), pair.slice(separator + 1));
289
+ }
290
+ if ([...url.searchParams.keys()].some(key => /^(?:token|access_token|authorization|refresh_token)$/i.test(key)))
291
+ usage('Credentials must not be placed in API URLs.');
292
+ const hasForm = flags['form-string'] !== undefined || flags['form-file'] !== undefined;
293
+ if (hasForm && flags['body-file'] !== undefined)
294
+ usage('Multipart form flags and --body-file are mutually exclusive.');
295
+ if (['GET', 'HEAD'].includes(method) && (flags['body-file'] || hasForm))
296
+ usage('GET and HEAD do not accept a body.');
297
+ const siteUpdate = method === 'PUT' && /^\/v1\/sites\/[^/]+\/?$/.test(url.pathname);
298
+ // Decode the exact Site payload before approval and reuse it for dispatch;
299
+ // editing a body file while the prompt is open cannot change the approved write.
300
+ const preparedBody = siteUpdate && !hasForm ? await requestBody(flags, process.stdin, signal) : undefined;
301
+ if (!['GET', 'HEAD'].includes(method))
302
+ await confirm(`${method} ${url.pathname}`, flags, signal, legacyEmbedWarning(preparedBody));
303
+ const multipart = hasForm ? await multipartBody(url.pathname, method, flags['form-string'] ?? [], flags['form-file'] ?? [], signal) : undefined;
304
+ const body = hasForm ? undefined : siteUpdate ? preparedBody : await requestBody(flags, process.stdin, signal);
305
+ output(await client.request(`${url.pathname}${url.search}`, { ...options, method, body, multipart }), json);
306
+ return;
307
+ }
308
+ const match = findCommand(positionals);
309
+ if (!match)
310
+ usage('Unknown command. Run stand --help or stand commands --json.');
311
+ checkOptions(flags, match.definition.options);
312
+ const preparedBody = match.definition.command === 'sites update' ? await requestBody(flags, process.stdin, signal) : undefined;
313
+ if (match.definition.confirmation)
314
+ await confirm(`${match.definition.command} ${match.args.join(' ')}`.trim(), flags, signal, legacyEmbedWarning(preparedBody));
315
+ if ((flags.interval !== undefined || flags.count !== undefined) && !flags.watch)
316
+ usage('--interval and --count require --watch.');
317
+ const count = flags.watch ? integerFlag(flags, 'count', Number.MAX_SAFE_INTEGER, 1, 1_000_000) : 1;
318
+ const interval = integerFlag(flags, 'interval', 5, 2, 3600) * 1000;
319
+ for (let iteration = 0; iteration < count; iteration++) {
320
+ if (iteration > 0)
321
+ await sleep(interval, signal);
322
+ const result = await executeCommand(client, match.definition, match.args, flags, process.stdin, options, preparedBody);
323
+ output(result, json);
324
+ }
325
+ }
326
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
327
+ const controller = new AbortController();
328
+ const cancel = () => controller.abort();
329
+ process.on('SIGINT', cancel);
330
+ process.on('SIGTERM', cancel);
331
+ process.on('SIGHUP', cancel);
332
+ process.stdout.on('error', error => {
333
+ if (isObject(error) && error.code === 'EPIPE') {
334
+ process.exit(0);
335
+ }
336
+ });
337
+ main(undefined, process.env, controller.signal).catch(error => {
338
+ const failure = error instanceof CliError ? error : new CliError('LOCAL_ERROR', 'The command failed locally. Check file permissions and configuration.');
339
+ if (process.argv.includes('--json') || !process.stderr.isTTY) {
340
+ process.stderr.write(`${terminalJson({ error: { code: failure.code, message: failure.message, ...(failure.status === undefined ? {} : { status: failure.status }), ...(failure.retryAfter ? { retryAfter: failure.retryAfter } : {}), ...(failure.reconciliation ? { reconciliation: failure.reconciliation } : {}) } })}\n`);
341
+ }
342
+ else {
343
+ process.stderr.write(`${terminalText(failure.code)}: ${terminalText(failure.message)}\n`);
344
+ if (failure.reconciliation)
345
+ process.stderr.write(`Reconciliation: ${terminalJson(failure.reconciliation)}\n`);
346
+ }
347
+ process.exitCode = failure.exitCode;
348
+ }).finally(() => { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); process.removeListener('SIGHUP', cancel); });
349
+ }
@@ -0,0 +1,115 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { basename, extname } from 'node:path';
4
+ import { CliError, isObject, usage } from './errors.js';
5
+ import { publicOperation } from './contract.js';
6
+ const MAX_TEXT_BYTES = 1024 * 1024;
7
+ const MAX_FORM_BYTES = 11 * 1024 * 1024; // Leave room inside the server's 12 MiB multipart envelope.
8
+ const IMAGE_UPLOAD_LIMITS = {
9
+ uploadRepAvatar: 5, uploadDraftAvatar: 5, uploadStandinAvatar: 5,
10
+ uploadMyIcon: 5, uploadMyLogo: 5, uploadIcon: 5, uploadLogo: 5,
11
+ uploadStandinPinIcon: 10, uploadStandinPinLogo: 10,
12
+ };
13
+ const MIME = {
14
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', '.markdown': 'text/markdown', '.csv': 'text/csv',
15
+ '.html': 'text/html', '.json': 'application/json', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
16
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif',
17
+ };
18
+ const cancelled = (signal) => { if (signal?.aborted)
19
+ throw new CliError('CANCELLED', 'Upload preparation cancelled. No request was sent.', 130); };
20
+ /** Construct only a published multipart operation; never interpret @values or URLs as files. */
21
+ export async function multipartBody(path, method, strings, files, signal) {
22
+ const operation = publicOperation(path, method);
23
+ const requestBody = isObject(operation?.requestBody) ? operation.requestBody : undefined;
24
+ const content = isObject(requestBody?.content) ? requestBody.content : undefined;
25
+ const media = isObject(content?.['multipart/form-data']) ? content['multipart/form-data'] : undefined;
26
+ const schema = isObject(media?.schema) ? media.schema : undefined;
27
+ const properties = isObject(schema?.properties) ? schema.properties : undefined;
28
+ if (!operation || !schema || !properties || !['POST', 'PUT', 'PATCH'].includes(method))
29
+ usage('This public operation does not accept multipart form data. Use stand schema to inspect its request.');
30
+ const entries = [...strings.map(value => ({ value, file: false })), ...files.map(value => ({ value, file: true }))];
31
+ if (entries.length > 20)
32
+ usage('Multipart uploads accept at most 20 parts.');
33
+ const seen = new Set();
34
+ const parsed = entries.map(({ value, file }) => {
35
+ const separator = value.indexOf('=');
36
+ const name = value.slice(0, separator);
37
+ if (separator < 1 || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) || /^(?:token|access_token|authorization|refresh_token)$/i.test(name))
38
+ usage('Form parts must use a published field name followed by = and its literal value or file path. Credentials are not form fields.');
39
+ if (seen.has(name))
40
+ usage(`Duplicate form field: ${name}.`);
41
+ seen.add(name);
42
+ const property = properties[name];
43
+ if (!isObject(property) || property.type !== 'string' || (property.format === 'binary') !== file)
44
+ usage(`Form field ${name} is not a published ${file ? 'file' : 'text'} field for this operation.`);
45
+ const input = value.slice(separator + 1);
46
+ if (file && !input)
47
+ usage(`Form field ${name} requires a local file path.`);
48
+ if (!file && (Buffer.byteLength(input, 'utf8') > MAX_TEXT_BYTES
49
+ || (typeof property.maxLength === 'number' && input.length > property.maxLength)
50
+ || (Array.isArray(property.enum) && !property.enum.includes(input))))
51
+ usage(`Form field ${name} exceeds its limit or is not an allowed value. Inspect stand schema.`);
52
+ return { name, input, file };
53
+ });
54
+ if (Array.isArray(schema.required) && schema.required.some(name => typeof name === 'string' && !seen.has(name)))
55
+ usage(`Missing required multipart fields: ${schema.required.filter(name => typeof name === 'string' && !seen.has(name)).join(', ')}.`);
56
+ const form = new FormData();
57
+ let total = 0;
58
+ for (const part of parsed) {
59
+ cancelled(signal);
60
+ if (!part.file) {
61
+ total += Buffer.byteLength(part.input, 'utf8');
62
+ if (total > MAX_FORM_BYTES)
63
+ usage('Multipart fields and files exceed the 11 MiB CLI payload bound.');
64
+ form.append(part.name, part.input);
65
+ continue;
66
+ }
67
+ const filename = basename(part.input);
68
+ if (!filename || Buffer.byteLength(filename) > 255 || /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/.test(filename))
69
+ usage('Upload filename must be at most 255 bytes and contain no control or bidirectional formatting characters.');
70
+ const contentType = MIME[extname(filename).toLowerCase()] ?? 'application/octet-stream';
71
+ const feedback = operation.operationId === 'submitFeedback';
72
+ const imageLimit = typeof operation.operationId === 'string' ? IMAGE_UPLOAD_LIMITS[operation.operationId] : undefined;
73
+ if (operation.operationId === 'uploadKnowledgeBaseSource' && !['.pdf', '.md', '.markdown', '.txt', '.docx'].includes(extname(filename).toLowerCase()))
74
+ usage('Knowledge uploads support PDF, Markdown, text and Word .docx files.');
75
+ if (feedback && !contentType.startsWith('image/'))
76
+ usage('Feedback attachments must use a .png, .jpg, .jpeg, .webp or .gif image filename.');
77
+ if (imageLimit && !contentType.startsWith('image/'))
78
+ usage('Image uploads must use a .png, .jpg, .jpeg, .webp or .gif filename.');
79
+ const limit = (feedback ? 4 : imageLimit ?? 10) * 1024 * 1024;
80
+ let handle;
81
+ try {
82
+ handle = await open(part.input, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
83
+ }
84
+ catch {
85
+ throw new CliError('UPLOAD_FILE', 'Cannot open the upload. Use a readable local regular file, not a symlink or URL. No request was sent.', 2);
86
+ }
87
+ try {
88
+ const stat = await handle.stat();
89
+ if (!stat.isFile() || stat.size < 1 || stat.size > limit || total + stat.size > MAX_FORM_BYTES)
90
+ usage(`Upload must be a nonempty regular file of at most ${limit / 1024 / 1024} MiB; total multipart payload must stay within 11 MiB.`);
91
+ const chunks = [];
92
+ let size = 0;
93
+ while (true) {
94
+ cancelled(signal);
95
+ const buffer = Buffer.alloc(Math.min(64 * 1024, limit - size + 1));
96
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
97
+ if (!bytesRead)
98
+ break;
99
+ size += bytesRead;
100
+ if (size > limit || total + size > MAX_FORM_BYTES)
101
+ usage('Upload grew beyond its file or aggregate limit. No request was sent.');
102
+ chunks.push(buffer.subarray(0, bytesRead));
103
+ }
104
+ if (size !== stat.size)
105
+ usage('Upload changed while it was being read. Retry only after the file is stable.');
106
+ total += size;
107
+ form.append(part.name, new Blob(chunks, { type: contentType }), filename);
108
+ }
109
+ finally {
110
+ await handle.close();
111
+ }
112
+ }
113
+ cancelled(signal);
114
+ return form;
115
+ }