unfold-deploy 0.1.0 → 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.
- package/CHANGELOG.md +9 -1
- package/README.md +44 -7
- package/demos/claude-success.json +72 -0
- package/demos/codex-checks-failing.json +62 -0
- package/demos/gcloud-deploy-failure.json +616 -0
- package/demos/gcloud-success.json +608 -0
- package/dist/cli.js +15 -7
- package/dist/commands/demo.js +22 -8
- package/dist/commands/observe.js +17 -2
- package/dist/commands/replay.js +16 -1
- package/dist/commands/run.js +1 -1
- package/dist/core/errors.js +3 -0
- package/dist/core/flow.js +12 -1
- package/dist/core/session.js +36 -5
- package/dist/core/terminal.js +4 -2
- package/dist/providers/agents/claude.js +205 -0
- package/dist/providers/agents/codex.js +152 -0
- package/dist/providers/agents/shared.js +119 -0
- package/dist/providers/gcloud/index.js +39 -0
- package/dist/providers/gcloud/parser.js +149 -0
- package/dist/providers/gcloud/stages.js +55 -0
- package/dist/providers/index.js +4 -1
- package/dist/providers/vercel/index.js +1 -0
- package/dist/providers/vercel/stages.js +3 -0
- package/dist/tui/render.js +14 -7
- package/dist/ui/app.js +6 -6
- package/package.json +12 -7
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export function agentStages(agent, docsUrl, permissions) {
|
|
2
|
+
return [
|
|
3
|
+
{
|
|
4
|
+
id: 'start',
|
|
5
|
+
title: 'Start',
|
|
6
|
+
summary: `${agent} starts a session with its model, settings and the tools it may use.`,
|
|
7
|
+
explanation: [
|
|
8
|
+
`Unfold runs ${agent} in its non-interactive mode, which reports every step as a structured event instead of drawing a full-screen interface.`,
|
|
9
|
+
`In this mode ${agent} cannot stop to ask you for permission, so it can only do what your settings and flags allow. ${permissions}`,
|
|
10
|
+
],
|
|
11
|
+
confirmedBy: 'The session start event.',
|
|
12
|
+
docsUrl,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'explore',
|
|
16
|
+
title: 'Explore',
|
|
17
|
+
summary: 'It reads files and searches the code to understand the task.',
|
|
18
|
+
explanation: [
|
|
19
|
+
'Agents usually start by looking around: listing folders, reading files, searching for symbols and running read-only commands such as `git status`.',
|
|
20
|
+
],
|
|
21
|
+
confirmedBy: 'A successful read, search or read-only command.',
|
|
22
|
+
docsUrl,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'change',
|
|
26
|
+
title: 'Change',
|
|
27
|
+
summary: 'It edits or creates files to carry out the task.',
|
|
28
|
+
explanation: [
|
|
29
|
+
'Edits are written straight to your working tree. Review them with `git diff` before committing.',
|
|
30
|
+
'If it is not allowed to edit, its attempts are denied and the task cannot be completed.',
|
|
31
|
+
],
|
|
32
|
+
confirmedBy: 'A successful file edit or write.',
|
|
33
|
+
docsUrl,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'verify',
|
|
37
|
+
title: 'Verify',
|
|
38
|
+
summary: 'It runs checks such as tests, a type check, a linter or a build.',
|
|
39
|
+
explanation: [
|
|
40
|
+
'Unfold counts a command as a check when it looks like one, for example `npm test`, `pytest`, `tsc`, `eslint`, `cargo build` or `make`.',
|
|
41
|
+
'If the agent never runs a check, this stays unverified: the change may still be right, but nothing in the run showed it working.',
|
|
42
|
+
],
|
|
43
|
+
confirmedBy: 'A check command that exits successfully.',
|
|
44
|
+
docsUrl,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'done',
|
|
48
|
+
title: 'Done',
|
|
49
|
+
summary: 'It reports its final answer and the session ends.',
|
|
50
|
+
explanation: ['The final event says whether the session succeeded and, depending on the agent, how long it took, how many turns it used and what it cost.'],
|
|
51
|
+
confirmedBy: 'The final result event, with no error.',
|
|
52
|
+
docsUrl,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
export const AGENT_NOTES = {
|
|
57
|
+
success: 'Done',
|
|
58
|
+
successHeadline: 'The agent finished',
|
|
59
|
+
docsLabel: 'Docs',
|
|
60
|
+
highlights: ['answer', 'filesChanged', 'denied', 'cost', 'tokens'],
|
|
61
|
+
interrupted: 'You pressed Ctrl+C while this was in progress. Changes the agent already wrote stay on disk; review them with `git diff`.',
|
|
62
|
+
unverified: "This run's output never showed this step.",
|
|
63
|
+
};
|
|
64
|
+
const VERIFY = /(^|[\s;&|(])((npm|pnpm|yarn|bun)( run)? (test|build|lint|typecheck|check)\b|npx (tsc|vitest|jest|eslint|playwright)|\b(tsc|vitest|jest|mocha|pytest|tox|ruff|mypy|eslint|phpunit|rspec|playwright)\b|go (test|build|vet)|cargo (test|build|check|clippy)|\bmake\b|gradle|mvn|dotnet (test|build)|swift (test|build))/;
|
|
65
|
+
const CHANGE = /(^|[\s;&|(])(sed -i|perl -pi|tee |mv |rm |cp |mkdir |touch |chmod |apply_patch|git (commit|apply|checkout|restore|reset|stash|add)|(npm|pnpm|yarn|bun) (i|install|add|remove|uninstall)\b|pip install)|[^2&]>{1,2}\s*(?!\/dev\/null)[\w./]/;
|
|
66
|
+
/** Rough classification of a shell command by what it does. */
|
|
67
|
+
export function classifyCommand(cmd) {
|
|
68
|
+
if (VERIFY.test(cmd))
|
|
69
|
+
return 'verify';
|
|
70
|
+
if (CHANGE.test(cmd))
|
|
71
|
+
return 'change';
|
|
72
|
+
return 'explore';
|
|
73
|
+
}
|
|
74
|
+
/** `/bin/zsh -lc "npm test"` → `npm test` (Codex wraps commands in a login shell). */
|
|
75
|
+
export function unwrapShell(cmd) {
|
|
76
|
+
const m = /^\S*\/(?:ba|z)?sh -l?c (['"])([\s\S]*)\1$/.exec(cmd.trim()) ?? /^\S*\/(?:ba|z)?sh -l?c (\S+)$/.exec(cmd.trim());
|
|
77
|
+
if (!m)
|
|
78
|
+
return cmd;
|
|
79
|
+
const inner = m.length === 3 ? m[2] : m[1];
|
|
80
|
+
return m[1] === "'" ? inner.replace(/'\\''/g, "'").replace(/'"'"'/g, "'") : inner.replace(/\\"/g, '"');
|
|
81
|
+
}
|
|
82
|
+
/** Shows paths relative to the session's folder when possible. */
|
|
83
|
+
export function relPath(path, cwd) {
|
|
84
|
+
if (cwd && path.startsWith(cwd.replace(/\/$/, '') + '/'))
|
|
85
|
+
return path.slice(cwd.replace(/\/$/, '').length + 1);
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
88
|
+
export const oneLine = (s, max = 160) => {
|
|
89
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
90
|
+
return t.length > max ? t.slice(0, max - 1) + '…' : t;
|
|
91
|
+
};
|
|
92
|
+
/** Tracks checks across a run so the end of the run can say how they went. */
|
|
93
|
+
export class CheckTracker {
|
|
94
|
+
last;
|
|
95
|
+
changed = new Set();
|
|
96
|
+
record(cmd, ok) {
|
|
97
|
+
this.last = { cmd, ok };
|
|
98
|
+
}
|
|
99
|
+
finish() {
|
|
100
|
+
const out = [];
|
|
101
|
+
if (this.changed.size) {
|
|
102
|
+
const files = [...this.changed];
|
|
103
|
+
out.push({ kind: 'fact', key: 'filesChanged', label: 'Files changed', value: `${files.slice(0, 8).join(', ')}${files.length > 8 ? ` and ${files.length - 8} more` : ''}`, stage: 'change' });
|
|
104
|
+
}
|
|
105
|
+
if (this.last && !this.last.ok) {
|
|
106
|
+
out.push({ kind: 'stage', stage: 'verify', status: 'unverified', note: `Checks ran, but the last one failed: ${oneLine(this.last.cmd, 80)}` });
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Hints for the fix prompt when an agent run fails. */
|
|
112
|
+
export function agentFixHints(state, allowHint) {
|
|
113
|
+
const hints = [];
|
|
114
|
+
const denied = state.facts.find((f) => f.key === 'denied');
|
|
115
|
+
if (denied)
|
|
116
|
+
hints.push(`The agent was denied permission for: ${denied.value}. ${allowHint}`);
|
|
117
|
+
hints.push('Review what the agent already changed with `git diff` before running it again.');
|
|
118
|
+
return hints;
|
|
119
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { GcloudParser } from './parser.js';
|
|
2
|
+
import { GCLOUD_NOTES, GCLOUD_STAGES } from './stages.js';
|
|
3
|
+
export const gcloud = {
|
|
4
|
+
id: 'gcloud',
|
|
5
|
+
title: 'Cloud Run deploy',
|
|
6
|
+
stages: GCLOUD_STAGES,
|
|
7
|
+
notes: GCLOUD_NOTES,
|
|
8
|
+
install: 'Install the Google Cloud CLI (https://cloud.google.com/sdk/docs/install), then run `gcloud auth login`.',
|
|
9
|
+
launch(userArgs) {
|
|
10
|
+
const args = [...userArgs];
|
|
11
|
+
// `unfold gcloud …` means `gcloud run deploy …`; accept the full form too.
|
|
12
|
+
if (args[0] === 'run' && args[1] === 'deploy')
|
|
13
|
+
args.splice(0, 2);
|
|
14
|
+
return { bin: process.env.UNFOLD_GCLOUD_BIN || 'gcloud', args: ['run', 'deploy', ...args] };
|
|
15
|
+
},
|
|
16
|
+
createParser: () => new GcloudParser(),
|
|
17
|
+
fixHints(state) {
|
|
18
|
+
const fact = (k) => state.facts.find((f) => f.key === k)?.value;
|
|
19
|
+
switch (state.failedStage) {
|
|
20
|
+
case 'prepare':
|
|
21
|
+
return [
|
|
22
|
+
'This failed before anything was built. Account, project, billing and disabled-API problems are fixed with gcloud commands, not code: `gcloud auth login`, `gcloud config set project <id>`, and `gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com`. If that is the cause, say so and stop.',
|
|
23
|
+
];
|
|
24
|
+
case 'build':
|
|
25
|
+
return [
|
|
26
|
+
`The container was built by Cloud Build, not locally.${fact('logsUrl') ? ` The full build log is at ${fact('logsUrl')}.` : ''}`,
|
|
27
|
+
`It was built with ${fact('buildType') ?? 'a Dockerfile or buildpacks'}. Reproduce it locally with \`docker build .\` (Dockerfile) or \`pack build\` (buildpacks) before and after the fix.`,
|
|
28
|
+
'For buildpacks, check that the project declares how to start (for example a `start` script in package.json, or a Procfile).',
|
|
29
|
+
];
|
|
30
|
+
case 'deploy':
|
|
31
|
+
return [
|
|
32
|
+
'The image built, but the new revision did not become healthy. The most common cause: the container must listen on 0.0.0.0 at the port in the PORT environment variable (8080 by default).',
|
|
33
|
+
`Check start-up errors with \`gcloud run services logs read ${fact('service') ?? '<service>'}${fact('region') ? ` --region ${fact('region')}` : ''}\`. Crashes on start, missing environment variables and memory limits show up there.`,
|
|
34
|
+
];
|
|
35
|
+
default:
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const START = /^(?:(Building using (\S+) and deploying)|Deploying(?: new)?)\s+(?:container|sources|locally-built source)?\s*to Cloud Run (service|job|worker pool) \[(.+?)\] in project \[(.+?)\](?: region \[(.+?)\])?/;
|
|
2
|
+
const STEP = /^(✓|OK|X|-|\.)?\s*(Creating Container Repository|Validating configuration|Uploading sources|Building Container|Creating Revision(?: in region: \S+)?|Routing traffic|Splitting instances|Setting IAM Policy|Enabling IAP)(?:\.\.\.)?\s*(.*)$/u;
|
|
3
|
+
const SERVED = /Service \[(.+?)\] revision \[(.+?)\] has been deployed and is serving (\d+) percent of traffic/;
|
|
4
|
+
const ERROR = /^ERROR: \((gcloud[\w.-]*)\) (.+)$/;
|
|
5
|
+
const PROMPT = /\((?:y\/N|Y\/n)\)\??\s*$|^Please (?:specify|enter)|^Service name|^Source code location|^Do you want to continue/;
|
|
6
|
+
const LOGS = /Logs are available at \[\s*(https?:\/\/\S+?)\s*\]/;
|
|
7
|
+
const CAUSE = /failed to start|listen on the port|PORT=\d+|HealthCheckContainerError|Container import failed|exited with|\berror\b|failed|denied|not found|quota/i;
|
|
8
|
+
const STEP_STAGE = {
|
|
9
|
+
'Creating Container Repository': 'prepare',
|
|
10
|
+
'Validating configuration': 'prepare',
|
|
11
|
+
'Uploading sources': 'build',
|
|
12
|
+
'Building Container': 'build',
|
|
13
|
+
'Creating Revision': 'deploy',
|
|
14
|
+
'Routing traffic': 'deploy',
|
|
15
|
+
'Splitting instances': 'deploy',
|
|
16
|
+
'Setting IAM Policy': 'deploy',
|
|
17
|
+
'Enabling IAP': 'deploy',
|
|
18
|
+
};
|
|
19
|
+
export class GcloudParser {
|
|
20
|
+
phase = 'start';
|
|
21
|
+
/** gcloud sometimes prints an HTML error page after "ERROR: … HTTPError". */
|
|
22
|
+
inHtmlError = false;
|
|
23
|
+
sawBuild = false;
|
|
24
|
+
prebuiltImage = false;
|
|
25
|
+
feed(line) {
|
|
26
|
+
const text = line.text.trim();
|
|
27
|
+
if (!text)
|
|
28
|
+
return [];
|
|
29
|
+
let m;
|
|
30
|
+
if ((m = START.exec(text))) {
|
|
31
|
+
this.phase = 'prepare';
|
|
32
|
+
this.prebuiltImage = !m[1] && /container to/.test(text);
|
|
33
|
+
const out = [
|
|
34
|
+
{ kind: 'fact', key: 'service', label: 'Service', value: m[4], stage: 'prepare' },
|
|
35
|
+
{ kind: 'fact', key: 'project', label: 'Project', value: m[5], stage: 'prepare' },
|
|
36
|
+
{ kind: 'stage', stage: 'prepare', status: 'confirmed', note: `Deploying ${m[3]} ${m[4]} in project ${m[5]}${m[6] ? `, region ${m[6]}` : ''}.` },
|
|
37
|
+
];
|
|
38
|
+
if (m[6])
|
|
39
|
+
out.push({ kind: 'fact', key: 'region', label: 'Region', value: m[6], stage: 'prepare' });
|
|
40
|
+
if (m[2])
|
|
41
|
+
out.push({ kind: 'fact', key: 'buildType', label: 'Build with', value: m[2], stage: 'build' });
|
|
42
|
+
if (this.prebuiltImage)
|
|
43
|
+
out.push({ kind: 'evidence', stage: 'build', note: 'Deploying a prebuilt container image, so there is nothing to build.' });
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
if (PROMPT.test(text))
|
|
47
|
+
return [{ kind: 'evidence', stage: 'prepare', note: 'gcloud asked a question in your terminal.' }];
|
|
48
|
+
if ((m = STEP.exec(text))) {
|
|
49
|
+
const label = m[2].replace(/ in region: .*/, '');
|
|
50
|
+
const stage = STEP_STAGE[label];
|
|
51
|
+
const mark = m[1];
|
|
52
|
+
const rest = m[3] ?? '';
|
|
53
|
+
const out = [];
|
|
54
|
+
const logs = LOGS.exec(rest);
|
|
55
|
+
if (logs)
|
|
56
|
+
out.push({ kind: 'fact', key: 'logsUrl', label: 'Build logs', value: logs[1], href: logs[1], stage: 'build' });
|
|
57
|
+
if (stage === 'build')
|
|
58
|
+
this.sawBuild = true;
|
|
59
|
+
if (stage !== 'prepare' && this.rank(stage) > this.rank(this.phase))
|
|
60
|
+
this.phase = stage;
|
|
61
|
+
if (mark === '✓' || mark === 'OK') {
|
|
62
|
+
// A finished build step only confirms the stage once the container is built;
|
|
63
|
+
// a finished revision step only once traffic is routed (or the IAM policy set).
|
|
64
|
+
if (label === 'Building Container' || label === 'Routing traffic' || label === 'Splitting instances')
|
|
65
|
+
out.push({ kind: 'stage', stage, status: 'confirmed', note: `${label} finished.` });
|
|
66
|
+
else
|
|
67
|
+
out.push({ kind: 'evidence', stage, note: `${label} finished.` });
|
|
68
|
+
}
|
|
69
|
+
else if (mark === 'X') {
|
|
70
|
+
out.push({ kind: 'stage', stage, status: 'failed', note: `${label} failed.${rest && !logs ? ` ${rest}` : ''}` });
|
|
71
|
+
if (rest && !logs)
|
|
72
|
+
out.push({ kind: 'error', severity: 'cause', stage, message: rest, priority: 2 });
|
|
73
|
+
}
|
|
74
|
+
else if (mark === '-') {
|
|
75
|
+
out.push({ kind: 'evidence', stage, note: `${label} was interrupted.` });
|
|
76
|
+
}
|
|
77
|
+
else if (mark !== '.') {
|
|
78
|
+
out.push({ kind: 'stage', stage, status: 'active', note: `${label}…` });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
if ((m = SERVED.exec(text))) {
|
|
83
|
+
this.phase = 'live';
|
|
84
|
+
const percent = Number(m[3]);
|
|
85
|
+
return [
|
|
86
|
+
{ kind: 'fact', key: 'revision', label: 'Revision', value: m[2], stage: 'deploy' },
|
|
87
|
+
{ kind: 'fact', key: 'traffic', label: 'Traffic', value: `${percent}%`, stage: 'live' },
|
|
88
|
+
{ kind: 'stage', stage: 'deploy', status: 'confirmed', note: `Revision ${m[2]} is ready.` },
|
|
89
|
+
percent > 0
|
|
90
|
+
? { kind: 'stage', stage: 'live', status: 'confirmed', note: `Serving ${percent} percent of traffic.` }
|
|
91
|
+
: { kind: 'stage', stage: 'live', status: 'unverified', note: 'The revision is deployed but receives no traffic (deployed with --no-traffic).' },
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
if ((m = /^For more troubleshooting guidance, see (https?:\/\/\S+)/.exec(text))) {
|
|
95
|
+
return [{ kind: 'fact', key: 'troubleshooting', label: 'Troubleshooting', value: m[1], href: m[1], stage: this.currentStage() }];
|
|
96
|
+
}
|
|
97
|
+
if ((m = /^Logs URL:\s*(https?:\/\/\S+)/.exec(text))) {
|
|
98
|
+
return [{ kind: 'fact', key: 'logsUrl', label: 'Logs', value: m[1], href: m[1], stage: this.currentStage() }];
|
|
99
|
+
}
|
|
100
|
+
if ((m = /^Service URL:\s*(https?:\/\/\S+)/.exec(text))) {
|
|
101
|
+
return [{ kind: 'fact', key: 'deploymentUrl', label: 'Service URL', value: m[1], href: m[1], stage: 'live' }];
|
|
102
|
+
}
|
|
103
|
+
if ((m = ERROR.exec(text))) {
|
|
104
|
+
const html = /<!DOCTYPE|<html/i.test(m[2]);
|
|
105
|
+
this.inHtmlError = html;
|
|
106
|
+
const message = html ? m[2].replace(/<!DOCTYPE[\s\S]*$|<html[\s\S]*$/i, '').replace(/:\s*$/, '').trim() : m[2];
|
|
107
|
+
return [{ kind: 'error', severity: 'fatal', stage: this.stageForError(message), message }];
|
|
108
|
+
}
|
|
109
|
+
if (this.inHtmlError) {
|
|
110
|
+
// Keep only the readable sentences of the error page, e.g. "The requested URL … was not found".
|
|
111
|
+
if (/<\/html>/i.test(text))
|
|
112
|
+
this.inHtmlError = false;
|
|
113
|
+
if (/<title>|<style|<meta/i.test(text))
|
|
114
|
+
return [];
|
|
115
|
+
const plain = text
|
|
116
|
+
.replace(/<[^>]*>/g, ' ')
|
|
117
|
+
.replace(/That[’']s (all we know|an error)\.?/gi, '')
|
|
118
|
+
.replace(/\s+/g, ' ')
|
|
119
|
+
.trim();
|
|
120
|
+
if (/[a-z]{3,} [a-z]{3,}/i.test(plain) && !/[{}]|^[\w-]+:/.test(plain)) {
|
|
121
|
+
return [{ kind: 'error', severity: 'cause', stage: this.currentStage(), message: plain, priority: 3 }];
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
if (/^(Deployment failed|Failed\.|Local build failed)/.test(text)) {
|
|
126
|
+
return [{ kind: 'evidence', stage: this.currentStage(), note: 'gcloud reported the deployment failed.' }];
|
|
127
|
+
}
|
|
128
|
+
// Revision start-up and build errors printed as plain lines.
|
|
129
|
+
if (this.phase !== 'start' && CAUSE.test(text) && !/^(Done|OK)\b/.test(text)) {
|
|
130
|
+
return [{ kind: 'error', severity: 'cause', stage: this.currentStage(), message: text, priority: /failed to start|listen on the port|PORT=/.test(text) ? 3 : 1 }];
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
rank(p) {
|
|
135
|
+
return ['start', 'prepare', 'build', 'deploy', 'live'].indexOf(p);
|
|
136
|
+
}
|
|
137
|
+
currentStage() {
|
|
138
|
+
return this.phase === 'start' ? 'prepare' : this.phase;
|
|
139
|
+
}
|
|
140
|
+
stageForError(message) {
|
|
141
|
+
if (/build/i.test(message) && !/revision/i.test(message))
|
|
142
|
+
return 'build';
|
|
143
|
+
if (/revision|container failed|failed to start|PORT|health ?check/i.test(message))
|
|
144
|
+
return 'deploy';
|
|
145
|
+
if (/permission|denied|not authenticated|auth|project|api .*(not|has not) been (used|enabled)|billing/i.test(message) && this.phase !== 'deploy')
|
|
146
|
+
return this.phase === 'build' ? 'build' : 'prepare';
|
|
147
|
+
return this.currentStage();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// General explanations of `gcloud run deploy`. Nothing here is evidence from a run.
|
|
2
|
+
export const GCLOUD_STAGES = [
|
|
3
|
+
{
|
|
4
|
+
id: 'prepare',
|
|
5
|
+
title: 'Prepare',
|
|
6
|
+
summary: 'gcloud works out which project, region and service to deploy to.',
|
|
7
|
+
explanation: [
|
|
8
|
+
'gcloud uses the account you logged in with (`gcloud auth login`) and your configured project and region. Anything missing, such as the service name or region, it asks for in the terminal.',
|
|
9
|
+
'With `--source`, it also decides how to build: from your Dockerfile if there is one, otherwise with Google Cloud buildpacks.',
|
|
10
|
+
],
|
|
11
|
+
confirmedBy: 'The "Deploying … to Cloud Run service [name] in project [id] region [region]" line, printed once the target is resolved.',
|
|
12
|
+
docsUrl: 'https://cloud.google.com/sdk/gcloud/reference/run/deploy',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'build',
|
|
16
|
+
title: 'Build',
|
|
17
|
+
summary: 'Your source is uploaded and built into a container image by Cloud Build.',
|
|
18
|
+
explanation: [
|
|
19
|
+
'For source deploys, gcloud uploads your code and Cloud Build turns it into a container image, stored in Artifact Registry. The build runs on Google Cloud, not on your machine; the full log is in the Cloud Build link.',
|
|
20
|
+
'If you deploy a prebuilt image with `--image`, there is nothing to build and this stage is skipped.',
|
|
21
|
+
],
|
|
22
|
+
confirmedBy: 'A "✓ Building Container..." step.',
|
|
23
|
+
docsUrl: 'https://cloud.google.com/run/docs/deploying-source-code',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'deploy',
|
|
27
|
+
title: 'Deploy',
|
|
28
|
+
summary: 'Cloud Run creates a new revision from the image and starts it.',
|
|
29
|
+
explanation: [
|
|
30
|
+
'Each deploy creates an immutable revision. Cloud Run starts a container from the image and waits for it to listen on the port in the PORT environment variable (8080 by default). A container that crashes or listens on the wrong port fails here.',
|
|
31
|
+
'Once the revision is healthy, traffic is routed to it, and with `--allow-unauthenticated` the public access policy is set.',
|
|
32
|
+
],
|
|
33
|
+
confirmedBy: 'A "✓ Creating Revision..." step, followed by "✓ Routing traffic...".',
|
|
34
|
+
docsUrl: 'https://cloud.google.com/run/docs/troubleshooting',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'live',
|
|
38
|
+
title: 'Live',
|
|
39
|
+
summary: 'The new revision serves traffic at the service URL.',
|
|
40
|
+
explanation: [
|
|
41
|
+
'When the deploy finishes, the new revision receives its share of traffic (normally 100 percent) at the service URL. Older revisions stay available for rollback.',
|
|
42
|
+
'Whether anyone on the internet can open the URL depends on the service’s authentication setting.',
|
|
43
|
+
],
|
|
44
|
+
confirmedBy: 'The "Service [name] revision [id] has been deployed and is serving … percent of traffic." message.',
|
|
45
|
+
docsUrl: 'https://cloud.google.com/run/docs/managing/revisions',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
export const GCLOUD_NOTES = {
|
|
49
|
+
success: 'Deployed',
|
|
50
|
+
successHeadline: 'Service is live',
|
|
51
|
+
docsLabel: 'Cloud Run docs',
|
|
52
|
+
highlights: ['revision', 'troubleshooting'],
|
|
53
|
+
interrupted: 'You pressed Ctrl+C while this stage was in progress. That stops gcloud, but Cloud Build or Cloud Run may finish the operation anyway. Check the Cloud Console.',
|
|
54
|
+
unverified: "This run's output never confirmed this stage, so Unfold does not claim it happened.",
|
|
55
|
+
};
|
package/dist/providers/index.js
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
+
import { claude } from './agents/claude.js';
|
|
2
|
+
import { codex } from './agents/codex.js';
|
|
3
|
+
import { gcloud } from './gcloud/index.js';
|
|
1
4
|
import { vercel } from './vercel/index.js';
|
|
2
|
-
export const providers = { vercel };
|
|
5
|
+
export const providers = { vercel, gcloud, claude, codex };
|
|
@@ -8,6 +8,7 @@ export const vercel = {
|
|
|
8
8
|
title: 'Vercel preview deploy',
|
|
9
9
|
stages: VERCEL_STAGES,
|
|
10
10
|
notes: VERCEL_NOTES,
|
|
11
|
+
install: 'Install it with `npm i -g vercel`, then run `vercel login`.',
|
|
11
12
|
launch(userArgs) {
|
|
12
13
|
const args = [...userArgs];
|
|
13
14
|
if (!SUBCOMMANDS.has(args[0] ?? ''))
|
|
@@ -46,6 +46,9 @@ export const VERCEL_STAGES = [
|
|
|
46
46
|
},
|
|
47
47
|
];
|
|
48
48
|
export const VERCEL_NOTES = {
|
|
49
|
+
success: 'Deployed',
|
|
50
|
+
successHeadline: 'Preview is ready',
|
|
51
|
+
docsLabel: 'Vercel docs',
|
|
49
52
|
interrupted: 'You pressed Ctrl+C while this stage was in progress. That stops the CLI, but if the deployment had already been created, Vercel may keep building it. Check the Inspect URL.',
|
|
50
53
|
unverified: "This run's output never confirmed this stage, so Unfold does not claim it happened.",
|
|
51
54
|
};
|
package/dist/tui/render.js
CHANGED
|
@@ -23,11 +23,11 @@ export function glyph(st, s, frame = 0) {
|
|
|
23
23
|
return s.dim('○');
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
const OUTCOME = { running: 'Running', success: '
|
|
26
|
+
const OUTCOME = { running: 'Running', success: 'Done', failure: 'Failed', interrupted: 'Stopped' };
|
|
27
27
|
export function outcomeLabel(state, s) {
|
|
28
28
|
if (state.outcome === 'success' && state.stages.at(-1)?.status !== 'confirmed')
|
|
29
29
|
return s.yellow('Exited 0, not confirmed');
|
|
30
|
-
const word = OUTCOME[state.outcome];
|
|
30
|
+
const word = state.outcome === 'success' ? (state.notes?.success ?? OUTCOME.success) : OUTCOME[state.outcome];
|
|
31
31
|
return state.outcome === 'success' ? s.green(word) : state.outcome === 'failure' ? s.red(word) : state.outcome === 'interrupted' ? s.yellow(word) : s.cyan(word);
|
|
32
32
|
}
|
|
33
33
|
export function elapsed(state, now = Date.now()) {
|
|
@@ -102,16 +102,23 @@ export function summary(state, logs, opts = {}) {
|
|
|
102
102
|
}
|
|
103
103
|
if (state.outcome === 'interrupted') {
|
|
104
104
|
line();
|
|
105
|
-
|
|
105
|
+
block(s.yellow('Stopped'), focusStage(state).note ?? 'Ctrl+C stopped the command before it finished.');
|
|
106
|
+
}
|
|
107
|
+
const highlights = (state.notes?.highlights ?? []).map((k) => state.facts.find((f) => f.key === k)).filter((f) => !!f);
|
|
108
|
+
if (highlights.length) {
|
|
109
|
+
line();
|
|
110
|
+
const kw = Math.max(...highlights.map((f) => f.label.length)) + 2;
|
|
111
|
+
for (const f of highlights)
|
|
112
|
+
wrapText(f.value, Math.max(20, cols - kw - 2)).forEach((l, n) => out.push(` ${n === 0 ? s.dim(f.label) + ' '.repeat(kw - f.label.length) : ' '.repeat(kw)}${l}`));
|
|
106
113
|
}
|
|
107
114
|
const url = state.facts.find((f) => f.key === 'alias') ?? state.facts.find((f) => f.key === 'deploymentUrl');
|
|
108
|
-
const inspect = state.facts.find((f) => f.key === 'inspectUrl');
|
|
115
|
+
const inspect = state.facts.find((f) => f.key === 'inspectUrl' || f.key === 'logsUrl');
|
|
109
116
|
if (url || inspect)
|
|
110
117
|
line();
|
|
111
118
|
if (url && state.outcome === 'success')
|
|
112
119
|
line(` ${s.dim('URL'.padEnd(8))}${s.cyan(url.value)}`);
|
|
113
120
|
if (inspect)
|
|
114
|
-
|
|
121
|
+
out.push(` ${s.dim((inspect.key === 'logsUrl' ? 'Logs' : 'Inspect').padEnd(8))}${inspect.value}`);
|
|
115
122
|
if (state.fix?.commands) {
|
|
116
123
|
line();
|
|
117
124
|
line(` ${s.bold('Fix it with an agent')} ${s.dim('(prompt built from this run)')}`);
|
|
@@ -121,9 +128,9 @@ export function summary(state, logs, opts = {}) {
|
|
|
121
128
|
}
|
|
122
129
|
// Output but nothing recognized: likely a CLI format Unfold doesn't know yet.
|
|
123
130
|
if (state.endedAt && state.lineCount > 3 && state.stages.every((x) => x.status === 'unverified' || x.status === 'pending')) {
|
|
124
|
-
const version = state.facts.find((f) => f.key === 'cliVersion')
|
|
131
|
+
const version = state.facts.find((f) => f.key === 'cliVersion');
|
|
125
132
|
line();
|
|
126
|
-
for (const l of wrapText(`Unfold didn't recognize any of this output${version ? ` (
|
|
133
|
+
for (const l of wrapText(`Unfold didn't recognize any of this output${version ? ` (${version.label} ${version.value})` : ''}, so no stage is confirmed. If this was a normal run, please report it at https://github.com/faizm10/unfold/issues.`, cols - 4))
|
|
127
134
|
out.push(` ${s.yellow(l)}`);
|
|
128
135
|
}
|
|
129
136
|
const unverified = state.stages.filter((x) => x.status === 'unverified').length;
|
package/dist/ui/app.js
CHANGED
|
@@ -88,7 +88,7 @@ function renderHeader(s) {
|
|
|
88
88
|
mode.className = `badge ${s.mode}`;
|
|
89
89
|
const status = $('status');
|
|
90
90
|
status.className = `pill ${s.outcome}`;
|
|
91
|
-
status.textContent = { running: 'Running', success: s.stages.at(-1)?.status === 'confirmed' ? '
|
|
91
|
+
status.textContent = { running: 'Running', success: s.stages.at(-1)?.status === 'confirmed' ? (s.notes?.success ?? 'Done') : 'Exited 0', failure: 'Failed', interrupted: 'Stopped' }[s.outcome];
|
|
92
92
|
$('saved').replaceChildren(...(s.savedTo ? ['Saved to ', h('code', {}, s.savedTo)] : s.mode === 'demo' ? ['Demo data: recorded output, nothing is sent anywhere.'] : []));
|
|
93
93
|
document.title = `${status.textContent} · Unfold`;
|
|
94
94
|
}
|
|
@@ -121,7 +121,7 @@ function extLink(href, label) {
|
|
|
121
121
|
function renderBanner(s) {
|
|
122
122
|
const el = $('banner');
|
|
123
123
|
const title = (id) => s.stages.find((st) => st.id === id)?.title ?? 'none';
|
|
124
|
-
const inspect = fact(s, 'inspectUrl');
|
|
124
|
+
const inspect = fact(s, 'inspectUrl') ?? fact(s, 'logsUrl');
|
|
125
125
|
if (s.outcome === 'running') {
|
|
126
126
|
el.hidden = true;
|
|
127
127
|
return;
|
|
@@ -131,15 +131,15 @@ function renderBanner(s) {
|
|
|
131
131
|
if (s.outcome === 'failure') {
|
|
132
132
|
const err = primaryError(s);
|
|
133
133
|
const cause = causes(s)[0];
|
|
134
|
-
fill(el, h('h2', {}, `
|
|
134
|
+
fill(el, h('h2', {}, `Failed during ${title(s.failedStage)}`), h('p', {}, `Last confirmed stage: ${title(s.lastConfirmed)}. Exit code ${s.exitCode ?? s.exitSignal ?? 'unknown'}.`), err ? h('div', { class: 'err' }, err.message) : h('p', {}, 'The command failed without printing a recognizable error. Check the logs.'), cause ? h('p', {}, 'Most relevant output: ', h('code', {}, cause.message)) : null, h('div', { class: 'actions' }, h('button', { class: 'btn primary', onclick: () => jumpTo((cause ?? err)?.seq) }, 'Jump to the error in the logs'), h('button', { class: 'btn', onclick: () => select(s.failedStage) }, `Open the ${title(s.failedStage)} stage`), inspect && inspect.href ? extLink(inspect.href, `Open ${inspect.label}`) : null), s.fix ? fixPanel(s) : null);
|
|
135
135
|
}
|
|
136
136
|
else if (s.outcome === 'success') {
|
|
137
137
|
const url = fact(s, 'alias') ?? fact(s, 'deploymentUrl');
|
|
138
138
|
const live = s.stages.at(-1)?.status === 'confirmed';
|
|
139
|
-
fill(el, h('h2', {}, live ?
|
|
139
|
+
fill(el, h('h2', {}, live ? (s.notes?.successHeadline ?? 'Finished') : 'Command finished, but its output never confirmed the last stage'), live ? null : h('p', {}, "The command exited with code 0, but its output never confirmed the deployment went live."), url?.href ? h('a', { class: 'live-url', href: url.href, target: '_blank', rel: 'noreferrer noopener' }, url.value) : h('p', {}, 'The command succeeded.'), fact(s, 'protection') ? h('p', {}, 'Deployment Protection is on, so visitors may need to sign in to Vercel.') : null, h('div', { class: 'actions' }, url?.value ? h('button', { class: 'btn', onclick: (e) => copy(url.value, e.currentTarget) }, 'Copy URL') : null, inspect?.href ? extLink(inspect.href, `Open ${inspect.label}`) : null));
|
|
140
140
|
}
|
|
141
141
|
else {
|
|
142
|
-
fill(el, h('h2', {}, 'Stopped with Ctrl+C'), h('p', {}, `Last confirmed stage: ${title(s.lastConfirmed)}. ${
|
|
142
|
+
fill(el, h('h2', {}, 'Stopped with Ctrl+C'), h('p', {}, `Last confirmed stage: ${title(s.lastConfirmed)}. ${s.stages.find((st) => st.status === 'unverified' && st.note)?.note ?? ''}`), h('div', { class: 'actions' }, inspect?.href ? extLink(inspect.href, `Open ${inspect.label}`) : null, h('button', { class: 'btn', onclick: () => openLogs(true) }, 'View logs')));
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
/** A ready-made prompt so a coding agent can pick up the failure. */
|
|
@@ -186,7 +186,7 @@ function renderDetail(s) {
|
|
|
186
186
|
: null, st.evidence.length
|
|
187
187
|
? h('ul', { class: 'evidence' }, ...st.evidence.map((e) => evidenceItem(e.seq, e.at, e.note, logs.get(e.seq)?.text ?? e.text)))
|
|
188
188
|
: h('p', { class: 'empty' }, 'No output from this run relates to this stage.'));
|
|
189
|
-
const explain = h('div', { class: 'card explain' }, h('div', { class: 'kicker' }, icon(ICONS.book), 'How this stage works · general, not from this run'), h('p', { class: 'summary' }, st.summary), ...st.explanation.map((p) => h('p', {}, p)), h('p', { class: 'confirm' }, h('b', {}, 'What would confirm it: '), st.confirmedBy), st.docsUrl ? h('a', { href: st.docsUrl, target: '_blank', rel: 'noreferrer noopener' },
|
|
189
|
+
const explain = h('div', { class: 'card explain' }, h('div', { class: 'kicker' }, icon(ICONS.book), 'How this stage works · general, not from this run'), h('p', { class: 'summary' }, st.summary), ...st.explanation.map((p) => h('p', {}, p)), h('p', { class: 'confirm' }, h('b', {}, 'What would confirm it: '), st.confirmedBy), st.docsUrl ? h('a', { href: st.docsUrl, target: '_blank', rel: 'noreferrer noopener' }, s.notes?.docsLabel ?? 'Docs') : null);
|
|
190
190
|
$('detail').setAttribute('aria-labelledby', `tab-${st.id}`);
|
|
191
191
|
$('detail').replaceChildren(observed, explain);
|
|
192
192
|
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unfold-deploy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Watch what happens after you hit Enter:
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Watch what happens after you hit Enter: live, explained flows for Vercel and Cloud Run deploys and Claude Code / Codex tasks, in your terminal.",
|
|
5
5
|
"keywords": [
|
|
6
|
-
"
|
|
7
|
-
"
|
|
6
|
+
"ai-agents",
|
|
7
|
+
"build-logs",
|
|
8
|
+
"claude-code",
|
|
8
9
|
"cli",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
10
|
+
"cloud-run",
|
|
11
|
+
"codex",
|
|
12
|
+
"deploy",
|
|
11
13
|
"devtools",
|
|
12
|
-
"
|
|
14
|
+
"gcloud",
|
|
15
|
+
"preview",
|
|
16
|
+
"terminal",
|
|
17
|
+
"vercel"
|
|
13
18
|
],
|
|
14
19
|
"homepage": "https://github.com/faizm10/unfold#readme",
|
|
15
20
|
"bugs": {
|