unfold-deploy 0.0.0-stage → 0.2.0

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 (48) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +150 -2
  4. package/bin/unfold.js +14 -0
  5. package/demos/claude-success.json +72 -0
  6. package/demos/codex-checks-failing.json +62 -0
  7. package/demos/gcloud-deploy-failure.json +616 -0
  8. package/demos/gcloud-success.json +608 -0
  9. package/demos/vercel-build-failure.json +331 -0
  10. package/demos/vercel-interrupted.json +271 -0
  11. package/demos/vercel-success.json +343 -0
  12. package/dist/cli.js +121 -0
  13. package/dist/commands/demo.js +75 -0
  14. package/dist/commands/observe.js +137 -0
  15. package/dist/commands/replay.js +76 -0
  16. package/dist/commands/run.js +24 -0
  17. package/dist/commands/show.js +44 -0
  18. package/dist/core/errors.js +3 -0
  19. package/dist/core/fix-prompt.js +85 -0
  20. package/dist/core/flow.js +211 -0
  21. package/dist/core/launch.js +196 -0
  22. package/dist/core/model.js +3 -0
  23. package/dist/core/redact.js +79 -0
  24. package/dist/core/server.js +109 -0
  25. package/dist/core/session.js +136 -0
  26. package/dist/core/store.js +52 -0
  27. package/dist/core/terminal.js +109 -0
  28. package/dist/providers/agents/claude.js +205 -0
  29. package/dist/providers/agents/codex.js +152 -0
  30. package/dist/providers/agents/shared.js +119 -0
  31. package/dist/providers/gcloud/index.js +39 -0
  32. package/dist/providers/gcloud/parser.js +149 -0
  33. package/dist/providers/gcloud/stages.js +55 -0
  34. package/dist/providers/index.js +5 -0
  35. package/dist/providers/provider.js +1 -0
  36. package/dist/providers/vercel/index.js +56 -0
  37. package/dist/providers/vercel/parser.js +239 -0
  38. package/dist/providers/vercel/stages.js +54 -0
  39. package/dist/tui/ansi.js +71 -0
  40. package/dist/tui/explore.js +220 -0
  41. package/dist/tui/panel.js +118 -0
  42. package/dist/tui/render.js +140 -0
  43. package/dist/tui/restore.js +29 -0
  44. package/dist/ui/app.js +332 -0
  45. package/dist/ui/index.html +60 -0
  46. package/dist/ui/styles.css +220 -0
  47. package/package.json +64 -4
  48. package/scripts/postinstall.mjs +13 -0
