@nonbot/cli 0.5.13

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/index.js ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ import { VERSION } from './version.js';
3
+ import { runTestCommand } from './commands/test.js';
4
+ import { runStatusCommand } from './commands/status.js';
5
+ import { runDaemonCommand } from './commands/daemon.js';
6
+ import { runLoginCommand } from './commands/login.js';
7
+ import { runRunCommand } from './commands/run.js';
8
+ import { runDoctorCommand } from './commands/doctor.js';
9
+ import { runLogsCommand } from './commands/logs.js';
10
+ import { runProfilesCommand } from './commands/profiles.js';
11
+ import { setActiveProfile } from './lib/auth.js';
12
+ import { header, kvRow, ANSI, isTTY } from './lib/output.js';
13
+ const COMMANDS = [
14
+ {
15
+ name: 'login',
16
+ description: 'Paste a PAT from /settings/connections; saves to ~/.config/nonbot/auth.json.',
17
+ run: (args) => runLoginCommand(args),
18
+ },
19
+ {
20
+ name: 'daemon',
21
+ description: 'Long-running listener — polls /api/cli/activations/pending every 2s.',
22
+ run: (args) => runDaemonCommand(args),
23
+ },
24
+ {
25
+ name: 'run',
26
+ description: 'One-shot: fire a single activation by id. Usage: nonbot run <activation-id>',
27
+ run: (args) => runRunCommand(args),
28
+ },
29
+ {
30
+ name: 'status',
31
+ description: 'Report login state + last daemon heartbeat.',
32
+ run: (args) => runStatusCommand(args),
33
+ },
34
+ {
35
+ name: 'doctor',
36
+ description: 'Health check — auth, server, daemon, provider CLIs, terminal.',
37
+ run: (args) => runDoctorCommand(args),
38
+ },
39
+ {
40
+ name: 'logs',
41
+ description: 'Local activation history. Flags: --limit <n>, --json, --follow/-f.',
42
+ run: (args) => runLogsCommand(args),
43
+ },
44
+ {
45
+ name: 'profiles',
46
+ description: 'List multi-account auth profiles; marks the active one.',
47
+ run: (args) => runProfilesCommand(args),
48
+ },
49
+ {
50
+ name: 'test',
51
+ description: 'Print the diagnostic banner locally — no server roundtrip.',
52
+ run: () => runTestCommand(),
53
+ },
54
+ ];
55
+ function printVersion() {
56
+ process.stdout.write(VERSION + '\n');
57
+ }
58
+ function printHelp() {
59
+ const tty = isTTY(process.stdout);
60
+ const lines = [];
61
+ lines.push(header('nonbot', `non.bot CLI host · v${VERSION}`));
62
+ lines.push('');
63
+ lines.push(tty ? ` ${ANSI.cyan}Usage${ANSI.reset} ${ANSI.dim}nonbot <command> [args]${ANSI.reset}` : ' Usage nonbot <command> [args]');
64
+ lines.push('');
65
+ lines.push(tty ? ` ${ANSI.cyan}Commands${ANSI.reset}` : ' Commands');
66
+ const w = COMMANDS.reduce((m, c) => Math.max(m, c.name.length), 0);
67
+ for (const cmd of COMMANDS) {
68
+ lines.push(kvRow(cmd.name, cmd.description, { keyWidth: w }));
69
+ }
70
+ lines.push('');
71
+ lines.push(tty ? ` ${ANSI.cyan}Flags${ANSI.reset}` : ' Flags');
72
+ lines.push(kvRow('--profile <name>', 'Use a named auth profile (multi-account). Default: "default".', { keyWidth: 18 }));
73
+ lines.push(kvRow('-v, --version', 'Print CLI version and exit.', { keyWidth: 18 }));
74
+ lines.push(kvRow('-h, --help', 'Print this help and exit.', { keyWidth: 18 }));
75
+ lines.push('');
76
+ process.stdout.write(lines.join('\n') + '\n');
77
+ }
78
+ function extractProfileFlag(args) {
79
+ let profile;
80
+ for (let i = 0; i < args.length; i++) {
81
+ if (args[i] === '--profile') {
82
+ profile = args[i + 1];
83
+ args.splice(i, profile === undefined ? 1 : 2);
84
+ i--;
85
+ }
86
+ else if (args[i].startsWith('--profile=')) {
87
+ profile = args[i].slice('--profile='.length);
88
+ args.splice(i, 1);
89
+ i--;
90
+ }
91
+ }
92
+ return profile;
93
+ }
94
+ async function main(argv) {
95
+ const args = argv.slice(2);
96
+ const profile = extractProfileFlag(args);
97
+ if (profile !== undefined) {
98
+ if (profile.length === 0) {
99
+ process.stderr.write('nonbot: --profile needs a name\n');
100
+ return 1;
101
+ }
102
+ try {
103
+ setActiveProfile(profile);
104
+ }
105
+ catch (e) {
106
+ process.stderr.write(`nonbot: ${e.message}\n`);
107
+ return 1;
108
+ }
109
+ }
110
+ if (args.length === 0) {
111
+ printHelp();
112
+ return 0;
113
+ }
114
+ const first = args[0];
115
+ if (first === '-v' || first === '--version') {
116
+ printVersion();
117
+ return 0;
118
+ }
119
+ if (first === '-h' || first === '--help') {
120
+ printHelp();
121
+ return 0;
122
+ }
123
+ const cmd = COMMANDS.find((c) => c.name === first);
124
+ if (!cmd) {
125
+ process.stderr.write(`nonbot: unknown command "${first}"\n\n`);
126
+ printHelp();
127
+ return 1;
128
+ }
129
+ const result = await cmd.run(args.slice(1));
130
+ return typeof result === 'number' ? result : 0;
131
+ }
132
+ main(process.argv).then((code) => process.exit(code), (err) => {
133
+ process.stderr.write(`nonbot: fatal: ${err.message}\n`);
134
+ process.exit(1);
135
+ });
@@ -0,0 +1,480 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import { tmpdir, homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { VERSION } from '../version.js';
6
+ import { resolveTerminal } from './terminal.js';
7
+ import { getActiveProfile } from './auth.js';
8
+ import { appendActivityLog } from './activity-log.js';
9
+ import { activationCard, clockTime } from './output.js';
10
+ import { buildCommandFromParams, shellQuoteSingle } from './command-builders.js';
11
+ import { validatePayload, validateActivationId, validateRepoPath, ValidationError, extractTerminalTheme, } from './payload-validator.js';
12
+ export const BUILT_COMMAND_MAX_LENGTH = 32 * 1024;
13
+ export const WIRE_COMMAND_MAX_LENGTH = 4096;
14
+ export const COMMAND_WARN_LENGTH = 8192;
15
+ export const COMMAND_MAX_LENGTH = 16384;
16
+ export function resolveExecutableCommand(act, warn = (s) => console.warn(s)) {
17
+ if (process.env.NONBOT_TEST_DIRECT_SHELL === '1' && act.payload) {
18
+ const p = act.payload;
19
+ if (p.template === 'test-direct-shell' && typeof p.shell === 'string') {
20
+ return {
21
+ shell: p.shell,
22
+ params: { template: 'diagnostic', activationId: act.id, repoPath: '' },
23
+ };
24
+ }
25
+ }
26
+ if (act.payload === undefined || act.payload === null) {
27
+ throw new Error('activation has no payload — server is on a pre-SEC1 version (see https://non.bot/docs/cli-daemon/upgrading). Daemon will not execute server-supplied shell text verbatim.');
28
+ }
29
+ if (typeof act.command === 'string' && act.command.length > WIRE_COMMAND_MAX_LENGTH) {
30
+ throw new Error(`[${act.id}] command_oversize: wire act.command is ${act.command.length} chars, cap ${WIRE_COMMAND_MAX_LENGTH}. The wire field is informational post-SEC1; an over-cap value suggests a server bug or a stolen-PAT log-flood attempt.`);
31
+ }
32
+ if (typeof act.command === 'string' &&
33
+ act.command.length > 0 &&
34
+ !act.command.startsWith('cd ')) {
35
+ warn(`[${act.id}] note: unusual prefix on wire act.command (does not start with 'cd ') — command_prefix drift. Executing daemon-built command anyway; wire field is informational.\n`);
36
+ }
37
+ let params;
38
+ try {
39
+ params = validatePayload(act.payload);
40
+ }
41
+ catch (e) {
42
+ if (e instanceof ValidationError) {
43
+ throw new Error(`[${act.id}] payload validation failed (${e.field}): ${e.reason}`);
44
+ }
45
+ throw e;
46
+ }
47
+ let shell;
48
+ try {
49
+ shell = buildCommandFromParams(params);
50
+ }
51
+ catch (e) {
52
+ const msg = e instanceof Error ? e.message : String(e);
53
+ throw new Error(`[${act.id}] command builder failed: ${msg}`);
54
+ }
55
+ if (shell.length > BUILT_COMMAND_MAX_LENGTH) {
56
+ throw new Error(`[${act.id}] built command is too large (${shell.length} chars, cap ${BUILT_COMMAND_MAX_LENGTH}). This indicates an oversized AGENTS.md or a builder bug.`);
57
+ }
58
+ if (typeof act.command === 'string' && act.command.length > 0 && act.command !== shell) {
59
+ warn(`[${act.id}] note: daemon-built command differs from server-supplied command (executing daemon-built; server text is informational).\n`);
60
+ }
61
+ return { shell, params };
62
+ }
63
+ export function validateActivationEnvelope(act) {
64
+ try {
65
+ validateActivationId(act.id);
66
+ }
67
+ catch (e) {
68
+ if (e instanceof ValidationError) {
69
+ throw new Error(`[${String(act.id)}] outer envelope validation failed (act.id): ${e.reason}`);
70
+ }
71
+ throw e;
72
+ }
73
+ if (act.repoPath !== undefined && act.repoPath !== null && act.repoPath !== '') {
74
+ try {
75
+ validateRepoPath(act.repoPath);
76
+ }
77
+ catch (e) {
78
+ if (e instanceof ValidationError) {
79
+ throw new Error(`[${act.id}] outer envelope validation failed (act.repoPath): ${e.reason}`);
80
+ }
81
+ throw e;
82
+ }
83
+ }
84
+ if (act.payload &&
85
+ typeof act.payload === 'object' &&
86
+ 'repoPath' in act.payload) {
87
+ const inner = act.payload.repoPath;
88
+ if (act.repoPath !== undefined &&
89
+ act.repoPath !== null &&
90
+ act.repoPath !== '' &&
91
+ typeof inner === 'string' &&
92
+ inner !== '' &&
93
+ inner !== act.repoPath) {
94
+ throw new Error(`[${act.id}] outer envelope drift: act.repoPath (${JSON.stringify(act.repoPath)}) does not match payload.repoPath (${JSON.stringify(inner)})`);
95
+ }
96
+ }
97
+ }
98
+ export function validateActivationCommand(act, warn = (s) => console.warn(s)) {
99
+ if (!act.command || typeof act.command !== 'string') {
100
+ throw new Error('activation has no command');
101
+ }
102
+ if (act.command.length > COMMAND_MAX_LENGTH) {
103
+ throw new Error(`Run command is too large (${act.command.length} chars, cap ${COMMAND_MAX_LENGTH}). This usually means the ticket tree is enormous — consider running the leaf stories individually, or filing a bug if the parent ticket really has that many descendants.`);
104
+ }
105
+ if (act.command.length > COMMAND_WARN_LENGTH) {
106
+ warn(`[${act.id}] note: large Run command (${act.command.length} chars, soft warn at ${COMMAND_WARN_LENGTH}) — likely a deep ticket tree. Proceeding.\n`);
107
+ }
108
+ }
109
+ const CAPTURED_OUTPUT_MAX = 4096;
110
+ function truncate(s, max) {
111
+ if (!s)
112
+ return s;
113
+ if (s.length <= max)
114
+ return s;
115
+ return s.slice(0, max - 32) + `\n…[truncated to ${max}B]`;
116
+ }
117
+ export function makeHeadlessSpawner(opts) {
118
+ return (act) => new Promise((resolve, reject) => {
119
+ try {
120
+ validateActivationEnvelope(act);
121
+ }
122
+ catch (e) {
123
+ reject(e instanceof Error ? e : new Error(String(e)));
124
+ return;
125
+ }
126
+ let resolved;
127
+ try {
128
+ resolved = resolveExecutableCommand(act, opts.errLog);
129
+ }
130
+ catch (e) {
131
+ reject(e instanceof Error ? e : new Error(String(e)));
132
+ return;
133
+ }
134
+ const cwd = act.repoPath || undefined;
135
+ if (opts.wait) {
136
+ const proc = spawn('bash', ['-c', resolved.shell], { cwd, stdio: 'inherit' });
137
+ proc.on('error', (e) => {
138
+ const err = e;
139
+ reject(new Error(`failed to run headless: ${err.message}`));
140
+ });
141
+ proc.on('exit', () => resolve());
142
+ return;
143
+ }
144
+ const proc = spawn('bash', ['-c', resolved.shell], {
145
+ cwd,
146
+ stdio: ['ignore', 'pipe', 'pipe'],
147
+ });
148
+ const prefixLines = (chunk, sink) => {
149
+ const text = chunk.toString('utf-8');
150
+ for (const line of text.split('\n')) {
151
+ if (line.length > 0)
152
+ sink(`[${act.id}] ${line}\n`);
153
+ }
154
+ };
155
+ proc.stdout?.on('data', (c) => prefixLines(c, opts.log));
156
+ proc.stderr?.on('data', (c) => prefixLines(c, opts.errLog));
157
+ proc.on('error', (e) => {
158
+ const err = e;
159
+ reject(new Error(`failed to run headless: ${err.message}`));
160
+ });
161
+ proc.on('exit', (code) => opts.log(`[${act.id}] exited ${code}\n`));
162
+ proc.on('spawn', () => resolve());
163
+ });
164
+ }
165
+ async function captureSpawn(cmd, args) {
166
+ return new Promise((resolve) => {
167
+ const stderrChunks = [];
168
+ const stdoutChunks = [];
169
+ let stderrLen = 0;
170
+ let stdoutLen = 0;
171
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
172
+ proc.stderr?.on('data', (c) => {
173
+ if (stderrLen >= CAPTURED_OUTPUT_MAX)
174
+ return;
175
+ stderrChunks.push(c);
176
+ stderrLen += c.length;
177
+ });
178
+ proc.stdout?.on('data', (c) => {
179
+ if (stdoutLen >= CAPTURED_OUTPUT_MAX)
180
+ return;
181
+ stdoutChunks.push(c);
182
+ stdoutLen += c.length;
183
+ });
184
+ proc.on('error', (e) => {
185
+ resolve({
186
+ exitCode: null,
187
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
188
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
189
+ spawnError: e,
190
+ });
191
+ });
192
+ proc.on('exit', (code) => {
193
+ resolve({
194
+ exitCode: code,
195
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
196
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
197
+ });
198
+ });
199
+ });
200
+ }
201
+ export const _captureSpawnForTests = captureSpawn;
202
+ export async function spawnTerminalDefault(act, auth) {
203
+ validateActivationEnvelope(act);
204
+ const resolved = resolveExecutableCommand(act);
205
+ const ext = process.platform === 'darwin' ? 'command' : 'sh';
206
+ const scriptPath = path.join(tmpdir(), `nonbot-${act.id}.${ext}`);
207
+ if (path.dirname(scriptPath) !== tmpdir()) {
208
+ throw new Error(`[${act.id}] refusing to write script outside tmpdir(): resolved path is ${JSON.stringify(scriptPath)}`);
209
+ }
210
+ let envPrefix = '';
211
+ if (auth) {
212
+ const patLine = `export NONBOT_PAT=${shellQuoteSingle(auth.pat)}`;
213
+ const runIdLine = `export NONBOT_RUN_ID=${shellQuoteSingle(act.id)}`;
214
+ const baseUrlLine = `export NONBOT_BASE_URL=${shellQuoteSingle(auth.baseUrl)}`;
215
+ const roleLine = `export NONBOT_ROLE='lead'`;
216
+ envPrefix = `${patLine}\n${runIdLine}\n${baseUrlLine}\n${roleLine}\n`;
217
+ }
218
+ const body = `#!/bin/bash\n${envPrefix}${resolved.shell}\n`;
219
+ await fs.writeFile(scriptPath, body, { mode: 0o700 });
220
+ await fs.chmod(scriptPath, 0o700);
221
+ const profile = resolveTerminal(act.terminal);
222
+ const theme = extractTerminalTheme(act.payload);
223
+ let itermProfileName;
224
+ if (theme) {
225
+ if (profile.id === 'tmux') {
226
+ const themeConfPath = path.join(tmpdir(), `nonbot-theme-${act.id}.conf`);
227
+ await fs.writeFile(themeConfPath, theme.tmuxConf, { mode: 0o600 });
228
+ const safePath = themeConfPath.replace(/'/g, `'\\''`);
229
+ const sourceLine = `tmux source-file '${safePath}'\n`;
230
+ const existingContent = await fs.readFile(scriptPath, 'utf-8');
231
+ const withTheme = existingContent.replace(/^(#!\/bin\/bash\n)/, `$1${sourceLine}`);
232
+ await fs.writeFile(scriptPath, withTheme, { mode: 0o700 });
233
+ await fs.chmod(scriptPath, 0o700);
234
+ }
235
+ else if (profile.id === 'iterm' || profile.id === 'iterm-tab') {
236
+ const profileDir = path.join(homedir(), 'Library', 'Application Support', 'iTerm2', 'DynamicProfiles');
237
+ await fs.mkdir(profileDir, { recursive: true });
238
+ const profileFilePath = path.join(profileDir, `nonbot-${act.id}.json`);
239
+ await fs.writeFile(profileFilePath, theme.itermProfileJson, { mode: 0o600 });
240
+ itermProfileName = theme.name;
241
+ }
242
+ else if (profile.id === 'terminal') {
243
+ console.info(`[nonbot] terminal-theme: Terminal.app per-window theming is not supported via AppleScript. ` +
244
+ `Switch to iTerm for theme support. Launching un-themed.`);
245
+ }
246
+ }
247
+ const { cmd, args } = profile.launch(scriptPath, itermProfileName ? { itermProfileName } : undefined);
248
+ const result = await captureSpawn(cmd, args);
249
+ const unlinkBest = async () => {
250
+ try {
251
+ await fs.unlink(scriptPath);
252
+ }
253
+ catch {
254
+ }
255
+ };
256
+ if (result.spawnError) {
257
+ await unlinkBest();
258
+ const err = result.spawnError;
259
+ const why = err.code === 'ENOENT'
260
+ ? `${profile.displayName} launcher "${cmd}" not found on PATH`
261
+ : err.message;
262
+ const failure = Object.assign(new Error(`failed to launch ${profile.displayName}: ${why}`), {
263
+ launcherStderr: result.stderr,
264
+ launcherStdout: result.stdout,
265
+ });
266
+ throw failure;
267
+ }
268
+ if (result.exitCode !== 0) {
269
+ await unlinkBest();
270
+ const tail = (result.stderr || result.stdout || '').trim().split('\n').slice(-3).join(' | ') ||
271
+ `(no output)`;
272
+ const failure = Object.assign(new Error(`${profile.displayName} launcher "${cmd}" exited ${result.exitCode}: ${tail}`), {
273
+ launcherStderr: result.stderr,
274
+ launcherStdout: result.stdout,
275
+ launcherExitCode: result.exitCode ?? undefined,
276
+ });
277
+ throw failure;
278
+ }
279
+ let tmuxPaneId;
280
+ if (profile.id === 'tmux') {
281
+ const first = result.stdout.trim().split(/\s+/)[0] ?? '';
282
+ if (/^%\d+$/.test(first))
283
+ tmuxPaneId = first;
284
+ }
285
+ return { tmuxPaneId };
286
+ }
287
+ export async function ack(auth, id, body, fetchImpl = fetch) {
288
+ try {
289
+ await fetchImpl(`${auth.baseUrl}/api/cli/activations/${id}/ack`, {
290
+ method: 'POST',
291
+ headers: {
292
+ Authorization: `Bearer ${auth.pat}`,
293
+ 'X-Requested-With': 'ConradPM-Native',
294
+ 'X-CLI-Version': VERSION,
295
+ 'Content-Type': 'application/json',
296
+ },
297
+ body: JSON.stringify(body),
298
+ });
299
+ }
300
+ catch {
301
+ }
302
+ }
303
+ class TraceBuilder {
304
+ steps = [];
305
+ lastTs = 0;
306
+ add(name, status, detail) {
307
+ const ts = Date.now();
308
+ const durationMs = this.lastTs === 0 ? undefined : Math.max(0, ts - this.lastTs);
309
+ this.steps.push({ name, status, detail, ts, durationMs });
310
+ this.lastTs = ts;
311
+ }
312
+ snapshot() {
313
+ return this.steps.slice();
314
+ }
315
+ }
316
+ export async function fireActivation(auth, act, deps = {}, log = (s) => process.stdout.write(s), errLog = (s) => process.stderr.write(s)) {
317
+ const fetchImpl = deps.fetchImpl ?? fetch;
318
+ const spawnFn = deps.spawnTerminal ?? ((a) => spawnTerminalDefault(a, auth));
319
+ const headless = deps.headless === true;
320
+ const trace = new TraceBuilder();
321
+ trace.add('received', 'ok', `id=${act.id} kind=${act.kind} repoPath=${act.repoPath ?? '(none)'} preference=${act.terminal ?? '(none)'}`);
322
+ const profile = resolveTerminal(act.terminal);
323
+ const terminal = headless ? 'headless' : profile.displayName;
324
+ if (headless) {
325
+ trace.add('resolved_terminal', 'ok', `headless (no terminal app)`);
326
+ }
327
+ else {
328
+ const { cmd } = profile.launch('/tmp/nonbot-probe');
329
+ trace.add('resolved_terminal', 'ok', `${terminal} (${cmd})`);
330
+ }
331
+ const payloadAny = (act.payload ?? {});
332
+ const storyTitle = payloadAny.storyTitle && typeof payloadAny.storyTitle === 'string'
333
+ ? payloadAny.storyTitle
334
+ : `${act.kind} activation`;
335
+ const providerName = payloadAny.provider && typeof payloadAny.provider === 'string'
336
+ ? payloadAny.provider
337
+ : 'unknown';
338
+ const cardKv = [
339
+ ['STORY', storyTitle],
340
+ ['PROVIDER', providerName],
341
+ ['PANE', terminal],
342
+ ['AT', clockTime()],
343
+ ];
344
+ if (act.repoPath)
345
+ cardKv.push(['REPO', act.repoPath]);
346
+ log(activationCard({
347
+ marker: '▶',
348
+ color: 'green',
349
+ id: act.id,
350
+ headerSuffix: 'RUNNING',
351
+ kv: cardKv,
352
+ }) + '\n');
353
+ const mode = headless ? 'headless' : 'terminal';
354
+ let launcherStderr;
355
+ let launcherStdout;
356
+ let launcherExitCode;
357
+ try {
358
+ trace.add('spawned_launcher', 'ok', `dispatched to ${terminal}`);
359
+ const spawnResult = (await spawnFn(act)) ?? {};
360
+ const tmuxPaneId = spawnResult.tmuxPaneId ?? undefined;
361
+ launcherExitCode = 0;
362
+ trace.add('launcher_exited', 'ok', `exit 0`);
363
+ trace.add('acked', 'ok', `status=launched terminal=${terminal}`);
364
+ const body = {
365
+ status: 'launched',
366
+ terminalAppUsed: terminal,
367
+ trace: trace.snapshot(),
368
+ launcherExitCode,
369
+ };
370
+ if (tmuxPaneId)
371
+ body.tmuxPaneId = tmuxPaneId;
372
+ await ack(auth, act.id, body, fetchImpl);
373
+ await logActivity({
374
+ ts: Date.now(),
375
+ id: act.id,
376
+ kind: act.kind,
377
+ mode,
378
+ target: terminal,
379
+ repoPath: act.repoPath,
380
+ status: 'launched',
381
+ profile: getActiveProfile(),
382
+ });
383
+ return { id: act.id, status: 'launched', kind: act.kind, tmuxPaneId: tmuxPaneId ?? null };
384
+ }
385
+ catch (e) {
386
+ const reason = e.message || 'unknown error';
387
+ const failure = e;
388
+ launcherStderr = truncate(failure.launcherStderr, CAPTURED_OUTPUT_MAX);
389
+ launcherStdout = truncate(failure.launcherStdout, CAPTURED_OUTPUT_MAX);
390
+ launcherExitCode = failure.launcherExitCode;
391
+ const failKv = [
392
+ ['REASON', reason],
393
+ ['PANE', terminal],
394
+ ['AT', clockTime()],
395
+ ];
396
+ if (launcherStderr) {
397
+ failKv.push(['STDERR', launcherStderr.slice(0, 500).replace(/\n/g, ' | ')]);
398
+ }
399
+ errLog(activationCard({
400
+ marker: 'X',
401
+ color: 'red',
402
+ id: act.id,
403
+ headerSuffix: 'FAILED',
404
+ kv: failKv,
405
+ stream: process.stderr,
406
+ }) + '\n');
407
+ trace.add('launcher_exited', 'failed', reason);
408
+ trace.add('acked', 'ok', `status=failed terminal=${terminal}`);
409
+ const body = {
410
+ status: 'failed',
411
+ terminalAppUsed: terminal,
412
+ errorReason: reason,
413
+ trace: trace.snapshot(),
414
+ launcherStderr,
415
+ launcherStdout,
416
+ launcherExitCode,
417
+ };
418
+ await ack(auth, act.id, body, fetchImpl);
419
+ await logActivity({
420
+ ts: Date.now(),
421
+ id: act.id,
422
+ kind: act.kind,
423
+ mode,
424
+ target: terminal,
425
+ repoPath: act.repoPath,
426
+ status: 'failed',
427
+ reason,
428
+ profile: getActiveProfile(),
429
+ });
430
+ return { id: act.id, status: 'failed', kind: act.kind };
431
+ }
432
+ }
433
+ async function logActivity(entry) {
434
+ try {
435
+ await appendActivityLog(entry);
436
+ }
437
+ catch {
438
+ }
439
+ }
440
+ export async function executePendingKills(kills, baseUrl, token) {
441
+ if (!Array.isArray(kills) || kills.length === 0)
442
+ return;
443
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
444
+ const runOne = async (k) => {
445
+ try {
446
+ spawnSync('tmux', ['send-keys', '-t', k.tmuxPaneId, 'C-c'], {
447
+ encoding: 'utf-8',
448
+ timeout: 2000,
449
+ windowsHide: true,
450
+ });
451
+ }
452
+ catch {
453
+ }
454
+ await sleep(2000);
455
+ try {
456
+ spawnSync('tmux', ['kill-pane', '-t', k.tmuxPaneId], {
457
+ encoding: 'utf-8',
458
+ timeout: 2000,
459
+ windowsHide: true,
460
+ });
461
+ }
462
+ catch {
463
+ }
464
+ try {
465
+ await fetch(`${baseUrl}/api/cli/activations/${k.activationId}/stop-ack`, {
466
+ method: 'POST',
467
+ headers: {
468
+ Authorization: `Bearer ${token}`,
469
+ 'X-Requested-With': 'ConradPM-Native',
470
+ 'X-CLI-Version': VERSION,
471
+ 'Content-Type': 'application/json',
472
+ },
473
+ body: JSON.stringify({ ok: true }),
474
+ });
475
+ }
476
+ catch {
477
+ }
478
+ };
479
+ await Promise.all(kills.map(runOne));
480
+ }
@@ -0,0 +1,48 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ function configDir() {
5
+ const override = process.env.NONBOT_CONFIG_DIR;
6
+ if (override && override.length > 0)
7
+ return override;
8
+ return path.join(homedir(), '.config', 'nonbot');
9
+ }
10
+ function logPath() {
11
+ return path.join(configDir(), 'activations.log');
12
+ }
13
+ const realFs = {
14
+ mkdir: (dir, opts) => fs.mkdir(dir, opts),
15
+ appendFile: (file, data) => fs.appendFile(file, data),
16
+ readFile: (file, enc) => fs.readFile(file, enc),
17
+ };
18
+ export async function appendActivityLog(entry, fsImpl = realFs) {
19
+ await fsImpl.mkdir(configDir(), { recursive: true, mode: 0o700 });
20
+ await fsImpl.appendFile(logPath(), JSON.stringify(entry) + '\n');
21
+ }
22
+ export async function readActivityLog(limit = 50, fsImpl = realFs) {
23
+ let raw;
24
+ try {
25
+ raw = await fsImpl.readFile(logPath(), 'utf-8');
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
31
+ const out = [];
32
+ for (const line of lines) {
33
+ try {
34
+ const parsed = JSON.parse(line);
35
+ if (typeof parsed.ts === 'number' &&
36
+ typeof parsed.id === 'string' &&
37
+ typeof parsed.status === 'string') {
38
+ out.push(parsed);
39
+ }
40
+ }
41
+ catch {
42
+ }
43
+ }
44
+ return out.slice(-limit);
45
+ }
46
+ export function _activityLogPathForTests() {
47
+ return logPath();
48
+ }