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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +150 -2
- package/bin/unfold.js +14 -0
- 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/demos/vercel-build-failure.json +331 -0
- package/demos/vercel-interrupted.json +271 -0
- package/demos/vercel-success.json +343 -0
- package/dist/cli.js +121 -0
- package/dist/commands/demo.js +75 -0
- package/dist/commands/observe.js +137 -0
- package/dist/commands/replay.js +76 -0
- package/dist/commands/run.js +24 -0
- package/dist/commands/show.js +44 -0
- package/dist/core/errors.js +3 -0
- package/dist/core/fix-prompt.js +85 -0
- package/dist/core/flow.js +211 -0
- package/dist/core/launch.js +196 -0
- package/dist/core/model.js +3 -0
- package/dist/core/redact.js +79 -0
- package/dist/core/server.js +109 -0
- package/dist/core/session.js +136 -0
- package/dist/core/store.js +52 -0
- package/dist/core/terminal.js +109 -0
- 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 +5 -0
- package/dist/providers/provider.js +1 -0
- package/dist/providers/vercel/index.js +56 -0
- package/dist/providers/vercel/parser.js +239 -0
- package/dist/providers/vercel/stages.js +54 -0
- package/dist/tui/ansi.js +71 -0
- package/dist/tui/explore.js +220 -0
- package/dist/tui/panel.js +118 -0
- package/dist/tui/render.js +140 -0
- package/dist/tui/restore.js +29 -0
- package/dist/ui/app.js +332 -0
- package/dist/ui/index.html +60 -0
- package/dist/ui/styles.css +220 -0
- package/package.json +64 -4
- package/scripts/postinstall.mjs +13 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { VercelParser } from './parser.js';
|
|
4
|
+
import { VERCEL_NOTES, VERCEL_STAGES } from './stages.js';
|
|
5
|
+
const SUBCOMMANDS = new Set(['deploy']);
|
|
6
|
+
export const vercel = {
|
|
7
|
+
id: 'vercel',
|
|
8
|
+
title: 'Vercel preview deploy',
|
|
9
|
+
stages: VERCEL_STAGES,
|
|
10
|
+
notes: VERCEL_NOTES,
|
|
11
|
+
install: 'Install it with `npm i -g vercel`, then run `vercel login`.',
|
|
12
|
+
launch(userArgs) {
|
|
13
|
+
const args = [...userArgs];
|
|
14
|
+
if (!SUBCOMMANDS.has(args[0] ?? ''))
|
|
15
|
+
args.unshift('deploy');
|
|
16
|
+
// Ask the CLI to stream build logs so the Build stage has real evidence.
|
|
17
|
+
if (!args.includes('--logs') && !args.includes('--no-wait') && !args.includes('--json'))
|
|
18
|
+
args.push('--logs');
|
|
19
|
+
return { bin: process.env.UNFOLD_VERCEL_BIN || 'vercel', args };
|
|
20
|
+
},
|
|
21
|
+
preflight(cwd) {
|
|
22
|
+
const msgs = [];
|
|
23
|
+
if (!existsSync(join(cwd, '.vercel', 'project.json')) && !existsSync(join(cwd, '.vercel', 'repo.json'))) {
|
|
24
|
+
msgs.push('This folder is not linked to a Vercel project yet; the Vercel CLI will ask you to link it in this terminal.');
|
|
25
|
+
}
|
|
26
|
+
return msgs;
|
|
27
|
+
},
|
|
28
|
+
createParser: () => new VercelParser(),
|
|
29
|
+
fixHints(state) {
|
|
30
|
+
const fact = (k) => state.facts.find((f) => f.key === k)?.value;
|
|
31
|
+
switch (state.failedStage) {
|
|
32
|
+
case 'prepare':
|
|
33
|
+
return [
|
|
34
|
+
'This failed before any files were sent to Vercel. Login, team scope and project-link problems are fixed with `vercel login` or `vercel link`, not code changes. If that is the cause, say so and stop.',
|
|
35
|
+
'Check `.vercel/project.json` and `vercel.json` (or `vercel.ts`) for invalid configuration.',
|
|
36
|
+
];
|
|
37
|
+
case 'upload':
|
|
38
|
+
return [
|
|
39
|
+
'This failed while sending files to Vercel. Too many or too large files usually means something should be in `.vercelignore` (build output, caches, large assets).',
|
|
40
|
+
'`vercel deploy --archive=tgz` uploads a single archive and avoids upload limits.',
|
|
41
|
+
];
|
|
42
|
+
case 'build':
|
|
43
|
+
return [
|
|
44
|
+
`The build ran on Vercel's machines, not locally. Reproduce it with \`${fact('buildCommand') ?? 'the build command'}\` (or \`vercel build\`) before and after the fix.`,
|
|
45
|
+
'Vercel builds use the environment variables set on the Vercel project for the Preview environment, not local `.env` files. A missing or different variable is a common reason a build passes locally and fails on Vercel; `vercel env ls` lists them.',
|
|
46
|
+
'Check the Node.js version (`engines.node` in package.json) and that the lockfile is committed and up to date.',
|
|
47
|
+
];
|
|
48
|
+
case 'preview':
|
|
49
|
+
return [
|
|
50
|
+
'The build finished but the deployment did not become Ready. This is often deployment checks, domain/alias assignment or project settings rather than application code.',
|
|
51
|
+
];
|
|
52
|
+
default:
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
const BUILD_LOG = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)(?:\s+(.*))?$/;
|
|
2
|
+
const INSPECT = /^(?:🔍\s*)?Inspect:?\s+(https?:\/\/\S+)/u;
|
|
3
|
+
const DEPLOY_URL = /^(?:[✅🔗▲]\s*)?(Preview|Production):?\s+(https?:\/\/\S+)/u;
|
|
4
|
+
const UPLOAD = /^Uploading\s+\[[^\]]*\]\s*\(([^)]+)\)/;
|
|
5
|
+
const READY = /^(?:✓\s*)?Ready\b(?:\s+in\s+(\S+))?/u;
|
|
6
|
+
const CLI_ERROR = /^(?:Error|❗️?\s*Error|Error!):?\s+(.+)$/u;
|
|
7
|
+
const COMMAND_EXITED = /Command "([^"]+)" exited with (\d+)/;
|
|
8
|
+
// Build output that is probably the real cause of a failure.
|
|
9
|
+
const CAUSE = /(\berror\b|\bERR!|Failed to compile|Type error|SyntaxError|ReferenceError|TypeError|Module not found|Cannot find module|Build failed|ELIFECYCLE|exited with)/i;
|
|
10
|
+
// Summary lines that say a build failed without saying why.
|
|
11
|
+
const GENERIC_CAUSE = /^(Failed to compile|Build failed|.*worker exited with|.*ELIFECYCLE|npm ERR! (code|errno|Lifecycle|A complete log))/i;
|
|
12
|
+
const NOT_CAUSE = /\b0 errors?\b|no errors|error[-_]boundary|errorHandler|\bwarn(ing)?\b/i;
|
|
13
|
+
export class VercelParser {
|
|
14
|
+
phase = 'start';
|
|
15
|
+
sawUpload = false;
|
|
16
|
+
buildStarted = false;
|
|
17
|
+
feed(line) {
|
|
18
|
+
const raw = line.text.trim();
|
|
19
|
+
const m = BUILD_LOG.exec(raw);
|
|
20
|
+
if (m)
|
|
21
|
+
return this.buildLine((m[2] ?? '').trim(), true);
|
|
22
|
+
// Older CLIs prefix spinner build messages with "Building: ".
|
|
23
|
+
const text = raw.replace(/^Building:\s+/, '');
|
|
24
|
+
const out = [];
|
|
25
|
+
if (this.phase === 'start' || this.phase === 'prepare') {
|
|
26
|
+
const v = /^Vercel CLI (\S+)/.exec(text);
|
|
27
|
+
if (v) {
|
|
28
|
+
this.phase = 'prepare';
|
|
29
|
+
return [
|
|
30
|
+
{ kind: 'fact', key: 'cliVersion', label: 'Vercel CLI', value: v[1], stage: 'prepare' },
|
|
31
|
+
{ kind: 'stage', stage: 'prepare', status: 'active', note: 'The Vercel CLI started.' },
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Interactive prompts (linking a project, confirmations).
|
|
36
|
+
if (/^\?\s+\S/.test(text)) {
|
|
37
|
+
return [{ kind: 'evidence', stage: 'prepare', note: 'The CLI asked a question in your terminal.' }];
|
|
38
|
+
}
|
|
39
|
+
const linked = /^(?:🔗\s*)?Linked to (\S+)/u.exec(text);
|
|
40
|
+
if (linked) {
|
|
41
|
+
return [
|
|
42
|
+
{ kind: 'fact', key: 'project', label: 'Project', value: linked[1].replace(/\s*\(.*$/, ''), stage: 'prepare' },
|
|
43
|
+
{ kind: 'evidence', stage: 'prepare', note: 'This folder was linked to a Vercel project.' },
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
if (/^(Retrieving project|Loading scopes|Searching for existing projects)/.test(text) && (this.phase === 'start' || this.phase === 'prepare')) {
|
|
47
|
+
this.phase = 'prepare';
|
|
48
|
+
return [{ kind: 'stage', stage: 'prepare', status: 'active', note: 'Resolving the project.' }];
|
|
49
|
+
}
|
|
50
|
+
const deploying = /^(Deploying|Setting up project)\s*(.*?)\s*$/.exec(text);
|
|
51
|
+
if (deploying && (this.phase === 'start' || this.phase === 'prepare')) {
|
|
52
|
+
this.phase = 'prepare';
|
|
53
|
+
if (deploying[2])
|
|
54
|
+
out.push({ kind: 'fact', key: 'project', label: 'Project', value: deploying[2], stage: 'prepare' });
|
|
55
|
+
out.push({ kind: 'stage', stage: 'prepare', status: 'confirmed', note: 'Login checked and project resolved; the deployment request is starting.' });
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
const up = UPLOAD.exec(text);
|
|
59
|
+
if (up) {
|
|
60
|
+
this.sawUpload = true;
|
|
61
|
+
this.phase = 'upload';
|
|
62
|
+
const [done, total] = up[1].split('/');
|
|
63
|
+
const complete = done && total && done.trim() === total.trim();
|
|
64
|
+
return [{ kind: 'stage', stage: 'upload', status: 'active', note: complete ? `Uploaded ${total.trim()} of new or changed files.` : `Uploading new or changed files: ${up[1]}.` }];
|
|
65
|
+
}
|
|
66
|
+
const inspect = INSPECT.exec(text);
|
|
67
|
+
if (inspect) {
|
|
68
|
+
this.phase = 'created';
|
|
69
|
+
out.push({ kind: 'fact', key: 'inspectUrl', label: 'Inspect', value: inspect[1], href: inspect[1], stage: 'upload' });
|
|
70
|
+
out.push({ kind: 'stage', stage: 'prepare', status: 'confirmed', note: 'Project resolved and files prepared.' });
|
|
71
|
+
if (this.sawUpload) {
|
|
72
|
+
out.push({ kind: 'stage', stage: 'upload', status: 'confirmed', note: 'Files uploaded and the deployment was created.' });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
out.push({
|
|
76
|
+
kind: 'evidence',
|
|
77
|
+
stage: 'upload',
|
|
78
|
+
note: 'The deployment was created, but no upload appeared in the output. Vercel may already have had every file, so the upload itself is not confirmed.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
const dep = DEPLOY_URL.exec(text);
|
|
84
|
+
if (dep) {
|
|
85
|
+
const label = dep[1] === 'Production' ? 'Production URL' : 'Preview URL';
|
|
86
|
+
out.push({ kind: 'fact', key: 'deploymentUrl', label, value: dep[2], href: dep[2], stage: 'preview' });
|
|
87
|
+
if (dep[1] === 'Production')
|
|
88
|
+
out.push({ kind: 'fact', key: 'target', label: 'Target', value: 'production', stage: 'preview' });
|
|
89
|
+
if (this.phase === 'built' || this.phase === 'ready' || (this.buildStarted && /\[\d+(\.\d+)?m?s\]$/.test(text))) {
|
|
90
|
+
out.push({ kind: 'stage', stage: 'preview', status: 'confirmed', note: 'The deployment is live at its URL.' });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
out.push({ kind: 'evidence', stage: 'preview', note: 'URL reserved. It will not serve your site until the deployment is Ready.' });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
const aliased = /^(?:▲\s*)?Aliased\s+(https?:\/\/\S+)/u.exec(text);
|
|
98
|
+
if (aliased) {
|
|
99
|
+
return [
|
|
100
|
+
{ kind: 'fact', key: 'alias', label: 'Aliased', value: aliased[1], href: aliased[1], stage: 'preview' },
|
|
101
|
+
{ kind: 'evidence', stage: 'preview', note: 'A domain was pointed at this deployment.' },
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
if (/^Queued/.test(text)) {
|
|
105
|
+
this.phase = 'build';
|
|
106
|
+
return [{ kind: 'stage', stage: 'build', status: 'active', note: 'Waiting in the queue for a build machine.' }];
|
|
107
|
+
}
|
|
108
|
+
if (/^Building…?\.{0,3}$/.test(text)) {
|
|
109
|
+
this.phase = 'build';
|
|
110
|
+
this.buildStarted = true;
|
|
111
|
+
return [{ kind: 'stage', stage: 'build', status: 'active', note: 'Building on Vercel.' }];
|
|
112
|
+
}
|
|
113
|
+
if (/^(Running Checks|Completing)/.test(text)) {
|
|
114
|
+
this.phase = 'built';
|
|
115
|
+
return [
|
|
116
|
+
{ kind: 'stage', stage: 'build', status: 'confirmed', note: 'Vercel reported the build finished.' },
|
|
117
|
+
{ kind: 'stage', stage: 'preview', status: 'active', note: /^Running Checks/.test(text) ? 'Waiting for deployment checks.' : 'Finishing the deployment.' },
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
const ready = READY.exec(text);
|
|
121
|
+
if (ready && this.phase !== 'start' && this.phase !== 'prepare') {
|
|
122
|
+
this.phase = 'ready';
|
|
123
|
+
if (ready[1])
|
|
124
|
+
out.push({ kind: 'fact', key: 'duration', label: 'Total time', value: ready[1], stage: 'preview' });
|
|
125
|
+
out.push({ kind: 'stage', stage: 'build', status: 'confirmed', note: 'The build finished.' });
|
|
126
|
+
out.push({ kind: 'stage', stage: 'preview', status: 'confirmed', note: 'Vercel reports the deployment is Ready.' });
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
if (/Deployment Protection is on/.test(text)) {
|
|
130
|
+
return [
|
|
131
|
+
{ kind: 'fact', key: 'protection', label: 'Deployment Protection', value: 'On', stage: 'preview' },
|
|
132
|
+
{ kind: 'evidence', stage: 'preview', note: 'Visitors may need to sign in to Vercel to view this URL.' },
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
const err = CLI_ERROR.exec(text);
|
|
136
|
+
if (err) {
|
|
137
|
+
const stage = this.stageForError();
|
|
138
|
+
const exited = COMMAND_EXITED.exec(text);
|
|
139
|
+
if (exited)
|
|
140
|
+
out.push({ kind: 'fact', key: 'failedCommand', label: 'Failed command', value: `${exited[1]} (exit ${exited[2]})`, stage: 'build' });
|
|
141
|
+
out.push({ kind: 'error', severity: 'fatal', stage, message: err[1] });
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
// Non-interactive mode reports errors as a JSON object on one line.
|
|
145
|
+
if (text.startsWith('{') && /"status"\s*:\s*"error"/.test(text)) {
|
|
146
|
+
let message = text;
|
|
147
|
+
try {
|
|
148
|
+
const j = JSON.parse(text);
|
|
149
|
+
message = [j.message ?? j.reason, j.hint].filter(Boolean).join(' ') || text;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// keep raw text
|
|
153
|
+
}
|
|
154
|
+
return [{ kind: 'error', severity: 'fatal', stage: this.stageForError(), message }];
|
|
155
|
+
}
|
|
156
|
+
if (/^Uploading failed|too_many_files|missing_files/.test(text)) {
|
|
157
|
+
return [{ kind: 'error', severity: 'fatal', stage: 'upload', message: text }];
|
|
158
|
+
}
|
|
159
|
+
// After the deployment exists, unprefixed spinner lines are the latest build log line.
|
|
160
|
+
if (this.phase === 'created' || this.phase === 'build')
|
|
161
|
+
return this.buildLine(text, false);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
stageForError() {
|
|
165
|
+
switch (this.phase) {
|
|
166
|
+
case 'start':
|
|
167
|
+
case 'prepare':
|
|
168
|
+
return 'prepare';
|
|
169
|
+
case 'upload':
|
|
170
|
+
return 'upload';
|
|
171
|
+
case 'created':
|
|
172
|
+
case 'build':
|
|
173
|
+
return 'build';
|
|
174
|
+
default:
|
|
175
|
+
return 'preview';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
buildLine(text, timestamped) {
|
|
179
|
+
if (text === '')
|
|
180
|
+
return [];
|
|
181
|
+
const out = [];
|
|
182
|
+
if (this.phase !== 'built' && this.phase !== 'ready') {
|
|
183
|
+
if (!this.buildStarted)
|
|
184
|
+
out.push({ kind: 'stage', stage: 'build', status: 'active', note: timestamped ? 'Build logs are streaming from Vercel.' : 'Building on Vercel.' });
|
|
185
|
+
this.buildStarted = true;
|
|
186
|
+
this.phase = 'build';
|
|
187
|
+
}
|
|
188
|
+
let r;
|
|
189
|
+
if ((r = /Running build in (.+)$/.exec(text))) {
|
|
190
|
+
out.push({ kind: 'fact', key: 'region', label: 'Build region', value: r[1], stage: 'build' });
|
|
191
|
+
out.push({ kind: 'evidence', stage: 'build', note: 'A build machine picked up the deployment.' });
|
|
192
|
+
}
|
|
193
|
+
else if ((r = /Build machine configuration: (.+)$/.exec(text))) {
|
|
194
|
+
out.push({ kind: 'fact', key: 'machine', label: 'Build machine', value: r[1], stage: 'build' });
|
|
195
|
+
}
|
|
196
|
+
else if (/(Downloading \d+ deployment files|Retrieving list of deployment files|Cloning )/.test(text)) {
|
|
197
|
+
out.push({ kind: 'evidence', stage: 'build', note: 'The build machine fetched your files.' });
|
|
198
|
+
}
|
|
199
|
+
else if (/Restored build cache|Build cache/.test(text) && !/Creat|Upload/.test(text)) {
|
|
200
|
+
out.push({ kind: 'evidence', stage: 'build', note: 'A previous build cache was restored.' });
|
|
201
|
+
}
|
|
202
|
+
else if ((r = /^Running "(?!vercel build)(.+)"$/.exec(text))) {
|
|
203
|
+
out.push({ kind: 'fact', key: 'buildCommand', label: 'Build command', value: r[1], stage: 'build' });
|
|
204
|
+
out.push({ kind: 'evidence', stage: 'build', note: 'Your build command started.' });
|
|
205
|
+
}
|
|
206
|
+
else if (/(Installing dependencies|added \d+ packages|up to date, audited|Lockfile|packages are looking for funding)/.test(text) && /Installing|added/.test(text)) {
|
|
207
|
+
out.push({ kind: 'evidence', stage: 'build', note: 'Dependencies were installed.' });
|
|
208
|
+
}
|
|
209
|
+
else if ((r = /Detected (.+?) version:? (\S+)/.exec(text))) {
|
|
210
|
+
out.push({ kind: 'fact', key: 'framework', label: 'Framework', value: `${r[1]} ${r[2]}`, stage: 'build' });
|
|
211
|
+
}
|
|
212
|
+
else if ((r = /Build Completed in (\S+)(?:\s*\[(.+)\])?/.exec(text))) {
|
|
213
|
+
this.phase = 'built';
|
|
214
|
+
out.push({ kind: 'stage', stage: 'build', status: 'confirmed', note: r[2] ? `Build completed in ${r[2]}.` : 'Build completed.' });
|
|
215
|
+
}
|
|
216
|
+
else if (/Deploying outputs/.test(text)) {
|
|
217
|
+
if (this.phase !== 'ready')
|
|
218
|
+
this.phase = 'built';
|
|
219
|
+
out.push({ kind: 'stage', stage: 'preview', status: 'active', note: 'Build output is being deployed to Vercel’s network.' });
|
|
220
|
+
}
|
|
221
|
+
else if (/^Deployment completed/.test(text)) {
|
|
222
|
+
out.push({ kind: 'evidence', stage: 'preview', note: 'Build output was deployed.' });
|
|
223
|
+
}
|
|
224
|
+
const exited = COMMAND_EXITED.exec(text);
|
|
225
|
+
if (exited) {
|
|
226
|
+
out.push({ kind: 'fact', key: 'failedCommand', label: 'Failed command', value: `${exited[1]} (exit ${exited[2]})`, stage: 'build' });
|
|
227
|
+
out.push({ kind: 'error', severity: 'cause', stage: 'build', message: text.replace(/^Error:\s*/, ''), priority: 0 });
|
|
228
|
+
}
|
|
229
|
+
else if (CAUSE.test(text) && !NOT_CAUSE.test(text)) {
|
|
230
|
+
out.push({ kind: 'error', severity: 'cause', stage: 'build', message: text.replace(/^Error:\s*/, ''), priority: GENERIC_CAUSE.test(text) ? 1 : 2 });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Test helper: run lines through a fresh parser. */
|
|
236
|
+
export function parseLines(lines) {
|
|
237
|
+
const p = new VercelParser();
|
|
238
|
+
return lines.map((text, i) => p.feed({ seq: i + 1, at: i, text }));
|
|
239
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// General explanations. Nothing here is evidence from a run.
|
|
2
|
+
export const VERCEL_STAGES = [
|
|
3
|
+
{
|
|
4
|
+
id: 'prepare',
|
|
5
|
+
title: 'Prepare',
|
|
6
|
+
summary: 'The CLI works out who you are, which project this folder belongs to, and which files to send.',
|
|
7
|
+
explanation: [
|
|
8
|
+
'The Vercel CLI uses the login saved on your machine and the link in .vercel/project.json. If the folder is not linked yet, it asks you a few questions in the terminal and creates that link.',
|
|
9
|
+
'It then lists the files to deploy (skipping anything in .vercelignore) and computes a fingerprint (SHA) of each one. Those fingerprints let Vercel skip files it already has.',
|
|
10
|
+
],
|
|
11
|
+
confirmedBy: 'A "Deploying <team>/<project>" line, which the CLI prints only after your login has been checked and the project resolved.',
|
|
12
|
+
docsUrl: 'https://vercel.com/docs/cli/deploy',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'upload',
|
|
16
|
+
title: 'Upload',
|
|
17
|
+
summary: 'Files Vercel does not already have are sent to Vercel, then a deployment is created.',
|
|
18
|
+
explanation: [
|
|
19
|
+
'Only files whose fingerprint Vercel has not seen before are uploaded, so a small change usually means a small upload. If nothing changed, there may be no visible upload at all.',
|
|
20
|
+
'Once Vercel has every file, the CLI creates the deployment and gets back an Inspect URL (the deployment page in the dashboard) and the URL the preview will live at.',
|
|
21
|
+
],
|
|
22
|
+
confirmedBy: 'An "Uploading [...]" progress line followed by the Inspect URL, which only appears once the deployment has been created.',
|
|
23
|
+
docsUrl: 'https://vercel.com/docs/deployments/overview',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'build',
|
|
27
|
+
title: 'Build',
|
|
28
|
+
summary: 'Vercel builds your project on its own machines, not on your laptop.',
|
|
29
|
+
explanation: [
|
|
30
|
+
'The deployment waits in a queue for a build machine. That machine downloads your files, restores the build cache if there is one, installs dependencies and runs your build command (for example npm run build).',
|
|
31
|
+
'The build uses the environment variables configured in the Vercel project for the Preview environment, not the .env files on your machine. This is a common reason a build passes locally but fails here.',
|
|
32
|
+
],
|
|
33
|
+
confirmedBy: '"Build Completed" in the build logs, or the CLI reporting the deployment Ready.',
|
|
34
|
+
docsUrl: 'https://vercel.com/docs/deployments/builds',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'preview',
|
|
38
|
+
title: 'Preview',
|
|
39
|
+
summary: 'The build output is put behind its own unique URL that you can open and share.',
|
|
40
|
+
explanation: [
|
|
41
|
+
'Each preview deployment gets an immutable URL. The CLI prints that URL early, but it only serves your site once the deployment is Ready.',
|
|
42
|
+
'Preview deployments never change your production domain. Many teams keep Deployment Protection on, so visitors may need to sign in to Vercel to see it.',
|
|
43
|
+
],
|
|
44
|
+
confirmedBy: 'The CLI printing "Ready", meaning Vercel reports the deployment as live at its URL.',
|
|
45
|
+
docsUrl: 'https://vercel.com/docs/deployments/environments#preview-environment-pre-production',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
export const VERCEL_NOTES = {
|
|
49
|
+
success: 'Deployed',
|
|
50
|
+
successHeadline: 'Preview is ready',
|
|
51
|
+
docsLabel: 'Vercel docs',
|
|
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.',
|
|
53
|
+
unverified: "This run's output never confirmed this stage, so Unfold does not claim it happened.",
|
|
54
|
+
};
|
package/dist/tui/ansi.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Minimal ANSI styling and text layout for the terminal views.
|
|
2
|
+
const useColor = () => !process.env.NO_COLOR && (process.env.FORCE_COLOR === '1' || !!process.stdout.isTTY);
|
|
3
|
+
const wrap = (open, close) => (s) => `\x1b[${open}m${s}\x1b[${close}m`;
|
|
4
|
+
const plain = (s) => s;
|
|
5
|
+
export function style(color = useColor()) {
|
|
6
|
+
return color
|
|
7
|
+
? { bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), cyan: wrap(36, 39), inverse: wrap(7, 27) }
|
|
8
|
+
: { bold: plain, dim: plain, red: plain, green: plain, yellow: plain, cyan: plain, inverse: plain };
|
|
9
|
+
}
|
|
10
|
+
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
|
|
11
|
+
export const stripAnsi = (s) => s.replace(ANSI, '');
|
|
12
|
+
/** Visible width in terminal columns (good enough for the text Unfold prints). */
|
|
13
|
+
export function width(s) {
|
|
14
|
+
let w = 0;
|
|
15
|
+
for (const ch of stripAnsi(s)) {
|
|
16
|
+
const cp = ch.codePointAt(0);
|
|
17
|
+
if (cp < 0x20 || (cp >= 0x300 && cp <= 0x36f) || cp === 0x200b)
|
|
18
|
+
continue;
|
|
19
|
+
// Emoji and CJK take two columns.
|
|
20
|
+
w += cp >= 0x1f300 || (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xff00 && cp <= 0xff60) ? 2 : 1;
|
|
21
|
+
}
|
|
22
|
+
return w;
|
|
23
|
+
}
|
|
24
|
+
/** Cuts a (possibly styled) string to `max` visible columns, adding an ellipsis. */
|
|
25
|
+
export function truncate(s, max) {
|
|
26
|
+
if (max <= 0)
|
|
27
|
+
return '';
|
|
28
|
+
if (width(s) <= max)
|
|
29
|
+
return s;
|
|
30
|
+
let out = '';
|
|
31
|
+
let w = 0;
|
|
32
|
+
let i = 0;
|
|
33
|
+
while (i < s.length) {
|
|
34
|
+
ANSI.lastIndex = i;
|
|
35
|
+
const m = s.startsWith('\x1b', i) ? ANSI.exec(s) : null;
|
|
36
|
+
if (m && m.index === i) {
|
|
37
|
+
out += m[0];
|
|
38
|
+
i += m[0].length;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const ch = String.fromCodePoint(s.codePointAt(i));
|
|
42
|
+
const cw = width(ch);
|
|
43
|
+
if (w + cw > max - 1)
|
|
44
|
+
break;
|
|
45
|
+
out += ch;
|
|
46
|
+
w += cw;
|
|
47
|
+
i += ch.length;
|
|
48
|
+
}
|
|
49
|
+
return out + '…' + (/\x1b\[/.test(out) ? '\x1b[0m' : '');
|
|
50
|
+
}
|
|
51
|
+
export const padEnd = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)));
|
|
52
|
+
/** Word-wraps plain text to `max` columns. */
|
|
53
|
+
export function wrapText(text, max) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
for (const para of text.split('\n')) {
|
|
56
|
+
let line = '';
|
|
57
|
+
for (const word of para.split(/\s+/).filter(Boolean)) {
|
|
58
|
+
if (line && width(line) + 1 + width(word) > max) {
|
|
59
|
+
lines.push(line);
|
|
60
|
+
line = '';
|
|
61
|
+
}
|
|
62
|
+
line = line ? `${line} ${word}` : word;
|
|
63
|
+
while (width(line) > max) {
|
|
64
|
+
lines.push(line.slice(0, max));
|
|
65
|
+
line = line.slice(max);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
lines.push(line);
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|