@tuuject/surowan-utils 0.0.0-canary.24.1.g5ba29720fe68

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/dist/client.js ADDED
@@ -0,0 +1,228 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { CliError, usageError } from "./errors.js";
3
+ /**
4
+ * Validates an endpoint. Plaintext http is only accepted on loopback: the API
5
+ * key travels in a header and must not cross the network unencrypted. URL
6
+ * credentials are rejected so the key can never be smuggled through a URL.
7
+ */
8
+ export function parseEndpoint(raw) {
9
+ const text = raw.trim();
10
+ let url;
11
+ try {
12
+ url = new URL(text);
13
+ }
14
+ catch {
15
+ throw usageError('invalid_endpoint', 'endpoint must be an absolute http(s) URL');
16
+ }
17
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
18
+ throw usageError('invalid_endpoint', 'endpoint must use http or https');
19
+ }
20
+ if (url.username !== '' || url.password !== '') {
21
+ throw usageError('invalid_endpoint', 'endpoint must not contain URL credentials');
22
+ }
23
+ if (url.search !== '' || url.hash !== '') {
24
+ throw usageError('invalid_endpoint', 'endpoint must not contain a query string or fragment');
25
+ }
26
+ if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
27
+ throw usageError('insecure_endpoint', 'plaintext http is only allowed for loopback endpoints; use https');
28
+ }
29
+ if (!url.pathname.endsWith('/')) {
30
+ url.pathname = `${url.pathname}/`;
31
+ }
32
+ return url;
33
+ }
34
+ function isLoopbackHost(hostname) {
35
+ const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
36
+ return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host);
37
+ }
38
+ /**
39
+ * Minimal JSON client for the Admin API. Requests never follow redirects and
40
+ * are never retried; the timeout aborts the in-flight request and the response
41
+ * body read that follows it.
42
+ */
43
+ export class AdminClient {
44
+ #baseUrl;
45
+ #apiKey;
46
+ #defaultTimeoutMs;
47
+ constructor(options) {
48
+ this.#baseUrl = options.baseUrl;
49
+ this.#apiKey = options.apiKey;
50
+ this.#defaultTimeoutMs = options.defaultTimeoutMs;
51
+ }
52
+ async get(path, query) {
53
+ return await this.#request('GET', path, query, undefined);
54
+ }
55
+ async post(path, body) {
56
+ return await this.#request('POST', path, undefined, body);
57
+ }
58
+ /**
59
+ * Downloads a media body. The same redirect/timeout rules as JSON requests
60
+ * apply; the body is bounded by `maxBytes` so an oversized or stalled stream
61
+ * fails fast instead of filling memory or disk.
62
+ */
63
+ async getMedia(path, query, maxBytes) {
64
+ const url = this.#resolve(path, query);
65
+ const init = {
66
+ method: 'GET',
67
+ headers: {
68
+ authorization: `Bearer ${this.#apiKey}`,
69
+ accept: 'application/octet-stream',
70
+ },
71
+ redirect: 'error',
72
+ signal: AbortSignal.timeout(this.#defaultTimeoutMs),
73
+ };
74
+ try {
75
+ const response = await fetch(url, init);
76
+ if (!response.ok) {
77
+ throw await responseError(response);
78
+ }
79
+ const body = await readBodyBytes(response, maxBytes);
80
+ if (body.truncated) {
81
+ throw mediaTooLarge(maxBytes);
82
+ }
83
+ return {
84
+ bytes: body.bytes,
85
+ contentType: normalizeContentType(response.headers.get('content-type')),
86
+ variant: response.headers.get('x-plasticwan-media-variant'),
87
+ };
88
+ }
89
+ catch (error) {
90
+ throw translateFetchError(error, this.#defaultTimeoutMs);
91
+ }
92
+ }
93
+ #resolve(path, query) {
94
+ const url = new URL(path.replace(/^\/+/, ''), this.#baseUrl);
95
+ if (query !== undefined) {
96
+ for (const [key, value] of query) {
97
+ url.searchParams.set(key, value);
98
+ }
99
+ }
100
+ return url;
101
+ }
102
+ async #request(method, path, query, body) {
103
+ const url = this.#resolve(path, query);
104
+ const headers = {
105
+ authorization: `Bearer ${this.#apiKey}`,
106
+ accept: 'application/json',
107
+ };
108
+ if (body !== undefined) {
109
+ headers['content-type'] = 'application/json';
110
+ }
111
+ const init = {
112
+ method,
113
+ headers,
114
+ redirect: 'error',
115
+ signal: AbortSignal.timeout(this.#defaultTimeoutMs),
116
+ };
117
+ if (body !== undefined) {
118
+ init.body = JSON.stringify(body);
119
+ }
120
+ try {
121
+ const response = await fetch(url, init);
122
+ // The body read belongs to the same try: a server that sends headers and
123
+ // then stalls must hit the timeout path, not `internal_error`.
124
+ return await readJsonResponse(response);
125
+ }
126
+ catch (error) {
127
+ throw translateFetchError(error, this.#defaultTimeoutMs);
128
+ }
129
+ }
130
+ }
131
+ function mediaTooLarge(maxBytes) {
132
+ return new CliError('media_file_too_large', `media content exceeded ${maxBytes} bytes`);
133
+ }
134
+ function translateFetchError(error, timeoutMs) {
135
+ if (error instanceof CliError) {
136
+ // A failed body read (invalid JSON, oversized body, ...) already carries a
137
+ // structured client error; it must keep its own code.
138
+ return error;
139
+ }
140
+ if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
141
+ return new CliError('timeout', `request timed out after ${timeoutMs}ms; it was not retried`);
142
+ }
143
+ const cause = error instanceof Error && error.cause instanceof Error ? error.cause.message : '';
144
+ const text = `${error instanceof Error ? error.message : String(error)} ${cause}`;
145
+ if (/redirect/i.test(text)) {
146
+ return new CliError('redirect_not_allowed', 'the server attempted a redirect; redirects are not followed');
147
+ }
148
+ return new CliError('network_error', 'the request failed before a response arrived; it was not retried');
149
+ }
150
+ async function readJsonResponse(response) {
151
+ const bodyText = await response.text();
152
+ if (!response.ok) {
153
+ throw responseErrorFromBody(response, bodyText);
154
+ }
155
+ if (bodyText.length === 0) {
156
+ throw new CliError('invalid_response', 'server returned an empty response');
157
+ }
158
+ const parsed = tryParseJson(bodyText);
159
+ if (parsed === undefined) {
160
+ throw new CliError('invalid_response', 'server response was not valid JSON');
161
+ }
162
+ return parsed;
163
+ }
164
+ async function responseError(response) {
165
+ return responseErrorFromBody(response, await response.text());
166
+ }
167
+ /** A JSON error body survives as the error code/message; anything else is http_error. */
168
+ function responseErrorFromBody(response, bodyText) {
169
+ const parsed = tryParseJson(bodyText);
170
+ if (isRecord(parsed) && typeof parsed.error === 'string' && parsed.error.length > 0) {
171
+ const message = typeof parsed.message === 'string' && parsed.message.length > 0 ? parsed.message : parsed.error;
172
+ return new CliError(parsed.error, message);
173
+ }
174
+ return new CliError('http_error', `server responded with HTTP ${response.status}`);
175
+ }
176
+ function normalizeContentType(raw) {
177
+ if (raw === null) {
178
+ return null;
179
+ }
180
+ const type = (raw.split(';')[0] ?? '').trim().toLowerCase();
181
+ return type.length === 0 ? null : type;
182
+ }
183
+ async function readBodyBytes(response, maxBytes) {
184
+ const declared = response.headers.get('content-length');
185
+ if (declared !== null && /^\d+$/.test(declared) && Number(declared) > maxBytes) {
186
+ await response.body?.cancel().catch(() => undefined);
187
+ return { bytes: Buffer.alloc(0), truncated: true };
188
+ }
189
+ const stream = response.body;
190
+ if (stream === null) {
191
+ return { bytes: Buffer.alloc(0), truncated: false };
192
+ }
193
+ const reader = stream.getReader();
194
+ const chunks = [];
195
+ let total = 0;
196
+ try {
197
+ for (;;) {
198
+ const { done, value } = await reader.read();
199
+ if (done) {
200
+ break;
201
+ }
202
+ if (value === undefined) {
203
+ continue;
204
+ }
205
+ total += value.byteLength;
206
+ if (total > maxBytes) {
207
+ await reader.cancel().catch(() => undefined);
208
+ return { bytes: Buffer.alloc(0), truncated: true };
209
+ }
210
+ chunks.push(value);
211
+ }
212
+ }
213
+ finally {
214
+ reader.releaseLock();
215
+ }
216
+ return { bytes: Buffer.concat(chunks), truncated: false };
217
+ }
218
+ function tryParseJson(text) {
219
+ try {
220
+ return JSON.parse(text);
221
+ }
222
+ catch {
223
+ return undefined;
224
+ }
225
+ }
226
+ export function isRecord(value) {
227
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
228
+ }
@@ -0,0 +1,19 @@
1
+ import type { CliCommand } from './args.ts';
2
+ import { type AdminClient } from './client.ts';
3
+ /**
4
+ * `--global-prompt` and `--group-prompt` accept at most 64Ki characters. The
5
+ * byte cap bounds the buffer while reading; the character cap is the contract.
6
+ */
7
+ export declare const MAX_PROMPT_CHARS = 65536;
8
+ export interface CliIo {
9
+ readonly stdout: (text: string) => void;
10
+ readonly stderr: (text: string) => void;
11
+ }
12
+ export interface CommandContext {
13
+ readonly json: boolean;
14
+ readonly io: CliIo;
15
+ readonly timeoutMs: number;
16
+ /** Redacts known secrets from any text the CLI prints or persists. */
17
+ readonly redact: (text: string) => string;
18
+ }
19
+ export declare function executeCommand(command: CliCommand, context: CommandContext, client: AdminClient): Promise<number>;
@@ -0,0 +1,403 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { isRecord } from "./client.js";
4
+ import { CliError, usageError } from "./errors.js";
5
+ import { runMedia } from "./media.js";
6
+ /**
7
+ * `--global-prompt` and `--group-prompt` accept at most 64Ki characters. The
8
+ * byte cap bounds the buffer while reading; the character cap is the contract.
9
+ */
10
+ export const MAX_PROMPT_CHARS = 65_536;
11
+ const MAX_PROMPT_BYTES = MAX_PROMPT_CHARS * 4;
12
+ export async function executeCommand(command, context, client) {
13
+ switch (command.kind) {
14
+ case 'list':
15
+ return await runList(command, context, client);
16
+ case 'get':
17
+ return await runGet(command, context, client);
18
+ case 'prompts':
19
+ return await runPrompts(command, context, client);
20
+ case 'preflight':
21
+ return await runPreflight(command, context, client);
22
+ case 'media':
23
+ return await runMedia(command, context, client);
24
+ case 'replay':
25
+ return await runReplay(command, context, client);
26
+ case 'models-list':
27
+ return await runModelsList(context, client);
28
+ case 'config-show':
29
+ return await runConfigShow(command, context, client);
30
+ case 'prompt-get':
31
+ return await runPromptGet(command, context, client);
32
+ }
33
+ }
34
+ async function runList(command, context, client) {
35
+ const query = new URLSearchParams();
36
+ if (command.limit !== undefined) {
37
+ query.set('limit', String(command.limit));
38
+ }
39
+ if (command.cursor !== undefined) {
40
+ query.set('cursor', command.cursor);
41
+ }
42
+ if (command.state !== undefined) {
43
+ query.set('state', command.state);
44
+ }
45
+ if (command.chat !== undefined) {
46
+ query.set('chat', command.chat);
47
+ }
48
+ if (command.search !== undefined) {
49
+ query.set('search', command.search);
50
+ }
51
+ if (command.at !== undefined) {
52
+ query.set('at', command.at);
53
+ }
54
+ if (command.from !== undefined) {
55
+ query.set('from', command.from);
56
+ }
57
+ if (command.to !== undefined) {
58
+ query.set('to', command.to);
59
+ }
60
+ const raw = await client.get('api/invocations', query);
61
+ if (!isRecord(raw) || !Array.isArray(raw.items)) {
62
+ throw new CliError('invalid_response', 'invocation list response has an unexpected shape');
63
+ }
64
+ const nextCursor = raw.next_cursor;
65
+ if (nextCursor !== null && typeof nextCursor !== 'string') {
66
+ throw new CliError('invalid_response', 'invocation list next_cursor must be a string or null');
67
+ }
68
+ context.io.stdout(context.json ? `${JSON.stringify(raw)}\n` : humanList(raw.items, nextCursor));
69
+ return 0;
70
+ }
71
+ async function runGet(command, context, client) {
72
+ const raw = await client.get(`api/invocations/${command.id}`);
73
+ if (!isRecord(raw) || typeof raw.id !== 'string') {
74
+ throw new CliError('invalid_response', 'invocation response has an unexpected shape');
75
+ }
76
+ writeDocument(raw, context);
77
+ return 0;
78
+ }
79
+ async function runPrompts(command, context, client) {
80
+ const raw = await client.get(`api/invocations/${command.id}/prompts`);
81
+ if (!isRecord(raw) ||
82
+ raw.source !== 'active' ||
83
+ typeof raw.source_invocation_id !== 'string' ||
84
+ typeof raw.global_prompt !== 'string' ||
85
+ typeof raw.group_prompt !== 'string' ||
86
+ raw.core_read_only !== true ||
87
+ !isNullableRecord(raw.template_values)) {
88
+ throw new CliError('invalid_response', 'active prompt response has an unexpected shape');
89
+ }
90
+ writeDocument(raw, context);
91
+ return 0;
92
+ }
93
+ async function runPreflight(command, context, client) {
94
+ const raw = await client.get(`api/invocations/${command.id}/replay-preflight`, preflightQuery(command));
95
+ if (!isPreflight(raw)) {
96
+ throw new CliError('invalid_response', 'replay preflight response has an unexpected shape');
97
+ }
98
+ writeDocument(raw, context);
99
+ return 0;
100
+ }
101
+ async function runReplay(command, context, client) {
102
+ // Prompt input is fully resolved (including stdin) before any request, so a
103
+ // stalled stdin never sends a preflight the old contract would not have sent.
104
+ // A sliced replay missing --confirm-paid is already refused while parsing
105
+ // arguments, so this path never reads stdin or reaches the preflight either.
106
+ const body = replayModelFields(command);
107
+ let hasPromptOverrides = false;
108
+ if (command.globalPromptSource !== undefined) {
109
+ body.global_prompt = await readPromptPart('global', command.globalPromptSource, context, false);
110
+ hasPromptOverrides = true;
111
+ }
112
+ if (command.groupPromptSource !== undefined) {
113
+ body.group_prompt = await readPromptPart('group', command.groupPromptSource, context, true);
114
+ hasPromptOverrides = true;
115
+ }
116
+ const preflight = await client.get(`api/invocations/${command.id}/replay-preflight`, preflightQuery(command));
117
+ if (!isPreflight(preflight)) {
118
+ throw new CliError('invalid_response', 'replay preflight response has an unexpected shape');
119
+ }
120
+ if (preflight.available !== true) {
121
+ // The preflight reason is the engine's own stable code, so the CLI reports
122
+ // exactly the failure a POST would have produced instead of a new one.
123
+ throw unavailableError(preflight);
124
+ }
125
+ // `before_send_id` selects a replay boundary; it is not a prompt override, so
126
+ // it must not require the prompt-overrides permission.
127
+ if (hasPromptOverrides && preflight.prompt_overrides_available !== true) {
128
+ throw new CliError('replay_prompt_parts_unavailable', 'this scene cannot replay with prompt overrides (prompt_overrides_available is false)');
129
+ }
130
+ if (command.beforeSendId !== undefined) {
131
+ body.before_send_id = command.beforeSendId;
132
+ }
133
+ const raw = await client.post(`api/invocations/${command.id}/replay`, body);
134
+ if (!isRecord(raw)) {
135
+ throw new CliError('invalid_response', 'replay response has an unexpected shape');
136
+ }
137
+ // Preserve the structured result even on failure; runCli reports the
138
+ // failure through the shared JSON stderr and exit-code path.
139
+ const failed = raw.error !== undefined && raw.error !== null;
140
+ writeDocument(raw, context);
141
+ if (failed) {
142
+ throw new CliError('replay_failed', `replay did not complete: ${describeError(raw.error)}`);
143
+ }
144
+ return 0;
145
+ }
146
+ async function runModelsList(context, client) {
147
+ const raw = await client.get('api/models');
148
+ if (!isRecord(raw) ||
149
+ raw.source !== 'active' ||
150
+ !Number.isSafeInteger(raw.generation) ||
151
+ !Array.isArray(raw.models) ||
152
+ !raw.models.every((model) => isRecord(model) &&
153
+ typeof model.provider === 'string' &&
154
+ model.provider.length > 0 &&
155
+ typeof model.model === 'string' &&
156
+ model.model.length > 0 &&
157
+ typeof model.name === 'string' &&
158
+ typeof model.reasoning === 'boolean' &&
159
+ typeof model.context_window === 'number' &&
160
+ model.context_window > 0 &&
161
+ typeof model.max_tokens === 'number' &&
162
+ model.max_tokens > 0 &&
163
+ Array.isArray(model.input) &&
164
+ model.input.includes('text') &&
165
+ model.input.every((input) => input === 'text' || input === 'image') &&
166
+ Array.isArray(model.thinking_levels) &&
167
+ model.thinking_levels.length > 0 &&
168
+ model.thinking_levels.every((level) => typeof level === 'string' && ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(level)))) {
169
+ throw new CliError('invalid_response', 'active model list response has an unexpected shape');
170
+ }
171
+ writeDocument(raw, context);
172
+ return 0;
173
+ }
174
+ async function runConfigShow(command, context, client) {
175
+ const query = new URLSearchParams({ source: command.source ?? 'active' });
176
+ const raw = await client.get('api/config/view', query);
177
+ if (!isRecord(raw) || typeof raw.source !== 'string' || !isRecord(raw.config)) {
178
+ throw new CliError('invalid_response', 'config view response has an unexpected shape');
179
+ }
180
+ writeDocument(raw, context);
181
+ return 0;
182
+ }
183
+ async function runPromptGet(command, context, client) {
184
+ const query = new URLSearchParams({ source: command.source ?? 'active' });
185
+ if (command.chat !== undefined) {
186
+ query.set('chat', command.chat);
187
+ }
188
+ const raw = await client.get(`api/prompts/${command.scope}`, query);
189
+ if (!isRecord(raw)) {
190
+ throw new CliError('invalid_response', 'prompt response has an unexpected shape');
191
+ }
192
+ if (raw.scope !== command.scope ||
193
+ typeof raw.prompt !== 'string' ||
194
+ raw.core_read_only !== true ||
195
+ !isNullableString(raw.chat_id) ||
196
+ typeof raw.source !== 'string') {
197
+ throw new CliError('invalid_response', `prompt ${command.scope} response has an unexpected shape`);
198
+ }
199
+ writeDocument(raw, context);
200
+ return 0;
201
+ }
202
+ function writeDocument(raw, context) {
203
+ context.io.stdout(context.json ? `${JSON.stringify(raw)}\n` : `${JSON.stringify(raw, null, 2)}\n`);
204
+ }
205
+ function isNullableString(value) {
206
+ return value === null || typeof value === 'string';
207
+ }
208
+ function isNullableRecord(value) {
209
+ return value === null || isRecord(value);
210
+ }
211
+ function isPreflight(value) {
212
+ if (!isRecord(value)) {
213
+ return false;
214
+ }
215
+ return (typeof value.available === 'boolean' &&
216
+ typeof value.prompt_overrides_available === 'boolean' &&
217
+ isNullableString(value.reason) &&
218
+ isNullableString(value.message));
219
+ }
220
+ function unavailableError(preflight) {
221
+ const reason = preflight.reason !== null && preflight.reason.length > 0 ? preflight.reason : undefined;
222
+ const message = preflight.message !== null && preflight.message.length > 0 ? preflight.message : undefined;
223
+ return new CliError(reason ?? 'replay_unavailable', message ?? reason ?? 'replay is not available');
224
+ }
225
+ function replayModelFields(selection) {
226
+ return {
227
+ ...(selection.provider === undefined ? {} : { provider: selection.provider }),
228
+ ...(selection.model === undefined ? {} : { model: selection.model }),
229
+ ...(selection.thinkingLevel === undefined ? {} : { thinking_level: selection.thinkingLevel }),
230
+ };
231
+ }
232
+ /** An absent selection keeps the old request URL byte-for-byte. */
233
+ function preflightQuery(command) {
234
+ const query = new URLSearchParams(replayModelFields(command));
235
+ if (command.beforeSendId !== undefined) {
236
+ query.set('before_send_id', command.beforeSendId);
237
+ }
238
+ return query;
239
+ }
240
+ function humanList(items, nextCursor) {
241
+ if (items.length === 0) {
242
+ return 'no invocations\n';
243
+ }
244
+ const lines = items.flatMap((item) => humanListLines(item));
245
+ if (nextCursor !== null) {
246
+ lines.push(`next_cursor: ${nextCursor}`);
247
+ }
248
+ return `${lines.join('\n')}\n`;
249
+ }
250
+ function humanListLines(item) {
251
+ return [humanListLine(item), ...humanMatchedLines(item)];
252
+ }
253
+ function humanListLine(item) {
254
+ if (!isRecord(item)) {
255
+ return '(malformed invocation)';
256
+ }
257
+ const chat = isRecord(item.chat) ? item.chat : undefined;
258
+ const title = typeof chat?.title === 'string' && chat.title.length > 0 ? chat.title : undefined;
259
+ const username = typeof chat?.username === 'string' && chat.username.length > 0 ? chat.username : undefined;
260
+ const chatId = chat !== undefined && (typeof chat.telegram_chat_id === 'string' || typeof chat.telegram_chat_id === 'number')
261
+ ? String(chat.telegram_chat_id)
262
+ : '?';
263
+ const thread = chat !== undefined && typeof chat.message_thread_id === 'number' && chat.message_thread_id !== 0
264
+ ? `#${chat.message_thread_id}`
265
+ : '';
266
+ const parts = [
267
+ typeof item.id === 'string' ? item.id : '?',
268
+ typeof item.state === 'string' ? item.state : '?',
269
+ typeof item.created_at === 'string' ? item.created_at : '?',
270
+ `${title ?? username ?? 'chat'}(${chatId}${thread})`,
271
+ ];
272
+ if (typeof item.total_tokens === 'number') {
273
+ parts.push(`tokens=${item.total_tokens}`);
274
+ }
275
+ if (typeof item.total_cost === 'number') {
276
+ parts.push(`cost=${item.total_cost}`);
277
+ }
278
+ return parts.join(' ');
279
+ }
280
+ /** Longest matched-message excerpt kept in the human list; the JSON output is untouched. */
281
+ const MAX_MATCH_SUMMARY_CHARS = 160;
282
+ /**
283
+ * `matched_messages` describes which messages made a filtered invocation match.
284
+ * It keeps the invocation's own `created_at` as the leading timestamp; a matched
285
+ * message's `at` is never presented as the invocation time.
286
+ */
287
+ function humanMatchedLines(item) {
288
+ if (!isRecord(item) || !Array.isArray(item.matched_messages)) {
289
+ return [];
290
+ }
291
+ const lines = [];
292
+ for (const entry of item.matched_messages) {
293
+ if (!isRecord(entry)) {
294
+ continue;
295
+ }
296
+ const source = entry.source === 'incoming' || entry.source === 'bot' ? entry.source : undefined;
297
+ if (source === undefined) {
298
+ continue;
299
+ }
300
+ const parts = [' matched', source];
301
+ if (typeof entry.at === 'string' && entry.at.length > 0) {
302
+ parts.push(entry.at);
303
+ }
304
+ if (typeof entry.telegram_message_id === 'string') {
305
+ parts.push(`msg=${entry.telegram_message_id}`);
306
+ }
307
+ if (typeof entry.telegram_send_id === 'string') {
308
+ parts.push(`send=${entry.telegram_send_id}`);
309
+ }
310
+ const text = matchSummary(entry.text);
311
+ if (text.length > 0) {
312
+ parts.push(text);
313
+ }
314
+ lines.push(parts.join(' '));
315
+ }
316
+ return lines;
317
+ }
318
+ function matchSummary(value) {
319
+ if (typeof value !== 'string') {
320
+ return '';
321
+ }
322
+ const collapsed = value.replace(/\s+/g, ' ').trim();
323
+ return collapsed.length > MAX_MATCH_SUMMARY_CHARS ? `${collapsed.slice(0, MAX_MATCH_SUMMARY_CHARS)}…` : collapsed;
324
+ }
325
+ function describeError(value) {
326
+ if (typeof value === 'string' && value.length > 0) {
327
+ return value;
328
+ }
329
+ try {
330
+ return JSON.stringify(value) ?? 'unknown error';
331
+ }
332
+ catch {
333
+ return 'unknown error';
334
+ }
335
+ }
336
+ async function readPromptPart(part, source, context, allowEmpty) {
337
+ const text = source === '-' ? await readStdinBounded(part, context.timeoutMs) : await readFileBounded(part, source);
338
+ if (!allowEmpty && text.trim().length === 0) {
339
+ throw usageError(`${part}_prompt_empty`, `${part} prompt must not be empty`);
340
+ }
341
+ if (text.length > MAX_PROMPT_CHARS) {
342
+ throw promptTooLarge(part);
343
+ }
344
+ return text;
345
+ }
346
+ async function readFileBounded(part, path) {
347
+ let info;
348
+ try {
349
+ info = await stat(path);
350
+ }
351
+ catch {
352
+ throw usageError(`${part}_prompt_read_failed`, `cannot read ${part} prompt file: ${path}`);
353
+ }
354
+ // A FIFO, device or socket passes stat() but can block a plain read forever;
355
+ // only a regular file (or a symlink to one) is a bounded prompt source.
356
+ // ponytail: path-based check; use open(O_NONBLOCK) + fstat if the containing
357
+ // directory is adversarial and a file can be swapped in after stat().
358
+ if (!info.isFile()) {
359
+ throw usageError(`${part}_prompt_read_failed`, `${part} prompt file must be a regular file: ${path}`);
360
+ }
361
+ if (info.size > MAX_PROMPT_BYTES) {
362
+ throw promptTooLarge(part);
363
+ }
364
+ let text;
365
+ try {
366
+ text = await readFile(path, 'utf8');
367
+ }
368
+ catch {
369
+ throw usageError(`${part}_prompt_read_failed`, `cannot read ${part} prompt file: ${path}`);
370
+ }
371
+ return text;
372
+ }
373
+ async function readStdinBounded(part, timeoutMs) {
374
+ if (process.stdin.isTTY === true) {
375
+ throw usageError('stdin_required', 'stdin is a terminal; pipe the prompt or pass --global-prompt/--group-prompt <file>');
376
+ }
377
+ const timer = setTimeout(() => {
378
+ process.stdin.destroy(new CliError('timeout', `stdin timed out after ${timeoutMs}ms; no request was sent`));
379
+ }, timeoutMs);
380
+ const chunks = [];
381
+ let total = 0;
382
+ try {
383
+ for await (const chunk of process.stdin) {
384
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
385
+ total += buffer.byteLength;
386
+ if (total > MAX_PROMPT_BYTES) {
387
+ process.stdin.destroy();
388
+ throw promptTooLarge(part);
389
+ }
390
+ chunks.push(buffer);
391
+ }
392
+ return Buffer.concat(chunks).toString('utf8');
393
+ }
394
+ finally {
395
+ clearTimeout(timer);
396
+ }
397
+ }
398
+ function promptTooLarge(part) {
399
+ return usageError(`${part}_prompt_too_large`, promptTooLargeMessage(part));
400
+ }
401
+ function promptTooLargeMessage(part) {
402
+ return `${part} prompt must be at most ${MAX_PROMPT_CHARS} characters`;
403
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Credentials for the Admin API, stored as one 0600 file under
3
+ * `~/.config/surowan-utils/`. Reading and writing share the same posture:
4
+ * the file must be a regular file that group/other cannot read or write, its
5
+ * bytes never reach an error message, and a replacement is renamed into place
6
+ * so a failure cannot damage the previous pair.
7
+ */
8
+ export interface Credentials {
9
+ /** Normalized base URL, in the form `parseEndpoint` produces. */
10
+ readonly endpoint: string;
11
+ readonly apiKey: string;
12
+ }
13
+ /** A credentials file is tiny; anything larger is not one this tool wrote. */
14
+ export declare const MAX_CREDENTIALS_BYTES: number;
15
+ /**
16
+ * `~/.config/surowan-utils/credentials.json` on every platform, Windows
17
+ * included: one documented location beats an XDG/AppData guess that would hide
18
+ * the file from users who followed the guide.
19
+ */
20
+ export declare function credentialsPath(homeDir?: string): string;
21
+ /**
22
+ * A key is an opaque token: no `pwk_` prefix is required, and synthetic keys
23
+ * (including ones ending in a backslash) must round-trip unchanged. Only what
24
+ * would corrupt a header or a log line is refused.
25
+ */
26
+ export declare function validateApiKey(value: string): string;
27
+ /**
28
+ * Reads the stored credentials, or `undefined` when nothing is stored yet.
29
+ * Every other anomaly fails fast with a fixed `CliError`: a JSON parse error
30
+ * quotes the surrounding text, and that text is the key.
31
+ */
32
+ export declare function readCredentials(path: string): Promise<Credentials | undefined>;
33
+ /**
34
+ * Writes the complete pair over whatever was stored before. The bytes land in
35
+ * a freshly created 0600 file that is renamed into place, so a failed write
36
+ * leaves the old file byte-for-byte intact. An existing symlink or an
37
+ * over-permissive file or directory is refused rather than replaced. No lock
38
+ * is taken: two concurrent logins are last-writer-wins.
39
+ */
40
+ export declare function writeCredentials(path: string, credentials: Credentials): Promise<void>;