@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.36

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.
Files changed (45) hide show
  1. package/dist/commands/smoke.js +133 -0
  2. package/dist/core/auth/ensure-authenticated.js +129 -0
  3. package/dist/core/bash-classifier.js +108 -1
  4. package/dist/core/codegraph/decision-store.js +248 -0
  5. package/dist/core/codegraph/detect-repo.js +459 -0
  6. package/dist/core/codegraph/install.js +134 -0
  7. package/dist/core/codegraph/offer-hook.js +220 -0
  8. package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
  9. package/dist/core/mcp/orchestrator-tools.js +595 -0
  10. package/dist/core/onboarding/ensure-initialized.js +133 -0
  11. package/dist/core/repl/session.js +370 -9
  12. package/dist/core/repl/slash-commands.js +68 -5
  13. package/dist/core/smoke/headless-driver.js +174 -0
  14. package/dist/core/smoke/orchestrator.js +194 -0
  15. package/dist/core/smoke/runner.js +238 -0
  16. package/dist/core/smoke/scenario-parser.js +316 -0
  17. package/dist/runtime/cli.js +453 -11
  18. package/dist/runtime/commands/cancel.js +231 -0
  19. package/dist/runtime/commands/codegraph-status.js +227 -0
  20. package/dist/runtime/commands/mcp.js +66 -11
  21. package/dist/runtime/commands/permissions.js +23 -0
  22. package/dist/runtime/commands/redo-blob-store.js +92 -0
  23. package/dist/runtime/commands/redo.js +361 -0
  24. package/dist/runtime/commands/status.js +11 -3
  25. package/dist/runtime/commands/undo.js +32 -0
  26. package/dist/runtime/headless-repl.js +195 -0
  27. package/dist/runtime/version.js +1 -1
  28. package/dist/tui/permissions-picker.js +78 -0
  29. package/dist/tui/render.js +35 -0
  30. package/dist/tui/status-bar.js +1 -1
  31. package/dist/tui/tool-stream-pane.js +45 -3
  32. package/package.json +7 -4
  33. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  34. package/test/scenarios/compact-force.scenario.txt +11 -0
  35. package/test/scenarios/identity.scenario.txt +11 -0
  36. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  37. package/test/scenarios/walkback.scenario.txt +12 -0
  38. package/dist/core/engine/compaction-hook.js +0 -154
  39. package/dist/core/init/scaffold.js +0 -195
  40. package/dist/core/memory/dual-write.spec.js +0 -297
  41. package/dist/core/memory-sync/queue.spec.js +0 -105
  42. package/dist/core/repl/codebase-survey.js +0 -308
  43. package/dist/core/repl/init-interview.js +0 -457
  44. package/dist/core/repl/onboarding-state.js +0 -297
  45. package/dist/runtime/commands/memory.spec.js +0 -174