@@ -0,0 +1,76 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readRun, runsDir } from '../core/store.js';
4
+ import { providers } from '../providers/index.js';
5
+ import { observe, say } from './observe.js';
6
+ /** Replays a run saved by `unfold vercel` (already-redacted events). */
7
+ export async function runReplay(file, flags, speed) {
8
+ const path = file ? ((await import('./show.js')).resolveRun(file) ?? file) : latestRun();
9
+ if (!path) {
10
+ say(`No saved runs in ${runsDir()}.`);
11
+ return 1;
12
+ }
13
+ const events = readRun(path);
14
+ const started = events.find((e) => e.type === 'run.started');
15
+ const ended = events.find((e) => e.type === 'run.ended');
16
+ const provider = started && providers[started.provider];
17
+ if (!started || !provider) {
18
+ say(`${path} is not an Unfold run file.`);
19
+ return 1;
20
+ }
21
+ say(`Replaying ${path}`);
22
+ return observe({
23
+ provider,
24
+ command: started.command,
25
+ cwd: started.cwd,
26
+ mode: 'replay',
27
+ flags,
28
+ save: false,
29
+ waitForBrowser: true,
30
+ run: async (session, term) => {
31
+ const t0 = started.at;
32
+ const start = Date.now();
33
+ const wait = async (at) => {
34
+ const ms = (at - t0) / speed - (Date.now() - start);
35
+ if (ms > 0)
36
+ await new Promise((r) => setTimeout(r, Math.min(ms, 2000)));
37
+ };
38
+ if (provider.createPresenter) {
39
+ // Structured providers: raw events aren't saved, so replay the recorded events.
40
+ for (const e of events) {
41
+ if (e.type === 'log')
42
+ await wait(e.line.at);
43
+ session.inject(e);
44
+ }
45
+ session.endReplay(ended?.exitCode ?? null, ended?.signal ?? null, ended?.interrupted ?? false);
46
+ return { exitCode: ended?.exitCode ?? null, signal: ended?.signal ?? null, interrupted: ended?.interrupted ?? false };
47
+ }
48
+ // Re-run the original lines through today's parser so parser fixes apply to old runs.
49
+ for (const e of events) {
50
+ if (e.type !== 'log')
51
+ continue;
52
+ const wait = (e.line.at - t0) / speed - (Date.now() - start);
53
+ if (wait > 0)
54
+ await new Promise((r) => setTimeout(r, Math.min(wait, 2000)));
55
+ term.write(e.line.text + '\r\n');
56
+ session.write(e.line.text + '\n');
57
+ }
58
+ return { exitCode: ended?.exitCode ?? null, signal: ended?.signal ?? null, interrupted: ended?.interrupted ?? false };
59
+ },
60
+ });
61
+ }
62
+ export function listRuns() {
63
+ try {
64
+ const dir = runsDir();
65
+ return readdirSync(dir)
66
+ .filter((f) => f.endsWith('.jsonl'))
67
+ .map((f) => join(dir, f))
68
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ function latestRun() {
75
+ return listRuns()[0];
76
+ }
@@ -0,0 +1,24 @@
1
+ import { launch, which } from '../core/launch.js';
2
+ import { redactArgs } from '../core/redact.js';
3
+ import { observe, say } from './observe.js';
4
+ export async function runLive(provider, userArgs, flags) {
5
+ const cwd = process.cwd();
6
+ const spec = provider.launch(userArgs);
7
+ const bin = which(spec.bin);
8
+ if (!bin) {
9
+ say(`Could not find "${spec.bin}" on your PATH.${provider.install ? ` ${provider.install}` : ''}`);
10
+ return 127;
11
+ }
12
+ for (const msg of provider.preflight?.(cwd) ?? [])
13
+ say(msg);
14
+ say(`Running: ${[spec.bin, ...redactArgs(spec.args)].join(' ')}`);
15
+ return observe({
16
+ provider,
17
+ command: [spec.bin, ...redactArgs(spec.args)],
18
+ cwd,
19
+ mode: 'live',
20
+ flags,
21
+ save: true,
22
+ run: (session, term) => launch({ bin, args: spec.args, cwd, term, onOutput: (d) => session.write(d) }),
23
+ });
24
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { FlowReducer } from '../core/flow.js';
4
+ import { readRun, runsDir } from '../core/store.js';
5
+ import { explore } from '../tui/explore.js';
6
+ import { style } from '../tui/ansi.js';
7
+ import { summary } from '../tui/render.js';
8
+ import { say } from './observe.js';
9
+ import { listRuns, runReplay } from './replay.js';
10
+ /** Resolves a run file from a path, a run id (or its prefix), or the latest run. */
11
+ export function resolveRun(ref) {
12
+ if (!ref)
13
+ return listRuns()[0];
14
+ if (existsSync(ref))
15
+ return ref;
16
+ const byId = join(runsDir(), ref.endsWith('.jsonl') ? ref : `${ref}.jsonl`);
17
+ if (existsSync(byId))
18
+ return byId;
19
+ return listRuns().find((p) => p.split('/').pop().startsWith(ref));
20
+ }
21
+ /** `unfold show`: explore a finished run in the terminal (or the browser with --web). */
22
+ export async function runShow(ref, flags) {
23
+ const path = resolveRun(ref);
24
+ if (!path) {
25
+ say(ref ? `No run matching "${ref}" in ${runsDir()}.` : `No saved runs yet in ${runsDir()}. Run \`unfold vercel\` or \`unfold demo\` first.`);
26
+ return 1;
27
+ }
28
+ if (flags.web)
29
+ return runReplay(path, { ...flags, panel: false }, 1e9);
30
+ const reducer = new FlowReducer();
31
+ for (const e of readRun(path))
32
+ reducer.apply(e);
33
+ const state = reducer.state;
34
+ if (!state) {
35
+ say(`${path} is not an Unfold run file.`);
36
+ return 1;
37
+ }
38
+ const logs = [...reducer.logs.values()];
39
+ if (process.stdout.isTTY && process.stdin.isTTY)
40
+ await explore(state, logs);
41
+ else
42
+ process.stdout.write(summary(state, logs, { s: style(false) }));
43
+ return 0;
44
+ }
@@ -0,0 +1,3 @@
1
+ /** A mistake in how Unfold was invoked: printed without a stack trace. */
2
+ export class UsageError extends Error {
3
+ }
@@ -0,0 +1,85 @@
1
+ import { homedir } from 'node:os';
2
+ export const AGENTS = ['claude', 'codex'];
3
+ const MAX_EXCERPT = 40;
4
+ const STAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z {0,2}/;
5
+ // path/to/file.ext:line[:col], as printed by compilers, bundlers and linters
6
+ const FILE_REF = /(?:^|[\s(])((?:\.{0,2}\/)?[\w@.-]+(?:\/[\w@.[\]-]+)*\.[A-Za-z]{1,5}):(\d+)(?::(\d+))?/g;
7
+ /** Most specific cause first, then by position in the output. */
8
+ export function rankCauses(state) {
9
+ const fatal = state.errors.find((e) => e.severity === 'fatal');
10
+ return state.errors
11
+ .filter((e) => e.severity === 'cause' && e.message !== fatal?.message)
12
+ .sort((a, b) => (b.priority ?? 1) - (a.priority ?? 1) || a.seq - b.seq);
13
+ }
14
+ function fileRefs(lines) {
15
+ const refs = new Set();
16
+ for (const line of lines) {
17
+ for (const m of line.replace(STAMP, '').matchAll(FILE_REF)) {
18
+ if (/^https?:/.test(m[1]) || m[1].includes('node_modules'))
19
+ continue;
20
+ refs.add(`${m[1]}:${m[2]}${m[3] ? `:${m[3]}` : ''}`);
21
+ }
22
+ }
23
+ return [...refs].slice(0, 8);
24
+ }
25
+ function excerpt(state, logs, anchor) {
26
+ const sorted = [...logs].sort((a, b) => a.seq - b.seq);
27
+ if (!sorted.length)
28
+ return { from: 0, to: 0, lines: [] };
29
+ const fatal = state.errors.find((e) => e.severity === 'fatal');
30
+ const end = fatal?.seq ?? sorted.at(-1).seq;
31
+ const start = anchor ? Math.max(anchor.seq - 12, end - MAX_EXCERPT + 1) : end - 30;
32
+ const lines = sorted.filter((l) => l.seq >= start && l.seq <= end).slice(-MAX_EXCERPT);
33
+ while (lines.length && lines[0].text.replace(STAMP, '').trim() === '')
34
+ lines.shift();
35
+ return { from: lines[0]?.seq ?? 0, to: lines.at(-1)?.seq ?? 0, lines };
36
+ }
37
+ const fence = (body) => '```\n' + body.replace(/```/g, '`​``') + '\n```';
38
+ /**
39
+ * Builds a prompt for a coding agent from a failed run. Uses only the already
40
+ * redacted log lines and parsed facts, so it contains nothing that isn't on the page.
41
+ */
42
+ export function buildFixPrompt(state, logs, hints = []) {
43
+ if (state.outcome !== 'failure')
44
+ return undefined;
45
+ const stage = (id) => state.stages.find((s) => s.id === id)?.title;
46
+ const fatal = state.errors.find((e) => e.severity === 'fatal');
47
+ const causes = rankCauses(state);
48
+ const top = causes[0];
49
+ const ex = excerpt(state, logs, top);
50
+ const refs = fileRefs([...causes.map((c) => c.message), ...ex.lines.map((l) => l.text)]);
51
+ const facts = state.facts.filter((f) => !['inspectUrl', 'deploymentUrl', 'cliVersion', 'duration', 'region', 'machine'].includes(f.key));
52
+ const failedStage = stage(state.failedStage) ?? 'an unknown stage';
53
+ const out = [];
54
+ out.push(`# Fix a failed ${state.title.toLowerCase()}`, '');
55
+ out.push(`I ran \`${state.command.join(' ')}\` in \`${state.cwd}\` and it failed during the **${failedStage}** stage` +
56
+ (state.lastConfirmed ? `. The last stage that completed was **${stage(state.lastConfirmed)}**.` : ', before any stage completed.') +
57
+ ` It exited with ${state.exitCode !== null ? `code ${state.exitCode}` : (state.exitSignal ?? 'an unknown status')}.`);
58
+ out.push('', '## Error', fence(fatal?.message ?? 'The command failed without printing a recognizable error. See the log excerpt.'));
59
+ if (causes.length) {
60
+ out.push('', '## Most relevant output', fence(causes.slice(0, 4).map((c) => c.message).join('\n')));
61
+ }
62
+ if (ex.lines.length) {
63
+ out.push('', `## Log excerpt (lines ${ex.from}–${ex.to})`, fence(ex.lines.map((l) => l.text.replace(STAMP, '')).join('\n')));
64
+ }
65
+ const context = [
66
+ ...facts.map((f) => `- ${f.label}: ${f.value}`),
67
+ ...(refs.length ? [`- Files mentioned in the output: ${refs.map((r) => `\`${r}\``).join(', ')}`] : []),
68
+ ];
69
+ if (context.length)
70
+ out.push('', '## Context', ...context);
71
+ if (hints.length)
72
+ out.push('', '## Things to know about this failure', ...hints.map((h) => `- ${h}`));
73
+ out.push('', '## What I want', `1. Find the root cause in this repository${refs.length ? ', starting with the files mentioned above' : ''}.`, '2. Make the smallest change that fixes it, then reproduce the failing step locally to confirm the fix.', "3. Don't deploy, and don't change hosting settings or environment variables. If the fix needs that, tell me exactly what to change instead.", '4. Summarize what was wrong and what you changed.', '', 'Values shown as [REDACTED] were removed on purpose; don\'t try to recover them.');
74
+ return out.join('\n') + '\n';
75
+ }
76
+ const shQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
77
+ /** One-line shell commands that start each agent with the saved prompt. */
78
+ export function agentCommands(promptPath, home = homedir()) {
79
+ // `~/…` is shorter to read and copy; only used when no quoting is needed.
80
+ const safe = /^[\w./-]+$/;
81
+ const rest = promptPath.startsWith(home + '/') ? promptPath.slice(home.length) : undefined;
82
+ const ref = rest && safe.test(home) && safe.test(rest) ? `~${rest}` : shQuote(promptPath);
83
+ const arg = `"$(cat ${ref})"`;
84
+ return { claude: `claude ${arg}`, codex: `codex ${arg}` };
85
+ }
@@ -0,0 +1,211 @@
1
+ const MAX_EVIDENCE = 40;
2
+ const DEFAULT_NOTES = {
3
+ interrupted: 'You pressed Ctrl+C while this stage was in progress, so its outcome was never reported.',
4
+ unverified: "This run's output never confirmed this stage.",
5
+ };
6
+ /**
7
+ * Pure reducer from RunEvents to FlowState plus the log. Rules:
8
+ * - Stages only move forward on explicit signals from the provider parser; a later
9
+ * stage starting never implies an earlier one finished.
10
+ * - When the run ends, anything not confirmed becomes `unverified` (or `failed` for
11
+ * the stage the failure is attributed to).
12
+ */
13
+ export class FlowReducer {
14
+ state;
15
+ logs = new Map();
16
+ notes = DEFAULT_NOTES;
17
+ apply(event) {
18
+ if (event.type === 'run.started') {
19
+ this.notes = { ...DEFAULT_NOTES, ...event.notes };
20
+ this.logs.clear();
21
+ this.state = {
22
+ runId: event.runId,
23
+ provider: event.provider,
24
+ title: event.title,
25
+ command: event.command,
26
+ cwd: event.cwd,
27
+ mode: event.mode,
28
+ startedAt: event.at,
29
+ outcome: 'running',
30
+ exitCode: null,
31
+ exitSignal: null,
32
+ stages: event.stages.map((d) => ({ ...d, status: 'pending', evidence: [] })),
33
+ notes: event.notes,
34
+ facts: [],
35
+ errors: [],
36
+ lineCount: 0,
37
+ };
38
+ return;
39
+ }
40
+ const s = this.state;
41
+ if (!s)
42
+ return;
43
+ switch (event.type) {
44
+ case 'log':
45
+ this.logs.set(event.line.seq, event.line);
46
+ s.lineCount = this.logs.size;
47
+ return;
48
+ case 'signal':
49
+ this.signal(s, event);
50
+ return;
51
+ case 'run.ended':
52
+ this.end(s, event);
53
+ return;
54
+ case 'fix.ready':
55
+ s.fix = event.fix;
56
+ return;
57
+ }
58
+ }
59
+ stage(s, id) {
60
+ return id ? s.stages.find((st) => st.id === id) : undefined;
61
+ }
62
+ addEvidence(st, seq, at, note) {
63
+ const text = this.logs.get(seq)?.text ?? '';
64
+ const existing = st.evidence.find((e) => e.seq === seq);
65
+ if (existing) {
66
+ // Same line redrawn (e.g. a progress bar): keep the latest version.
67
+ existing.text = text;
68
+ existing.note = note;
69
+ existing.at = at;
70
+ return;
71
+ }
72
+ st.evidence.push({ seq, at, text, note });
73
+ if (st.evidence.length > MAX_EVIDENCE)
74
+ st.evidence.splice(1, 1); // keep the first line
75
+ }
76
+ signal(s, e) {
77
+ const sig = e.signal;
78
+ switch (sig.kind) {
79
+ case 'stage': {
80
+ const st = this.stage(s, sig.stage);
81
+ if (!st)
82
+ return;
83
+ if (sig.status === 'active') {
84
+ if (st.status === 'pending' || st.status === 'active') {
85
+ st.status = 'active';
86
+ st.startedAt ??= e.at;
87
+ st.note = sig.note;
88
+ }
89
+ }
90
+ else if (sig.status === 'confirmed') {
91
+ if (st.status !== 'failed') {
92
+ st.status = 'confirmed';
93
+ st.startedAt ??= e.at;
94
+ st.endedAt = e.at;
95
+ st.note = sig.note;
96
+ }
97
+ }
98
+ else if (sig.status === 'unverified') {
99
+ // A parser saying "this happened but didn't succeed" (e.g. checks ran and failed).
100
+ if (st.status === 'pending' || st.status === 'active') {
101
+ st.status = 'unverified';
102
+ st.note = sig.note;
103
+ }
104
+ }
105
+ else if (st.status !== 'confirmed') {
106
+ st.status = 'failed';
107
+ st.endedAt = e.at;
108
+ st.note = sig.note;
109
+ s.failedStage = st.id;
110
+ }
111
+ this.addEvidence(st, e.seq, e.at, sig.note);
112
+ this.updateLastConfirmed(s);
113
+ return;
114
+ }
115
+ case 'evidence': {
116
+ const st = this.stage(s, sig.stage);
117
+ if (st)
118
+ this.addEvidence(st, e.seq, e.at, sig.note);
119
+ return;
120
+ }
121
+ case 'fact': {
122
+ const existing = s.facts.find((f) => f.key === sig.key);
123
+ const fact = { key: sig.key, label: sig.label, value: sig.value, stage: sig.stage, href: sig.href, seq: e.seq };
124
+ if (existing)
125
+ Object.assign(existing, fact);
126
+ else
127
+ s.facts.push(fact);
128
+ return;
129
+ }
130
+ case 'error': {
131
+ const info = { severity: sig.severity, priority: sig.priority, stage: sig.stage, message: sig.message, seq: e.seq, at: e.at };
132
+ if (!s.errors.some((x) => x.seq === e.seq && x.message === sig.message))
133
+ s.errors.push(info);
134
+ if (sig.severity === 'fatal') {
135
+ const st = this.stage(s, sig.stage) ?? this.currentStage(s);
136
+ if (st && st.status !== 'confirmed') {
137
+ // Keep a note the parser already gave the failure (e.g. "Creating Revision failed.");
138
+ // the full message is shown as the error.
139
+ if (st.status !== 'failed' || !st.note)
140
+ st.note = sig.message;
141
+ st.status = 'failed';
142
+ st.endedAt = e.at;
143
+ s.failedStage = st.id;
144
+ info.stage = st.id;
145
+ this.addEvidence(st, e.seq, e.at, 'Error reported here');
146
+ }
147
+ }
148
+ return;
149
+ }
150
+ }
151
+ }
152
+ /** The stage work is most plausibly happening in right now. */
153
+ currentStage(s) {
154
+ const active = [...s.stages].reverse().find((st) => st.status === 'active');
155
+ if (active)
156
+ return active;
157
+ const lastIdx = s.stages.findLastIndex((st) => st.status === 'confirmed');
158
+ return s.stages[lastIdx + 1] ?? s.stages[lastIdx];
159
+ }
160
+ updateLastConfirmed(s) {
161
+ const idx = s.stages.findLastIndex((st) => st.status === 'confirmed');
162
+ s.lastConfirmed = idx >= 0 ? s.stages[idx].id : undefined;
163
+ }
164
+ end(s, e) {
165
+ s.endedAt = e.at;
166
+ s.exitCode = e.exitCode;
167
+ s.exitSignal = e.signal;
168
+ const failed = e.exitCode !== 0 || e.signal !== null;
169
+ const finished = s.stages.at(-1)?.status === 'confirmed';
170
+ // Ctrl+C after the workflow already finished successfully does not change the outcome.
171
+ if (e.interrupted && (failed || !finished)) {
172
+ s.outcome = 'interrupted';
173
+ }
174
+ else if (failed) {
175
+ s.outcome = 'failure';
176
+ if (!s.failedStage) {
177
+ const st = this.stage(s, s.errors.find((x) => x.severity === 'fatal')?.stage) ?? this.currentStage(s);
178
+ if (st && st.status !== 'confirmed') {
179
+ st.status = 'failed';
180
+ st.endedAt = e.at;
181
+ st.note ??= `The command exited with code ${e.exitCode ?? e.signal} during this stage.`;
182
+ s.failedStage = st.id;
183
+ }
184
+ }
185
+ }
186
+ else {
187
+ s.outcome = 'success';
188
+ }
189
+ // Index of the furthest stage the run is known to have reached.
190
+ const reached = s.stages.findLastIndex((st) => st.status !== 'pending');
191
+ s.stages.forEach((st, i) => {
192
+ if (st.status === 'active') {
193
+ st.status = 'unverified';
194
+ st.note = s.outcome === 'interrupted' ? this.notes.interrupted : this.notes.unverified;
195
+ }
196
+ else if (st.status === 'pending') {
197
+ st.status = 'unverified';
198
+ // A stage skipped over by later ones: prefer the parser's own explanation, if it gave one.
199
+ if (i < reached)
200
+ st.note = st.evidence.at(-1)?.note ?? this.notes.unverified;
201
+ else if (s.outcome === 'interrupted')
202
+ st.note = 'The run stopped before this stage was observed.';
203
+ else if (s.outcome === 'failure')
204
+ st.note = 'Not reached: the run failed before this stage.';
205
+ else
206
+ st.note = this.notes.unverified;
207
+ }
208
+ });
209
+ this.updateLastConfirmed(s);
210
+ }
211
+ }
@@ -0,0 +1,196 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { chmodSync, existsSync, statSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { delimiter, dirname, isAbsolute, join } from 'node:path';
5
+ import { onRestore } from '../tui/restore.js';
6
+ const require = createRequire(import.meta.url);
7
+ /** Finds an executable on PATH without running a shell. */
8
+ export function which(bin) {
9
+ if (isAbsolute(bin) || bin.includes('/'))
10
+ return existsSync(bin) ? bin : undefined;
11
+ const exts = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
12
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
13
+ for (const ext of exts) {
14
+ const p = join(dir, bin + ext);
15
+ try {
16
+ if (statSync(p).isFile())
17
+ return p;
18
+ }
19
+ catch {
20
+ // not here
21
+ }
22
+ }
23
+ }
24
+ return undefined;
25
+ }
26
+ /** `UNFOLD_TERMINAL=script|pipe` forces a fallback (for troubleshooting and tests). */
27
+ const forced = () => process.env.UNFOLD_TERMINAL;
28
+ function loadPty() {
29
+ if (forced() === 'script' || forced() === 'pipe')
30
+ return undefined;
31
+ try {
32
+ const pty = require('node-pty');
33
+ // node-pty 1.1.0 prebuilds can ship spawn-helper without its execute bit.
34
+ const root = dirname(require.resolve('node-pty/package.json'));
35
+ const helper = join(root, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
36
+ if (existsSync(helper) && !(statSync(helper).mode & 0o111))
37
+ chmodSync(helper, 0o755);
38
+ return pty;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ /**
45
+ * Runs the command attached to the user's terminal. With node-pty the child sees a
46
+ * real TTY, so prompts, colors and spinners behave exactly as they would without
47
+ * Unfold; all keystrokes (including Ctrl+C) are forwarded to it.
48
+ *
49
+ * The child inherits the environment so it can use the existing login and project
50
+ * config. Unfold never records the environment.
51
+ */
52
+ export function launch(opts) {
53
+ const pty = loadPty();
54
+ if (pty)
55
+ return launchPty(pty, opts);
56
+ if (terminalMode() === 'script')
57
+ return launchScript(opts);
58
+ return launchPiped(opts);
59
+ }
60
+ /**
61
+ * How the wrapped command will be attached to the terminal:
62
+ * - `pty`: node-pty, full control (live panel supported)
63
+ * - `script`: the system `script` command gives the child a real TTY; used when node-pty
64
+ * isn't installed (e.g. Linux without build tools). The panel is off because the child
65
+ * sizes itself to the whole terminal.
66
+ * - `pipe`: no TTY for the child; prompts and spinners may behave differently.
67
+ */
68
+ export function terminalMode() {
69
+ if (loadPty())
70
+ return 'pty';
71
+ if (forced() !== 'pipe' && process.platform !== 'win32' && process.stdin.isTTY && which('script'))
72
+ return 'script';
73
+ return 'pipe';
74
+ }
75
+ const shQuote = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
76
+ function launchScript({ bin, args, cwd, term: io, onOutput }) {
77
+ // BSD/macOS: script -q file cmd args… util-linux: script -qefc "cmd args…" file
78
+ const scriptArgs = process.platform === 'darwin' || process.platform.endsWith('bsd')
79
+ ? ['-q', '/dev/null', bin, ...args]
80
+ : ['-qefc', [bin, ...args].map(shQuote).join(' '), '/dev/null'];
81
+ const child = spawn('script', scriptArgs, { cwd, env: process.env, stdio: ['inherit', 'pipe', 'inherit'] });
82
+ let interrupted = false;
83
+ const onSignal = (sig) => {
84
+ interrupted = true;
85
+ child.kill(sig);
86
+ };
87
+ process.on('SIGINT', onSignal);
88
+ process.on('SIGTERM', onSignal);
89
+ // `script` puts the terminal in raw mode itself; make sure it's undone if we die first.
90
+ const release = onRestore(() => process.stdin.isTTY && process.stdin.setRawMode(false));
91
+ child.stdout.on('data', (d) => {
92
+ const text = d.toString('utf8');
93
+ io.write(text);
94
+ onOutput(text);
95
+ });
96
+ return new Promise((resolve) => {
97
+ child.on('error', (err) => onOutput(`Error: ${err.message}\n`));
98
+ child.on('close', (code, signal) => {
99
+ release();
100
+ process.off('SIGINT', onSignal);
101
+ process.off('SIGTERM', onSignal);
102
+ // Ctrl+C reaches the child through the TTY, so infer it from the exit status.
103
+ resolve({ exitCode: code, signal, interrupted: interrupted || code === 130 || signal === 'SIGINT' });
104
+ });
105
+ });
106
+ }
107
+ function launchPty(pty, { bin, args, cwd, term: io, onOutput }) {
108
+ const stdin = process.stdin;
109
+ const stdout = process.stdout;
110
+ const term = pty.spawn(bin, args, {
111
+ name: process.env.TERM || 'xterm-256color',
112
+ cols: stdout.columns || 100,
113
+ rows: io.rows(),
114
+ cwd,
115
+ env: process.env,
116
+ });
117
+ let interrupted = false;
118
+ const onInput = (data) => {
119
+ if (data.includes(0x03))
120
+ interrupted = true;
121
+ term.write(data.toString('utf8'));
122
+ };
123
+ let alive = true;
124
+ const onResize = () => alive && term.resize(stdout.columns || 100, io.rows());
125
+ io.onRowsChange(onResize);
126
+ // Signals that reach Unfold directly (not via the TTY) are passed on.
127
+ const onSignal = (sig) => {
128
+ interrupted = true;
129
+ try {
130
+ term.kill(sig);
131
+ }
132
+ catch {
133
+ // already gone
134
+ }
135
+ };
136
+ if (stdin.isTTY)
137
+ stdin.setRawMode(true);
138
+ const release = onRestore(() => stdin.isTTY && stdin.setRawMode(false));
139
+ stdin.resume();
140
+ stdin.on('data', onInput);
141
+ stdout.on('resize', onResize);
142
+ process.on('SIGINT', onSignal);
143
+ process.on('SIGTERM', onSignal);
144
+ term.onData((d) => {
145
+ io.write(d);
146
+ onOutput(d);
147
+ });
148
+ return new Promise((resolve) => {
149
+ term.onExit(({ exitCode, signal }) => {
150
+ alive = false;
151
+ release();
152
+ stdin.off('data', onInput);
153
+ if (stdin.isTTY)
154
+ stdin.setRawMode(false);
155
+ stdin.pause();
156
+ stdout.off('resize', onResize);
157
+ process.off('SIGINT', onSignal);
158
+ process.off('SIGTERM', onSignal);
159
+ const sig = signal ? signalName(signal) : null;
160
+ resolve({ exitCode: sig ? null : exitCode, signal: sig, interrupted });
161
+ });
162
+ });
163
+ }
164
+ function launchPiped({ bin, args, cwd, term: io, onOutput }) {
165
+ process.stderr.write('[unfold] No pseudo-terminal available (node-pty is not installed); prompts and spinners may look different.\n');
166
+ const child = spawn(bin, args, { cwd, env: process.env, stdio: ['inherit', 'pipe', 'pipe'] });
167
+ let interrupted = false;
168
+ const onSignal = (sig) => {
169
+ interrupted = true;
170
+ child.kill(sig);
171
+ };
172
+ process.on('SIGINT', onSignal);
173
+ process.on('SIGTERM', onSignal);
174
+ child.stdout.on('data', (d) => {
175
+ io.write(d.toString('utf8'));
176
+ onOutput(d.toString('utf8'));
177
+ });
178
+ child.stderr.on('data', (d) => {
179
+ process.stderr.write(d);
180
+ onOutput(d.toString('utf8'));
181
+ });
182
+ return new Promise((resolve) => {
183
+ child.on('error', (err) => {
184
+ onOutput(`Error: ${err.message}\n`);
185
+ });
186
+ child.on('close', (code, signal) => {
187
+ process.off('SIGINT', onSignal);
188
+ process.off('SIGTERM', onSignal);
189
+ resolve({ exitCode: code, signal, interrupted });
190
+ });
191
+ });
192
+ }
193
+ function signalName(n) {
194
+ const names = { 1: 'SIGHUP', 2: 'SIGINT', 9: 'SIGKILL', 15: 'SIGTERM' };
195
+ return names[n] ?? `signal ${n}`;
196
+ }
@@ -0,0 +1,3 @@
1
+ // Shared, provider-agnostic event model. Providers (vercel today, gcloud later)
2
+ // only produce `Signal`s from log lines; everything else lives here.
3
+ export {};