@tuuject/surowan-utils 0.0.0-stage → 0.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.
@@ -0,0 +1,172 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname, join } from 'node:path';
6
+ import { isRecord, parseEndpoint } from "./client.js";
7
+ import { CliError, usageError } from "./errors.js";
8
+ /** A credentials file is tiny; anything larger is not one this tool wrote. */
9
+ export const MAX_CREDENTIALS_BYTES = 16 * 1024;
10
+ const MAX_API_KEY_LENGTH = 4096;
11
+ /** Whitespace (including Unicode spaces) and control characters. */
12
+ const FORBIDDEN_API_KEY_CHARACTERS = /[\p{White_Space}\p{Cc}]/u;
13
+ const INVALID_CREDENTIALS = 'invalid_credentials';
14
+ /**
15
+ * `~/.config/surowan-utils/credentials.json` on every platform, Windows
16
+ * included: one documented location beats an XDG/AppData guess that would hide
17
+ * the file from users who followed the guide.
18
+ */
19
+ export function credentialsPath(homeDir = homedir()) {
20
+ return join(homeDir, '.config', 'surowan-utils', 'credentials.json');
21
+ }
22
+ /**
23
+ * A key is an opaque token: no `pwk_` prefix is required, and synthetic keys
24
+ * (including ones ending in a backslash) must round-trip unchanged. Only what
25
+ * would corrupt a header or a log line is refused.
26
+ */
27
+ export function validateApiKey(value) {
28
+ if (value.length < 1 || value.length > MAX_API_KEY_LENGTH || FORBIDDEN_API_KEY_CHARACTERS.test(value)) {
29
+ throw usageError('invalid_api_key', `API key must be 1 to ${MAX_API_KEY_LENGTH} characters with no whitespace or control characters`);
30
+ }
31
+ return value;
32
+ }
33
+ /**
34
+ * Reads the stored credentials, or `undefined` when nothing is stored yet.
35
+ * Every other anomaly fails fast with a fixed `CliError`: a JSON parse error
36
+ * quotes the surrounding text, and that text is the key.
37
+ */
38
+ export async function readCredentials(path) {
39
+ const info = await inspectCredentialsFile(path);
40
+ if (info === undefined) {
41
+ return undefined;
42
+ }
43
+ if (info.size > MAX_CREDENTIALS_BYTES) {
44
+ throw invalidCredentials(path, `credentials file must not exceed ${MAX_CREDENTIALS_BYTES} bytes`);
45
+ }
46
+ await assertCredentialsDirectory(dirname(path), path);
47
+ let text;
48
+ try {
49
+ text = await readFile(path, 'utf8');
50
+ }
51
+ catch {
52
+ throw invalidCredentials(path, 'credentials file could not be read');
53
+ }
54
+ return parseStoredCredentials(text, path);
55
+ }
56
+ /**
57
+ * Writes the complete pair over whatever was stored before. The bytes land in
58
+ * a freshly created 0600 file that is renamed into place, so a failed write
59
+ * leaves the old file byte-for-byte intact. An existing symlink or an
60
+ * over-permissive file or directory is refused rather than replaced. No lock
61
+ * is taken: two concurrent logins are last-writer-wins.
62
+ */
63
+ export async function writeCredentials(path, credentials) {
64
+ const endpoint = parseEndpoint(credentials.endpoint).href;
65
+ const apiKey = validateApiKey(credentials.apiKey);
66
+ const text = `${JSON.stringify({ endpoint, apiKey })}\n`;
67
+ if (Buffer.byteLength(text) > MAX_CREDENTIALS_BYTES) {
68
+ throw usageError('credentials_too_large', `credentials must not exceed ${MAX_CREDENTIALS_BYTES} bytes`);
69
+ }
70
+ const directory = dirname(path);
71
+ try {
72
+ await mkdir(directory, { recursive: true, mode: 0o700 });
73
+ await assertCredentialsDirectory(directory, path);
74
+ await inspectCredentialsFile(path);
75
+ const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
76
+ try {
77
+ const handle = await open(temporary, 'wx', 0o600);
78
+ try {
79
+ await handle.writeFile(text, 'utf8');
80
+ await handle.sync();
81
+ }
82
+ finally {
83
+ await handle.close();
84
+ }
85
+ await rename(temporary, path);
86
+ }
87
+ catch (error) {
88
+ await unlink(temporary).catch(() => undefined);
89
+ throw error;
90
+ }
91
+ }
92
+ catch (error) {
93
+ if (error instanceof CliError) {
94
+ throw error;
95
+ }
96
+ // The filesystem message is dropped: it can name the temporary file, and
97
+ // the caller only ever needs to know that the write did not happen.
98
+ throw new CliError('credentials_write_failed', `credentials file could not be written: ${path}`);
99
+ }
100
+ }
101
+ /**
102
+ * In-place inspection shared by both directions. `undefined` means nothing is
103
+ * stored; anything that is present must be a regular file that group and other
104
+ * cannot read or write (POSIX modes; on Windows the home ACL is inherited).
105
+ */
106
+ async function inspectCredentialsFile(path) {
107
+ const info = await lstat(path).catch((error) => {
108
+ if (isErrno(error, 'ENOENT')) {
109
+ return undefined;
110
+ }
111
+ throw invalidCredentials(path, 'credentials file could not be inspected');
112
+ });
113
+ if (info === undefined) {
114
+ return undefined;
115
+ }
116
+ if (info.isSymbolicLink()) {
117
+ throw invalidCredentials(path, 'credentials file must not be a symbolic link');
118
+ }
119
+ if (!info.isFile()) {
120
+ throw invalidCredentials(path, 'credentials file must be a regular file');
121
+ }
122
+ if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
123
+ throw invalidCredentials(path, 'credentials file permissions must be 0600 or narrower');
124
+ }
125
+ return info;
126
+ }
127
+ /** The containing directory must be real (not a symlink) and not group/other accessible. */
128
+ async function assertCredentialsDirectory(directory, path) {
129
+ const info = await lstat(directory).catch(() => undefined);
130
+ if (info === undefined || !info.isDirectory()) {
131
+ throw invalidCredentials(path, 'credentials directory must be a real directory');
132
+ }
133
+ if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
134
+ throw invalidCredentials(path, 'credentials directory permissions must be 0700 or narrower');
135
+ }
136
+ }
137
+ function parseStoredCredentials(text, path) {
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(text);
141
+ }
142
+ catch {
143
+ throw invalidCredentials(path, 'credentials file is not valid JSON');
144
+ }
145
+ if (!isRecord(parsed)) {
146
+ throw invalidCredentials(path, 'credentials file must contain exactly "endpoint" and "apiKey" strings');
147
+ }
148
+ const { endpoint: rawEndpoint, apiKey: rawApiKey } = parsed;
149
+ if (Object.keys(parsed).length !== 2 || typeof rawEndpoint !== 'string' || typeof rawApiKey !== 'string') {
150
+ throw invalidCredentials(path, 'credentials file must contain exactly "endpoint" and "apiKey" strings');
151
+ }
152
+ let endpoint;
153
+ try {
154
+ endpoint = parseEndpoint(rawEndpoint).href;
155
+ }
156
+ catch {
157
+ throw invalidCredentials(path, 'credentials file endpoint is invalid');
158
+ }
159
+ try {
160
+ validateApiKey(rawApiKey);
161
+ }
162
+ catch {
163
+ throw invalidCredentials(path, 'credentials file API key is invalid');
164
+ }
165
+ return { endpoint, apiKey: rawApiKey };
166
+ }
167
+ function invalidCredentials(path, reason) {
168
+ return new CliError(INVALID_CREDENTIALS, `${reason}: ${path}`);
169
+ }
170
+ function isErrno(error, code) {
171
+ return error instanceof Error && error.code === code;
172
+ }
@@ -0,0 +1,10 @@
1
+ /** A failure with a stable machine-readable code and a process exit code. */
2
+ export declare class CliError extends Error {
3
+ readonly code: string;
4
+ readonly exitCode: number;
5
+ constructor(code: string, message: string, options?: {
6
+ readonly exitCode?: number;
7
+ });
8
+ }
9
+ /** Bad invocation input: exit code 2, like conventional CLI usage errors. */
10
+ export declare function usageError(code: string, message: string): CliError;
package/dist/errors.js ADDED
@@ -0,0 +1,15 @@
1
+ /** A failure with a stable machine-readable code and a process exit code. */
2
+ export class CliError extends Error {
3
+ code;
4
+ exitCode;
5
+ constructor(code, message, options = {}) {
6
+ super(message);
7
+ this.name = 'CliError';
8
+ this.code = code;
9
+ this.exitCode = options.exitCode ?? 1;
10
+ }
11
+ }
12
+ /** Bad invocation input: exit code 2, like conventional CLI usage errors. */
13
+ export function usageError(code, message) {
14
+ return new CliError(code, message, { exitCode: 2 });
15
+ }
@@ -0,0 +1,2 @@
1
+ export declare function promptLoginField(label: string, secret: boolean, timeoutMs: number): Promise<string>;
2
+ export declare function readLoginKey(timeoutMs: number): Promise<string>;
package/dist/login.js ADDED
@@ -0,0 +1,65 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { Writable } from 'node:stream';
4
+ import { CliError, usageError } from "./errors.js";
5
+ export async function promptLoginField(label, secret, timeoutMs) {
6
+ if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
7
+ throw usageError('login_input_required', 'login requires a terminal or an explicit endpoint and API key; use --api-key-stdin for piped keys');
8
+ }
9
+ // readline still handles editing and terminal raw-mode restoration, but none
10
+ // of the secret input (including paste and backspace) reaches its output.
11
+ const output = secret ? new Writable({ write: (_chunk, _encoding, callback) => callback() }) : process.stderr;
12
+ const terminal = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
13
+ const cancelled = new AbortController();
14
+ const timeout = AbortSignal.timeout(timeoutMs);
15
+ const cancel = () => cancelled.abort();
16
+ terminal.on('SIGINT', cancel);
17
+ terminal.on('close', cancel);
18
+ process.stderr.write(label);
19
+ try {
20
+ return await terminal.question('', { signal: AbortSignal.any([cancelled.signal, timeout]) });
21
+ }
22
+ catch {
23
+ if (timeout.aborted) {
24
+ throw new CliError('timeout', `login input timed out after ${timeoutMs}ms; no credentials were saved`);
25
+ }
26
+ throw usageError('login_cancelled', 'login was cancelled; no credentials were saved');
27
+ }
28
+ finally {
29
+ terminal.removeListener('SIGINT', cancel);
30
+ terminal.removeListener('close', cancel);
31
+ terminal.close();
32
+ if (secret) {
33
+ process.stderr.write('\n');
34
+ output.destroy();
35
+ }
36
+ }
37
+ }
38
+ export async function readLoginKey(timeoutMs) {
39
+ if (process.stdin.isTTY === true) {
40
+ throw usageError('stdin_required', '--api-key-stdin requires a piped API key, not a terminal');
41
+ }
42
+ const timer = setTimeout(() => {
43
+ process.stdin.destroy(new CliError('timeout', `stdin timed out after ${timeoutMs}ms; no credentials were saved`));
44
+ }, timeoutMs);
45
+ const chunks = [];
46
+ let total = 0;
47
+ try {
48
+ for await (const chunk of process.stdin) {
49
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
50
+ total += buffer.byteLength;
51
+ // Permit the usual trailing CRLF without increasing the 4 KiB key cap.
52
+ if (total > 4_098) {
53
+ process.stdin.destroy();
54
+ throw usageError('invalid_api_key', 'API key input exceeds 4 KiB');
55
+ }
56
+ chunks.push(buffer);
57
+ }
58
+ return Buffer.concat(chunks)
59
+ .toString('utf8')
60
+ .replace(/\r?\n$/, '');
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }
@@ -0,0 +1,9 @@
1
+ import type { MediaCommand } from './args.ts';
2
+ import type { AdminClient } from './client.ts';
3
+ import type { CommandContext } from './commands.ts';
4
+ /** Bounds for one `invocation media` run; requests are sequential and bounded by these. */
5
+ export declare const MAX_MEDIA_ITEMS = 32;
6
+ export declare const MAX_MEDIA_FILE_BYTES: number;
7
+ export declare const MAX_MEDIA_TOTAL_BYTES: number;
8
+ export declare const MANIFEST_FILE_NAME = "manifest.json";
9
+ export declare function runMedia(command: MediaCommand, context: CommandContext, client: AdminClient): Promise<number>;
package/dist/media.js ADDED
@@ -0,0 +1,220 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { isRecord } from "./client.js";
6
+ import { CliError } from "./errors.js";
7
+ /** Bounds for one `invocation media` run; requests are sequential and bounded by these. */
8
+ export const MAX_MEDIA_ITEMS = 32;
9
+ export const MAX_MEDIA_FILE_BYTES = 20 * 1024 * 1024;
10
+ export const MAX_MEDIA_TOTAL_BYTES = 100 * 1024 * 1024;
11
+ export const MANIFEST_FILE_NAME = 'manifest.json';
12
+ /**
13
+ * Known MIME types and the extension used for the saved file. Content types
14
+ * outside this table (but syntactically valid) are stored as `.bin`; a value
15
+ * that is not a MIME type at all is refused.
16
+ */
17
+ const MEDIA_EXTENSIONS = new Map([
18
+ ['image/jpeg', 'jpg'],
19
+ ['image/png', 'png'],
20
+ ['image/webp', 'webp'],
21
+ ['image/gif', 'gif'],
22
+ ['image/bmp', 'bmp'],
23
+ ['image/tiff', 'tiff'],
24
+ ['video/mp4', 'mp4'],
25
+ ['video/webm', 'webm'],
26
+ ['video/quicktime', 'mov'],
27
+ ['audio/ogg', 'ogg'],
28
+ ['audio/mpeg', 'mp3'],
29
+ ['audio/mp4', 'm4a'],
30
+ ['audio/wav', 'wav'],
31
+ ['audio/x-wav', 'wav'],
32
+ ['audio/flac', 'flac'],
33
+ ['application/pdf', 'pdf'],
34
+ ['application/zip', 'zip'],
35
+ ['application/json', 'json'],
36
+ ['application/octet-stream', 'bin'],
37
+ ['application/x-tgsticker', 'tgs'],
38
+ ['text/plain', 'txt'],
39
+ ]);
40
+ const MEDIA_ID_PATTERN = /^\d{1,19}$/;
41
+ const MIME_PATTERN = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
42
+ export async function runMedia(command, context, client) {
43
+ const invocationId = command.id;
44
+ const items = parseMediaList(await client.get(`api/invocations/${invocationId}/media`), invocationId);
45
+ if (items.length > MAX_MEDIA_ITEMS) {
46
+ throw new CliError('media_too_many_items', `media list has ${items.length} items; at most ${MAX_MEDIA_ITEMS} can be downloaded`);
47
+ }
48
+ const declaredBytes = items.reduce((total, item) => total + (item.file_size ?? 0), 0);
49
+ if (declaredBytes > MAX_MEDIA_TOTAL_BYTES) {
50
+ throw new CliError('media_total_too_large', `media list declares ${declaredBytes} bytes; the total limit is ${MAX_MEDIA_TOTAL_BYTES}`);
51
+ }
52
+ const directory = await mkdtemp(join(tmpdir(), 'surowan-media-'));
53
+ const records = [];
54
+ let totalBytes = 0;
55
+ for (const item of items) {
56
+ const outcome = await downloadItem(item, {
57
+ invocationId,
58
+ variant: command.variant,
59
+ directory,
60
+ client,
61
+ budgetBytes: MAX_MEDIA_TOTAL_BYTES - totalBytes,
62
+ });
63
+ totalBytes += outcome.bytes;
64
+ records.push(outcome.record);
65
+ }
66
+ const manifest = { invocation_id: invocationId, variant: command.variant, directory, items: records };
67
+ const failures = records.filter((record) => record.status === 'failed');
68
+ const manifestJson = context.redact(JSON.stringify(manifest, null, 2));
69
+ if (records.length > 0 && failures.length === records.length) {
70
+ await rm(directory, { recursive: true, force: true });
71
+ printManifest(manifest, manifestJson, context);
72
+ throw mediaFailure(failures, records.length);
73
+ }
74
+ try {
75
+ await writeFile(join(directory, MANIFEST_FILE_NAME), `${manifestJson}\n`, { flag: 'wx', mode: 0o600 });
76
+ }
77
+ catch (error) {
78
+ await rm(directory, { recursive: true, force: true });
79
+ throw new CliError('media_manifest_write_failed', `could not write ${MANIFEST_FILE_NAME}: ${messageOf(error)}`);
80
+ }
81
+ printManifest(manifest, manifestJson, context);
82
+ if (failures.length > 0) {
83
+ throw mediaFailure(failures, records.length);
84
+ }
85
+ return 0;
86
+ }
87
+ async function downloadItem(item, options) {
88
+ if (!item.variants.includes(options.variant)) {
89
+ return {
90
+ record: failureRecord(item, 'variant_not_available', `variant ${options.variant} is not available for this item`),
91
+ bytes: 0,
92
+ };
93
+ }
94
+ if (item.file_size !== null && item.file_size > MAX_MEDIA_FILE_BYTES) {
95
+ return {
96
+ record: failureRecord(item, 'media_file_too_large', `item declares ${item.file_size} bytes; the per-file limit is ${MAX_MEDIA_FILE_BYTES}`),
97
+ bytes: 0,
98
+ };
99
+ }
100
+ if (item.file_size !== null && item.file_size > options.budgetBytes) {
101
+ return { record: budgetFailure(item, options.budgetBytes), bytes: 0 };
102
+ }
103
+ const cap = Math.min(MAX_MEDIA_FILE_BYTES, options.budgetBytes);
104
+ let download;
105
+ try {
106
+ download = await options.client.getMedia(`api/invocations/${options.invocationId}/media/${item.id}/content`, new URLSearchParams({ variant: options.variant }), cap);
107
+ }
108
+ catch (error) {
109
+ if (error instanceof CliError && error.code === 'media_file_too_large' && cap < MAX_MEDIA_FILE_BYTES) {
110
+ return { record: budgetFailure(item, options.budgetBytes), bytes: 0 };
111
+ }
112
+ return { record: failureRecord(item, errorCode(error), messageOf(error)), bytes: 0 };
113
+ }
114
+ if (download.variant !== options.variant) {
115
+ return {
116
+ record: failureRecord(item, 'invalid_response', `media content did not confirm variant ${options.variant}`),
117
+ bytes: 0,
118
+ };
119
+ }
120
+ const contentType = download.contentType;
121
+ if (contentType !== null && !MIME_PATTERN.test(contentType)) {
122
+ return { record: failureRecord(item, 'invalid_response', 'media content type is not a valid MIME type'), bytes: 0 };
123
+ }
124
+ const extension = contentType === null ? 'bin' : (MEDIA_EXTENSIONS.get(contentType) ?? 'bin');
125
+ const path = join(options.directory, `${item.id}.${extension}`);
126
+ try {
127
+ await writeFile(path, download.bytes, { flag: 'wx', mode: 0o600 });
128
+ }
129
+ catch (error) {
130
+ await rm(path, { force: true }).catch(() => undefined);
131
+ return { record: failureRecord(item, 'media_write_failed', messageOf(error)), bytes: 0 };
132
+ }
133
+ const bytes = download.bytes.byteLength;
134
+ return {
135
+ record: {
136
+ id: item.id,
137
+ message_id: item.message_id,
138
+ revision_id: item.revision_id,
139
+ kind: item.kind,
140
+ status: 'downloaded',
141
+ path,
142
+ mime_type: contentType,
143
+ bytes,
144
+ sha256: createHash('sha256').update(download.bytes).digest('hex'),
145
+ error: null,
146
+ },
147
+ bytes,
148
+ };
149
+ }
150
+ function failureRecord(item, code, message) {
151
+ return {
152
+ id: item.id,
153
+ message_id: item.message_id,
154
+ revision_id: item.revision_id,
155
+ kind: item.kind,
156
+ status: 'failed',
157
+ path: null,
158
+ mime_type: item.mime_type,
159
+ bytes: null,
160
+ sha256: null,
161
+ error: { code, message },
162
+ };
163
+ }
164
+ function budgetFailure(item, budgetBytes) {
165
+ return failureRecord(item, 'media_total_too_large', `this item does not fit in the remaining ${budgetBytes} bytes of the ${MAX_MEDIA_TOTAL_BYTES} byte total`);
166
+ }
167
+ function printManifest(manifest, manifestJson, context) {
168
+ context.io.stdout(context.json ? `${JSON.stringify(manifest)}\n` : `${manifestJson}\n`);
169
+ }
170
+ function mediaFailure(failures, total) {
171
+ const details = failures
172
+ .slice(0, 3)
173
+ .map((item) => `${item.id}: ${item.error?.code ?? 'failed'}${item.error?.message ? ` (${item.error.message})` : ''}`)
174
+ .join('; ');
175
+ const remaining = failures.length > 3 ? `; +${failures.length - 3} more` : '';
176
+ return new CliError('media_download_failed', `${failures.length} of ${total} media items failed: ${details}${remaining}`);
177
+ }
178
+ function errorCode(error) {
179
+ return error instanceof CliError ? error.code : 'internal_error';
180
+ }
181
+ function messageOf(error) {
182
+ if (error instanceof Error && error.message.length > 0) {
183
+ return error.message;
184
+ }
185
+ return 'unexpected failure';
186
+ }
187
+ function parseMediaList(raw, invocationId) {
188
+ if (!isRecord(raw) || raw.invocation_id !== invocationId || !Array.isArray(raw.items)) {
189
+ throw new CliError('invalid_response', 'media list response has an unexpected shape');
190
+ }
191
+ return raw.items.map((item) => parseMediaItem(item));
192
+ }
193
+ function parseMediaItem(raw) {
194
+ if (!isRecord(raw) ||
195
+ typeof raw.id !== 'string' ||
196
+ !MEDIA_ID_PATTERN.test(raw.id) ||
197
+ !isNonEmptyString(raw.message_id) ||
198
+ !isNonEmptyString(raw.revision_id) ||
199
+ !isNonEmptyString(raw.kind) ||
200
+ !(raw.mime_type === null || typeof raw.mime_type === 'string') ||
201
+ !(raw.file_size === null ||
202
+ (typeof raw.file_size === 'number' && Number.isSafeInteger(raw.file_size) && raw.file_size >= 0)) ||
203
+ !Array.isArray(raw.variants) ||
204
+ raw.variants.length === 0 ||
205
+ !raw.variants.every((variant) => variant === 'original' || variant === 'preview')) {
206
+ throw new CliError('invalid_response', 'media list item has an unexpected shape');
207
+ }
208
+ return {
209
+ id: raw.id,
210
+ message_id: raw.message_id,
211
+ revision_id: raw.revision_id,
212
+ kind: raw.kind,
213
+ mime_type: raw.mime_type,
214
+ file_size: raw.file_size,
215
+ variants: raw.variants,
216
+ };
217
+ }
218
+ function isNonEmptyString(value) {
219
+ return typeof value === 'string' && value.length > 0;
220
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { type CliIo } from './commands.ts';
2
+ export declare const USAGE = "surowan-utils - Plastic Wan Admin API \u5DE5\u5177\u5BA2\u6237\u7AEF\n\n\u7528\u6CD5:\n surowan-utils login [--endpoint <url>] [--api-key-stdin] [--json]\n surowan-utils doctor [--json]\n surowan-utils models list [--json]\n surowan-utils config show [--source active|file] [--json]\n surowan-utils prompt get global [--source active|file] [--json]\n surowan-utils prompt get group --chat <id> [--source active|file] [--json]\n surowan-utils invocation list [--limit N] [--cursor ID] [--state STATE] [--chat ID] [--search <keyword>] [--at <time>] [--from <time>] [--to <time>] [--json]\n surowan-utils invocation get <id> [--json]\n surowan-utils invocation prompts <id> [--json]\n surowan-utils invocation preflight <id> [--before-send <send-id>] [--provider <alias> --model <id>] [--thinking-level <level>] [--json]\n surowan-utils invocation media <id> [--variant original|preview] [--json]\n surowan-utils invocation replay <id> [--before-send <send-id> --confirm-paid] [--provider <alias> --model <id>] [--thinking-level <level>] [--global-prompt <file|->] [--group-prompt <file|->] [--json]\n\n\u5168\u5C40\u9009\u9879:\n --endpoint <url> Admin Panel \u57FA\u5730\u5740\uFF08\u8986\u76D6\u73AF\u5883\u53D8\u91CF\u4E0E\u767B\u5F55\u6587\u4EF6\uFF09\uFF1B\u660E\u6587 http \u4EC5\u5141\u8BB8 loopback\n --api-key <key> API key\uFF08\u8986\u76D6 SUROWAN_API_KEY \u4E0E\u767B\u5F55\u6587\u4EF6\uFF1B\u5EFA\u8BAE\u9690\u85CF\u8F93\u5165\u3001stdin \u6216\u5B89\u5168\u73AF\u5883\u6CE8\u5165\uFF09\n --api-key-stdin \u4EC5 login\uFF1A\u8BFB\u53D6\u6807\u51C6\u8F93\u5165\u81F3 EOF\uFF0C\u63A5\u53D7\u5355\u884C API key\uFF0C\u4E0D\u56DE\u663E\n --source <active|file> config show / prompt get\uFF1A\u8BFB\u53D6\u8FD0\u884C\u65F6\u751F\u6548\u914D\u7F6E\u6216\u914D\u7F6E\u6587\u4EF6\uFF08\u9ED8\u8BA4 active\uFF09\n --variant <original|preview> \u4EC5 invocation media\uFF1A\u4E0B\u8F7D\u539F\u59CB\u6587\u4EF6\u6216\u9884\u89C8\uFF08\u9ED8\u8BA4 original\uFF09\n --provider <alias> --model <id> \u4EC5 invocation preflight/replay\uFF1A\u4E34\u65F6\u9009\u7528\u5DF2\u542F\u7528\u6A21\u578B\uFF0C\u4E24\u9879\u5FC5\u987B\u6210\u5BF9\n --thinking-level <level> \u4EC5 invocation preflight/replay\uFF1A\u4E34\u65F6\u601D\u8003\u7EA7\u522B\uFF08off|minimal|low|medium|high|xhigh|max\uFF09\n --global-prompt <file|-> \u4EC5 invocation replay\uFF1A\u66FF\u6362 global prompt\uFF1B- \u8868\u793A stdin\n --group-prompt <file|-> \u4EC5 invocation replay\uFF1A\u66FF\u6362 group prompt\uFF08\u5141\u8BB8\u7A7A\uFF09\uFF1B- \u8868\u793A stdin\n --search <keyword> \u4EC5 invocation list\uFF1A\u6309\u5B57\u9762\u5173\u952E\u8BCD\u8FC7\u6EE4\u6D88\u606F\uFF081-100 \u5B57\u7B26\uFF0C\u4E0D\u89E3\u91CA\u901A\u914D\u7B26\uFF09\n --at <time> \u4EC5 invocation list\uFF1A\u7B5B\u9009\u8BE5\u65F6\u523B\u7684 Invocation\uFF0C\u4E0E --from/--to \u4E92\u65A5\n --from <time> / --to <time> \u4EC5 invocation list\uFF1A\u7B5B\u9009\u65F6\u95F4\u8303\u56F4\n --before-send <send-id> \u4EC5 invocation preflight/replay\uFF1A\u4ECE\u8BE5 telegram_sends \u5185\u90E8 id\uFF08\u5DF2\u6210\u529F\u53D1\u9001\uFF09\u4E4B\u524D\u5F00\u59CB\u56DE\u653E\n --confirm-paid \u4EC5 invocation replay\uFF1A\u5207\u7247\u91CD\u653E\uFF08--before-send\uFF09\u4F1A\u771F\u5B9E\u8C03\u7528\u6A21\u578B\u5E76\u8BA1\u8D39\uFF0C\u9700\u663E\u5F0F\u786E\u8BA4\uFF0C\u5426\u5219\u5728\u53D1\u8BF7\u6C42\u524D\u62D2\u7EDD\n --timeout-ms <ms> \u8BF7\u6C42/\u6BCF\u6B21\u8F93\u5165\u8D85\u65F6\uFF1B\u9ED8\u8BA4\u975E replay \u547D\u4EE4 30000\uFF0Creplay 300000\uFF0C\u4E0D\u81EA\u52A8\u91CD\u8BD5\n --json \u8F93\u51FA\u7A33\u5B9A JSON\n -h, --help \u663E\u793A\u672C\u5E2E\u52A9\n\n\u65F6\u95F4\u683C\u5F0F: YYYY-MM-DD[ |T]HH:mm[:ss[.1-3\u4F4D]][Z|\u00B1HH:mm]\uFF1B\u65E5\u671F\u5FC5\u987B\u6709\u6548\u3002\u4E0D\u5E26 Z/offset \u65F6\u7531\u670D\u52A1\u7AEF\u6309 --chat \u5BF9\u5E94\u7FA4\u6216\u5168\u5C40\u65F6\u533A\u89E3\u91CA\u3002\n\u767B\u5F55\u6587\u4EF6: ~/.config/surowan-utils/credentials.json\uFF08\u672A\u52A0\u5BC6\uFF0C\u5305\u542B API key\uFF09\u3002\nlogin \u53EA\u4FDD\u5B58\u51ED\u636E\uFF1Bdoctor \u7528\u53EA\u8BFB\u8BF7\u6C42\u9A8C\u8BC1\u8FDE\u63A5\u4E0E\u9274\u6743\uFF0C\u4E0D\u8C03\u7528\u6A21\u578B\u3002\nconfig show \u4E0E prompt get \u53EA\u8BFB\u53D6\u8131\u654F\u540E\u7684\u914D\u7F6E\u4E0E prompt\uFF0C\u4E0D\u4FEE\u6539\u670D\u52A1\u7AEF\u72B6\u6001\u3002\nmodels list \u53EA\u5217\u5F53\u524D\u751F\u6548\u3001\u5DF2\u6CE8\u518C\u4E14\u63A5\u53D7\u6587\u672C\u7684\u6A21\u578B\u53CA\u652F\u6301\u7684 thinking_levels\uFF0C\u4E0D\u63A2\u6D4B\u4E0A\u6E38\u6216\u8C03\u7528\u6A21\u578B\u3002\n\u4E34\u65F6\u6A21\u578B\u548C\u601D\u8003\u7EA7\u522B\u4E0D\u5199\u914D\u7F6E\uFF1B\u6307\u5B9A\u6A21\u578B\u4F46\u672A\u6307\u5B9A thinking-level \u65F6\u7528\u76EE\u6807\u6A21\u578B\u652F\u6301\u7684\u6700\u4F4E\u7EA7\u522B\u3002\u9884\u68C0\u4E0E POST \u4F7F\u7528\u540C\u4E00\u6A21\u578B\u9009\u62E9\uFF0C\u672A\u8986\u76D6\u65F6\u7EE7\u627F\u5F53\u524D\u7FA4\u914D\u7F6E\u3002\ninvocation media \u987A\u5E8F\u4E0B\u8F7D\u5230 mkdtemp \u65B0\u5EFA\u76EE\u5F55\uFF0Cstdout \u8F93\u51FA manifest\uFF0C\u6BCF\u4E2A\u6587\u4EF6\u4E0D\u8D85\u8FC7 20MiB\u3001\u603B\u8BA1\u4E0D\u8D85\u8FC7 100MiB \u4E14\u81F3\u591A 32 \u9879\u3002\ninvocation list \u9ED8\u8BA4\u4FDD\u6301\u65E7\u8F93\u51FA\uFF1B\u670D\u52A1\u7AEF\u8FD4\u56DE matched_messages \u65F6\u4EBA\u7C7B\u53EF\u8BFB\u8F93\u51FA\u4F1A\u8FFD\u52A0\u547D\u4E2D\u6458\u8981\u3002\ninvocation replay \u5148\u8BF7\u6C42 replay-preflight\uFF1B\u4E0D\u53EF\u91CD\u653E\u6216\u4E0D\u5141\u8BB8 prompt \u8986\u76D6\u65F6\u4E0D\u53D1\u9001 replay \u8BF7\u6C42\uFF1B--before-send \u53EA\u9009\u62E9\u56DE\u653E\u8FB9\u754C\uFF0C\u4E0D\u5C5E\u4E8E prompt \u8986\u76D6\u3002\u5207\u7247\u91CD\u653E\u5FC5\u987B\u663E\u5F0F\u7ED9\u51FA --confirm-paid\uFF1A\u7F3A\u5931\u65F6\u5728\u8BFB\u53D6 stdin \u4E0E\u53D1\u9001\u4EFB\u4F55\u8BF7\u6C42\u524D\u4EE5 confirm_paid_required\uFF08\u9000\u51FA\u7801 2\uFF09\u62D2\u7EDD\uFF1B\u672A\u5207\u7247\u7684 replay \u65E0\u9700\u8BE5 flag\u3002\n\u9000\u51FA\u7801: 0 \u6210\u529F\uFF1B1 \u8BF7\u6C42\u3001replay\u3001\u8D85\u65F6\u6216\u51ED\u636E\u6587\u4EF6\u5931\u8D25\uFF1B2 \u53C2\u6570/\u8F93\u5165/\u51ED\u636E\u4E0D\u5408\u6CD5\u3002\u9519\u8BEF\u4EE5 JSON \u5199\u5230 stderr\u3002";
3
+ export interface RunOptions {
4
+ readonly env?: Record<string, string | undefined>;
5
+ readonly io?: CliIo;
6
+ readonly homeDir?: string;
7
+ }
8
+ export declare function runCli(argv: readonly string[], options?: RunOptions): Promise<number>;
9
+ /**
10
+ * The API key must never appear in output, even if a server echoes it back.
11
+ * Also used for files the CLI persists (the media manifest), so both stdout and
12
+ * on-disk metadata share one redaction contract.
13
+ */
14
+ export declare function redactSecrets(text: string, secrets: readonly (string | undefined)[]): string;