@@ -0,0 +1,595 @@
1
+ /**
2
+ * Pugi MCP server — orchestrator-tools surface (Wave 7 P1 / 2026-05-28).
3
+ *
4
+ * SCOPE — this module is intentionally orthogonal to `server-tools.ts`.
5
+ *
6
+ * - `server-tools.ts` exposes the *engine* surface (read / grep / glob /
7
+ * edit / write / bash) — workspace-scoped, file-tools-backed, used by
8
+ * "external agent borrows Pugi's in-process executor".
9
+ *
10
+ * - `orchestrator-tools.ts` (THIS FILE) exposes the *orchestrator*
11
+ * surface — `pugi.run` / `pugi.read` / `pugi.write` / `pugi.dispatch`
12
+ * / `pugi.publish` / `pugi.deploy`. These are CLI-level operations
13
+ * used by an EXTERNAL Claude Code (or Cursor) session that wants to
14
+ * loop fix-publish-test against the LIVE Pugi runtime. The motivating
15
+ * use case is the 2026-05-28 CEO dogfood blocker: Pugi REPL emits
16
+ * pseudo-tool-tags inline (no real file writes / no real shell exec);
17
+ * the operator wants to drive a remote Claude Code session that
18
+ * programmatically invokes Pugi against the engine VM, captures
19
+ * output, edits source, republishes the CLI, and re-tests — all
20
+ * without an interactive human at every step.
21
+ *
22
+ * SECURITY POSTURE — every orchestrator tool is gated by an env-var
23
+ * permission switch that defaults to OFF. The MCP server's
24
+ * `permissionGate` still applies on top (deny-by-default), but env
25
+ * gates are a coarser kill-switch the operator can flip per-machine
26
+ * without rebuilding the CLI.
27
+ *
28
+ * - PUGI_MCP_EXEC_ENABLED=1 — enables `pugi.run`
29
+ * - PUGI_MCP_PUBLISH_ENABLED=1 — enables `pugi.publish`
30
+ * - PUGI_MCP_DEPLOY_ENABLED=1 — enables `pugi.deploy`
31
+ *
32
+ * `pugi.read` / `pugi.write` / `pugi.dispatch` do not require an env
33
+ * gate (read+write enforce workspace + protected-path containment;
34
+ * dispatch only sends prompts to the operator's already-authenticated
35
+ * Anvil session). All three still pass through the MCP-server
36
+ * permissionGate, so an operator running `pugi mcp serve` without
37
+ * `--allow-write` still sees `pugi.write` refused at dispatch.
38
+ */
39
+ import { execFile } from 'node:child_process';
40
+ import { promisify } from 'node:util';
41
+ import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
42
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ const execFileAsync = promisify(execFile);
45
+ /**
46
+ * Protected basename patterns — mirror of
47
+ * `core/bash-classifier.ts::PROTECTED_BASENAME_PATTERNS`. We DO NOT
48
+ * import from there because that module is bash-classifier specific
49
+ * (the regex shapes there carry shell-quote boundaries). For path-only
50
+ * matching we use simpler RegExps anchored on the basename. Keeps the
51
+ * two modules independently auditable.
52
+ */
53
+ const PROTECTED_BASENAMES = [
54
+ /^\.env$/,
55
+ /^\.env\.[A-Za-z0-9_-]+$/,
56
+ /^id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/,
57
+ /^\.npmrc$/,
58
+ /^\.pypirc$/,
59
+ /^\.gitconfig$/,
60
+ /^credentials(\.json)?$/,
61
+ ];
62
+ const PROTECTED_DIR_SEGMENTS = new Set([
63
+ '.git',
64
+ '.ssh',
65
+ '.gnupg',
66
+ 'node_modules',
67
+ ]);
68
+ /**
69
+ * Resolve + validate a caller-supplied path against the workspace
70
+ * root. Refuses absolute paths outside the root, parent-traversal
71
+ * escapes, and protected basenames / dir segments.
72
+ *
73
+ * Exported so the spec can drive it directly — pinning the security
74
+ * boundary at a single audited entry point.
75
+ */
76
+ export function resolveWorkspacePathOrThrow(ctx, requested) {
77
+ if (typeof requested !== 'string' || requested.length === 0) {
78
+ throw new Error('path must be a non-empty string');
79
+ }
80
+ if (requested.includes('\0')) {
81
+ throw new Error('path contains a null byte');
82
+ }
83
+ const root = resolve(ctx.workspaceRoot);
84
+ const candidate = isAbsolute(requested) ? requested : resolve(root, requested);
85
+ const absolute = resolve(candidate);
86
+ // Containment check — absolute must live under root. We use
87
+ // `relative` + `..` detection rather than `startsWith(root)` so a
88
+ // sibling dir whose name happens to share a prefix (e.g. /tmp/wsX
89
+ // vs /tmp/ws) does not accidentally pass.
90
+ const rel = relative(root, absolute);
91
+ if (rel === '' || rel === '.') {
92
+ throw new Error(`path "${requested}" resolves to the workspace root itself`);
93
+ }
94
+ if (rel.startsWith('..') || isAbsolute(rel)) {
95
+ throw new Error(`path "${requested}" escapes the workspace root`);
96
+ }
97
+ // Protected segment / basename check — applied to EVERY component of
98
+ // the resolved path under the root. We split on the OS separator so
99
+ // Windows + POSIX share the same gate.
100
+ const segments = rel.split(sep);
101
+ for (const segment of segments) {
102
+ if (PROTECTED_DIR_SEGMENTS.has(segment)) {
103
+ throw new Error(`path "${requested}" touches protected segment "${segment}"`);
104
+ }
105
+ for (const pattern of PROTECTED_BASENAMES) {
106
+ if (pattern.test(segment)) {
107
+ throw new Error(`path "${requested}" touches protected basename "${segment}"`);
108
+ }
109
+ }
110
+ }
111
+ return { absolute, relativeToRoot: rel };
112
+ }
113
+ /**
114
+ * Build the orchestrator tool surface. The MCP server consumes the
115
+ * returned array via `createPugiMcpServer({ tools })`. Permission
116
+ * gating happens at TWO layers:
117
+ *
118
+ * 1. `capabilities.{exec,publish,deploy}` — env-var kill-switch
119
+ * checked at tool-execute time. A tool whose capability is OFF
120
+ * throws a deterministic refusal message; the MCP wire surfaces
121
+ * it as `isError: true` content.
122
+ *
123
+ * 2. The MCP server's `permissionGate` — checked BEFORE execute
124
+ * runs. The `pugi mcp serve` wiring in `runtime/commands/mcp.ts`
125
+ * synthesises a default gate; callers (tests) can pass
126
+ * `() => true` to bypass.
127
+ *
128
+ * The double-layer design is intentional — it lets an operator
129
+ * configure `PUGI_MCP_EXEC_ENABLED=1` system-wide AND still refuse a
130
+ * specific `pugi.run` call via the per-tool prompt without restarting
131
+ * the server.
132
+ */
133
+ export function buildOrchestratorTools(ctx) {
134
+ const fetchImpl = ctx.fetchImpl ??
135
+ ((...args) => fetch(...args));
136
+ const execImpl = ctx.execFileImpl ?? execFileAsync;
137
+ const tools = [
138
+ {
139
+ name: 'pugi.run',
140
+ description: 'Execute a pugi CLI subcommand and capture stdout/stderr/exitCode. ' +
141
+ 'Use for `--version`, `explain`, `smoke`, etc. ' +
142
+ 'Requires PUGI_MCP_EXEC_ENABLED=1 at server boot.',
143
+ permission: 'bash',
144
+ inputSchema: {
145
+ type: 'object',
146
+ additionalProperties: false,
147
+ required: ['command'],
148
+ properties: {
149
+ command: {
150
+ type: 'string',
151
+ description: 'Whitespace-tokenised argv tail (e.g. "explain README.md").',
152
+ },
153
+ cwd: {
154
+ type: 'string',
155
+ description: 'Optional workspace-relative cwd; defaults to workspace root.',
156
+ },
157
+ timeoutMs: {
158
+ type: 'number',
159
+ minimum: 100,
160
+ maximum: 300000,
161
+ description: 'Hard timeout in ms (default 30000).',
162
+ },
163
+ },
164
+ },
165
+ async execute(args) {
166
+ if (!ctx.capabilities.exec) {
167
+ throw new Error('pugi.run: PUGI_MCP_EXEC_ENABLED is not set. ' +
168
+ 'Restart `pugi mcp serve` with PUGI_MCP_EXEC_ENABLED=1 to enable shell execution.');
169
+ }
170
+ const command = requireString(args, 'command');
171
+ const tokens = tokeniseArgv(command);
172
+ if (tokens.length === 0) {
173
+ throw new Error('pugi.run: command tokenises to zero args');
174
+ }
175
+ const timeoutMs = optionalNumber(args, 'timeoutMs', 30000);
176
+ const cwdInput = optionalString(args, 'cwd');
177
+ const cwd = cwdInput
178
+ ? resolveWorkspacePathOrThrow(ctx, cwdInput).absolute
179
+ : ctx.workspaceRoot;
180
+ const started = Date.now();
181
+ try {
182
+ const { stdout, stderr } = await execImpl(ctx.pugiBin, tokens, {
183
+ cwd,
184
+ timeout: timeoutMs,
185
+ maxBuffer: 4 * 1024 * 1024,
186
+ // Strip secret envs — orchestrator-driven CLI runs do NOT
187
+ // need the operator's NPM_TOKEN / GH_TOKEN / OPENAI_API_KEY
188
+ // visible. We pass through only PATH + HOME + a minimal
189
+ // shell. Same posture as bashToolSync(source='mcp').
190
+ env: sanitisedEnv(),
191
+ });
192
+ const durationMs = Date.now() - started;
193
+ return JSON.stringify({
194
+ stdout: clamp(stdout, 32 * 1024),
195
+ stderr: clamp(stderr, 32 * 1024),
196
+ exitCode: 0,
197
+ durationMs,
198
+ });
199
+ }
200
+ catch (err) {
201
+ const e = err;
202
+ const durationMs = Date.now() - started;
203
+ return JSON.stringify({
204
+ stdout: clamp(e.stdout ?? '', 32 * 1024),
205
+ stderr: clamp(e.stderr ?? (e.message ?? ''), 32 * 1024),
206
+ exitCode: typeof e.code === 'number' ? e.code : 1,
207
+ durationMs,
208
+ ...(e.signal ? { signal: e.signal } : {}),
209
+ ...(e.killed ? { killed: true } : {}),
210
+ });
211
+ }
212
+ },
213
+ },
214
+ {
215
+ name: 'pugi.read',
216
+ description: 'Read a file inside the configured workspace root. Refuses paths outside ' +
217
+ 'the root, parent-traversal escapes, and protected basenames (.env / .git / ' +
218
+ '.ssh / id_rsa / .npmrc / credentials.json). Default cap 256KB.',
219
+ permission: 'read',
220
+ inputSchema: {
221
+ type: 'object',
222
+ additionalProperties: false,
223
+ required: ['path'],
224
+ properties: {
225
+ path: { type: 'string' },
226
+ },
227
+ },
228
+ async execute(args) {
229
+ const path = requireString(args, 'path');
230
+ const { absolute, relativeToRoot } = resolveWorkspacePathOrThrow(ctx, path);
231
+ const stat = statSync(absolute);
232
+ if (!stat.isFile()) {
233
+ throw new Error(`pugi.read: "${relativeToRoot}" is not a regular file`);
234
+ }
235
+ const CAP = 256 * 1024;
236
+ const content = readFileSync(absolute, 'utf8');
237
+ const sizeBytes = Buffer.byteLength(content, 'utf8');
238
+ const truncated = sizeBytes > CAP;
239
+ return JSON.stringify({
240
+ path: relativeToRoot,
241
+ content: truncated ? content.slice(0, CAP) : content,
242
+ sizeBytes,
243
+ mtime: stat.mtime.toISOString(),
244
+ ...(truncated ? { truncated: true, capBytes: CAP } : {}),
245
+ });
246
+ },
247
+ },
248
+ {
249
+ name: 'pugi.write',
250
+ description: 'Create or overwrite a workspace file using atomic tmp+rename. Refuses paths ' +
251
+ 'outside the workspace root and protected basenames.',
252
+ permission: 'edit',
253
+ inputSchema: {
254
+ type: 'object',
255
+ additionalProperties: false,
256
+ required: ['path', 'content'],
257
+ properties: {
258
+ path: { type: 'string' },
259
+ content: { type: 'string' },
260
+ },
261
+ },
262
+ async execute(args) {
263
+ const path = requireString(args, 'path');
264
+ const content = requireString(args, 'content');
265
+ const { absolute, relativeToRoot } = resolveWorkspacePathOrThrow(ctx, path);
266
+ mkdirSync(dirname(absolute), { recursive: true });
267
+ const tmpPath = `${absolute}.pugi-mcp-tmp-${process.pid}-${Date.now()}`;
268
+ // Open with O_CREAT|O_EXCL so a concurrent writer cannot race
269
+ // a same-named tmp file out from under us. Mode 0o600 (operator
270
+ // only) — orchestrator writes are NOT shared artefacts.
271
+ const fd = openSync(tmpPath, 'wx', 0o600);
272
+ try {
273
+ writeFileSync(fd, content, 'utf8');
274
+ // fsync via fstatSync is a no-op on most kernels — the real
275
+ // durability win comes from rename being atomic at the inode
276
+ // layer. We still touch the fd to surface any late-EIO before
277
+ // rename commits.
278
+ fstatSync(fd);
279
+ }
280
+ finally {
281
+ closeSync(fd);
282
+ }
283
+ renameSync(tmpPath, absolute);
284
+ const bytesWritten = Buffer.byteLength(content, 'utf8');
285
+ return JSON.stringify({
286
+ path: relativeToRoot,
287
+ bytesWritten,
288
+ });
289
+ },
290
+ },
291
+ {
292
+ name: 'pugi.dispatch',
293
+ description: 'Send a prompt to the live Pugi engine (Anvil-routed). Returns the synthesised ' +
294
+ 'response, tool calls, cost, duration, and file changes. Uses the operator’s ' +
295
+ 'currently active credential — set PUGI_API_KEY or run `pugi login` first.',
296
+ permission: 'network',
297
+ inputSchema: {
298
+ type: 'object',
299
+ additionalProperties: false,
300
+ required: ['prompt'],
301
+ properties: {
302
+ prompt: { type: 'string' },
303
+ persona: {
304
+ type: 'string',
305
+ description: 'Persona id, default "mira".',
306
+ },
307
+ workspace: {
308
+ type: 'string',
309
+ description: 'Workspace label (logical, not a filesystem path).',
310
+ },
311
+ },
312
+ },
313
+ async execute(args) {
314
+ const prompt = requireString(args, 'prompt');
315
+ const persona = optionalString(args, 'persona') ?? 'mira';
316
+ const workspace = optionalString(args, 'workspace');
317
+ if (!ctx.apiKey) {
318
+ throw new Error('pugi.dispatch: no active credential. Set PUGI_API_KEY or run `pugi login`.');
319
+ }
320
+ const endpoint = `${ctx.apiUrl.replace(/\/+$/, '')}/api/pugi/engine`;
321
+ const started = Date.now();
322
+ const response = await fetchImpl(endpoint, {
323
+ method: 'POST',
324
+ headers: {
325
+ Authorization: `Bearer ${ctx.apiKey}`,
326
+ 'Content-Type': 'application/json',
327
+ Accept: 'application/json',
328
+ 'User-Agent': 'pugi-mcp-orchestrator/1',
329
+ },
330
+ body: JSON.stringify({
331
+ prompt,
332
+ persona,
333
+ ...(workspace ? { workspace } : {}),
334
+ }),
335
+ });
336
+ const durationMs = Date.now() - started;
337
+ if (!response.ok) {
338
+ const bodyText = await safeText(response);
339
+ throw new Error(`pugi.dispatch: ${response.status} ${response.statusText} — ${clamp(bodyText, 2000)}`);
340
+ }
341
+ const parsed = (await response.json().catch(() => ({})));
342
+ return JSON.stringify({
343
+ response: typeof parsed.response === 'string' ? parsed.response : '',
344
+ toolCalls: Array.isArray(parsed.toolCalls) ? parsed.toolCalls : [],
345
+ cost: typeof parsed.cost === 'number' ? parsed.cost : 0,
346
+ durationMs,
347
+ fileChanges: Array.isArray(parsed.fileChanges) ? parsed.fileChanges : [],
348
+ });
349
+ },
350
+ },
351
+ {
352
+ name: 'pugi.publish',
353
+ description: 'Bump @pugi/cli version + build + publish to npm. Use bumpType "beta" for ' +
354
+ 'prerelease bumps (default) or "patch" for stable. Requires ' +
355
+ 'PUGI_MCP_PUBLISH_ENABLED=1 AND a configured ~/.npmrc auth token.',
356
+ permission: 'network',
357
+ inputSchema: {
358
+ type: 'object',
359
+ additionalProperties: false,
360
+ properties: {
361
+ bumpType: {
362
+ type: 'string',
363
+ enum: ['patch', 'beta'],
364
+ description: 'Default "beta" — pre-release bump.',
365
+ },
366
+ },
367
+ },
368
+ async execute(args) {
369
+ if (!ctx.capabilities.publish) {
370
+ throw new Error('pugi.publish: PUGI_MCP_PUBLISH_ENABLED is not set. ' +
371
+ 'Restart `pugi mcp serve` with PUGI_MCP_PUBLISH_ENABLED=1 to enable.');
372
+ }
373
+ const bumpType = optionalString(args, 'bumpType') ?? 'beta';
374
+ if (bumpType !== 'patch' && bumpType !== 'beta') {
375
+ throw new Error(`pugi.publish: invalid bumpType "${bumpType}"`);
376
+ }
377
+ // npm version semantics: "patch" bumps z; "prerelease --preid beta"
378
+ // bumps the beta tag. We thread through `pnpm` because the
379
+ // monorepo build expects the workspace-aware variant.
380
+ const versionArgs = bumpType === 'beta'
381
+ ? ['version', 'prerelease', '--preid', 'beta', '--no-git-tag-version']
382
+ : ['version', 'patch', '--no-git-tag-version'];
383
+ const versionOut = await execImpl('npm', versionArgs, {
384
+ cwd: ctx.workspaceRoot,
385
+ timeout: 60000,
386
+ env: sanitisedEnv(),
387
+ });
388
+ const newVersion = (versionOut.stdout || '').trim().replace(/^v/, '');
389
+ const buildOut = await execImpl('pnpm', ['build'], {
390
+ cwd: ctx.workspaceRoot,
391
+ timeout: 180000,
392
+ env: sanitisedEnv(),
393
+ });
394
+ const publishOut = await execImpl('pnpm', ['publish', '--no-git-checks', '--access', 'public'], {
395
+ cwd: ctx.workspaceRoot,
396
+ timeout: 180000,
397
+ env: sanitisedEnv(),
398
+ });
399
+ return JSON.stringify({
400
+ newVersion,
401
+ registry: 'https://registry.npmjs.org',
402
+ npmExitCode: 0,
403
+ buildStdoutTail: clamp(buildOut.stdout, 2000),
404
+ publishStdoutTail: clamp(publishOut.stdout, 2000),
405
+ });
406
+ },
407
+ },
408
+ {
409
+ name: 'pugi.deploy',
410
+ description: 'SSH-redeploy a Pugi service on the engine VM (admin-api / admin-web / ' +
411
+ 'pugi-web / all). Runs git pull + pnpm install + build + pm2 restart. ' +
412
+ 'Requires PUGI_MCP_DEPLOY_ENABLED=1.',
413
+ permission: 'network',
414
+ inputSchema: {
415
+ type: 'object',
416
+ additionalProperties: false,
417
+ required: ['target'],
418
+ properties: {
419
+ target: {
420
+ type: 'string',
421
+ enum: ['admin-api', 'admin-web', 'pugi-web', 'all'],
422
+ },
423
+ },
424
+ },
425
+ async execute(args) {
426
+ if (!ctx.capabilities.deploy) {
427
+ throw new Error('pugi.deploy: PUGI_MCP_DEPLOY_ENABLED is not set. ' +
428
+ 'Restart `pugi mcp serve` with PUGI_MCP_DEPLOY_ENABLED=1 to enable.');
429
+ }
430
+ const target = requireString(args, 'target');
431
+ const allowed = ['admin-api', 'admin-web', 'pugi-web', 'all'];
432
+ if (!allowed.includes(target)) {
433
+ throw new Error(`pugi.deploy: invalid target "${target}" (allowed: ${allowed.join(', ')})`);
434
+ }
435
+ // The redeploy script lives on the engine VM at ~/deploy/<target>.sh.
436
+ // We do NOT inline the shell — the operator owns the remote
437
+ // script and can tune it without rebuilding the CLI.
438
+ const remoteCmd = `set -euo pipefail; ~/deploy/${target}.sh`;
439
+ const started = Date.now();
440
+ const { stdout, stderr } = await execImpl('ssh', [
441
+ // BatchMode rejects password prompts so a misconfigured
442
+ // ssh-agent fails fast instead of blocking the dispatch.
443
+ '-o',
444
+ 'BatchMode=yes',
445
+ '-o',
446
+ 'StrictHostKeyChecking=accept-new',
447
+ ctx.sshAlias,
448
+ remoteCmd,
449
+ ], {
450
+ cwd: ctx.workspaceRoot,
451
+ timeout: 300000,
452
+ maxBuffer: 4 * 1024 * 1024,
453
+ env: sanitisedEnv(),
454
+ });
455
+ const durationMs = Date.now() - started;
456
+ return JSON.stringify({
457
+ host: ctx.sshAlias,
458
+ target,
459
+ gitPullHead: extractGitHead(stdout) ?? null,
460
+ pm2Status: extractPm2Status(stdout, stderr) ?? null,
461
+ durationMs,
462
+ stdoutTail: clamp(stdout, 4000),
463
+ stderrTail: clamp(stderr, 2000),
464
+ });
465
+ },
466
+ },
467
+ ];
468
+ return tools.sort((a, b) => a.name.localeCompare(b.name));
469
+ }
470
+ /* ---------- helpers ---------------------------------------------------- */
471
+ function requireString(args, key) {
472
+ const v = args[key];
473
+ if (typeof v !== 'string' || v.length === 0) {
474
+ throw new Error(`argument "${key}" must be a non-empty string`);
475
+ }
476
+ return v;
477
+ }
478
+ function optionalString(args, key) {
479
+ const v = args[key];
480
+ if (v === undefined || v === null)
481
+ return undefined;
482
+ if (typeof v !== 'string') {
483
+ throw new Error(`argument "${key}" must be a string when set`);
484
+ }
485
+ return v;
486
+ }
487
+ function optionalNumber(args, key, fallback) {
488
+ const v = args[key];
489
+ if (v === undefined || v === null)
490
+ return fallback;
491
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
492
+ throw new Error(`argument "${key}" must be a finite number when set`);
493
+ }
494
+ return v;
495
+ }
496
+ function clamp(s, max) {
497
+ if (typeof s !== 'string')
498
+ return '';
499
+ if (s.length <= max)
500
+ return s;
501
+ return `${s.slice(0, max)}\n…(truncated at ${max} bytes)`;
502
+ }
503
+ /**
504
+ * Tokenise an argv tail the same way Claude Code's `pugi run` quoting
505
+ * convention does — whitespace-split with double-quote groups
506
+ * preserved. We do NOT eval a shell because that would let the model
507
+ * inject arbitrary commands (e.g. `; rm -rf ~`) into the orchestrator
508
+ * surface. Anything fancier (env-var expansion, globbing) must be
509
+ * delegated to the model via a `bash` capability flag — which is
510
+ * intentionally not part of this surface.
511
+ *
512
+ * Exported for the spec.
513
+ */
514
+ export function tokeniseArgv(command) {
515
+ const out = [];
516
+ let buf = '';
517
+ let inQuotes = false;
518
+ for (let i = 0; i < command.length; i += 1) {
519
+ const ch = command[i];
520
+ if (ch === '"') {
521
+ inQuotes = !inQuotes;
522
+ continue;
523
+ }
524
+ if (ch === '\\' && command[i + 1] === '"') {
525
+ buf += '"';
526
+ i += 1;
527
+ continue;
528
+ }
529
+ if (!inQuotes && (ch === ' ' || ch === '\t')) {
530
+ if (buf.length > 0) {
531
+ out.push(buf);
532
+ buf = '';
533
+ }
534
+ continue;
535
+ }
536
+ buf += ch;
537
+ }
538
+ if (inQuotes) {
539
+ throw new Error('pugi.run: unterminated double-quote in command');
540
+ }
541
+ if (buf.length > 0)
542
+ out.push(buf);
543
+ return out;
544
+ }
545
+ function sanitisedEnv() {
546
+ // Allowlist — pass through only what `pugi` needs to find itself
547
+ // and the local toolchain. NPM_TOKEN is added back for
548
+ // `pugi.publish` via the npm CLI's own ~/.npmrc lookup — we do not
549
+ // pass it via env because that surface ends up in `ps` output on
550
+ // some kernels.
551
+ const allow = ['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'NODE_OPTIONS'];
552
+ const out = {};
553
+ for (const key of allow) {
554
+ const value = process.env[key];
555
+ if (value !== undefined)
556
+ out[key] = value;
557
+ }
558
+ return out;
559
+ }
560
+ async function safeText(response) {
561
+ try {
562
+ return await response.text();
563
+ }
564
+ catch {
565
+ return '';
566
+ }
567
+ }
568
+ function extractGitHead(stdout) {
569
+ // Match "HEAD is now at <sha> …" or "<sha> commit message" — the
570
+ // remote redeploy script logs `git rev-parse HEAD` after pull.
571
+ const m = stdout.match(/(?:HEAD is now at|^|\n)([0-9a-f]{7,40})\b/);
572
+ return m ? m[1] : null;
573
+ }
574
+ function extractPm2Status(stdout, stderr) {
575
+ const haystack = `${stdout}\n${stderr}`;
576
+ // Match "[PM2] Process pugi-admin-api restarted" or "online" / "stopped"
577
+ const restart = haystack.match(/\[PM2\][^\n]+(restarted|online|stopped|errored)/i);
578
+ if (restart)
579
+ return restart[0].trim();
580
+ return null;
581
+ }
582
+ /* ---------- helper: load this module from compiled JS at runtime ------- */
583
+ // `fileURLToPath(import.meta.url)` is used by sibling modules to find
584
+ // fixtures at runtime; we re-export it here so the spec can build an
585
+ // isolated workspace next to the compiled module without hard-coding
586
+ // paths. Defensive — not currently used by the production wiring.
587
+ export const ORCHESTRATOR_TOOLS_MODULE_FILE = (() => {
588
+ try {
589
+ return fileURLToPath(import.meta.url);
590
+ }
591
+ catch {
592
+ return '';
593
+ }
594
+ })();
595
+ //# sourceMappingURL=orchestrator-tools.js.map