inflight-cli 2.4.0 → 2.6.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/dist/commands/preview.js +2 -0
- package/dist/commands/share.js +36 -16
- package/dist/lib/api.d.ts +2 -1
- package/dist/lib/api.js +5 -2
- package/dist/lib/scrollable-select.d.ts +13 -0
- package/dist/lib/scrollable-select.js +98 -0
- package/dist/providers/netlify.js +111 -8
- package/dist/providers/vercel.js +118 -12
- package/package.json +3 -2
package/dist/commands/preview.js
CHANGED
|
@@ -115,6 +115,8 @@ export async function previewCommand(opts) {
|
|
|
115
115
|
message: "How many commits?",
|
|
116
116
|
placeholder: "1",
|
|
117
117
|
validate: (v) => {
|
|
118
|
+
if (!v)
|
|
119
|
+
return "Enter a positive number";
|
|
118
120
|
const n = parseInt(v, 10);
|
|
119
121
|
if (isNaN(n) || n < 1)
|
|
120
122
|
return "Enter a positive number";
|
package/dist/commands/share.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getGitInfo, getGitSyncState, generateCommitMessage, commitAndPush, push
|
|
|
5
5
|
import open from "open";
|
|
6
6
|
import { providers } from "../providers/index.js";
|
|
7
7
|
import { apiGetMe, apiCreateVersion, apiGetRecentProjects } from "../lib/api.js";
|
|
8
|
+
import { scrollableSelect } from "../lib/scrollable-select.js";
|
|
8
9
|
function formatRelativeTime(timestampMs) {
|
|
9
10
|
const seconds = Math.floor((Date.now() - timestampMs) / 1000);
|
|
10
11
|
if (seconds < 60)
|
|
@@ -22,10 +23,10 @@ function formatRelativeTime(timestampMs) {
|
|
|
22
23
|
return `${days}d ago`;
|
|
23
24
|
return new Date(timestampMs).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
24
25
|
}
|
|
25
|
-
function
|
|
26
|
+
function isValidHostedUrl(url) {
|
|
26
27
|
try {
|
|
27
28
|
const hostname = new URL(url.startsWith("http") ? url : `https://${url}`).hostname;
|
|
28
|
-
return hostname
|
|
29
|
+
return hostname.includes(".");
|
|
29
30
|
}
|
|
30
31
|
catch {
|
|
31
32
|
return false;
|
|
@@ -95,7 +96,7 @@ async function checkAndSyncGit(cwd) {
|
|
|
95
96
|
message: "Commit message",
|
|
96
97
|
initialValue: defaultMessage,
|
|
97
98
|
validate: (v) => {
|
|
98
|
-
if (!v.trim())
|
|
99
|
+
if (!v || !v.trim())
|
|
99
100
|
return "Commit message is required";
|
|
100
101
|
},
|
|
101
102
|
});
|
|
@@ -259,10 +260,12 @@ export async function shareCommand(opts = {}) {
|
|
|
259
260
|
if (!stagingUrl.startsWith("http")) {
|
|
260
261
|
stagingUrl = `https://${stagingUrl}`;
|
|
261
262
|
}
|
|
262
|
-
if (
|
|
263
|
-
const message = "
|
|
263
|
+
if (!isValidHostedUrl(stagingUrl)) {
|
|
264
|
+
const message = stagingUrl.includes("localhost")
|
|
265
|
+
? "Inflight needs a hosted URL — localhost isn't accessible to your team. Deploy to Vercel, Netlify, or another hosting provider first."
|
|
266
|
+
: "Must be a hosted URL with a domain (e.g., my-branch.vercel.app)";
|
|
264
267
|
if (opts.json) {
|
|
265
|
-
console.log(JSON.stringify({ error: "
|
|
268
|
+
console.log(JSON.stringify({ error: "invalid_url", message }));
|
|
266
269
|
}
|
|
267
270
|
else {
|
|
268
271
|
p.log.error(message);
|
|
@@ -326,10 +329,12 @@ export async function shareCommand(opts = {}) {
|
|
|
326
329
|
new URL(v.startsWith("http") ? v : `https://${v}`);
|
|
327
330
|
}
|
|
328
331
|
catch {
|
|
329
|
-
return "Must be a valid URL";
|
|
332
|
+
return "Must be a valid URL (e.g., my-branch.vercel.app)";
|
|
330
333
|
}
|
|
331
|
-
if (
|
|
332
|
-
return "
|
|
334
|
+
if (!isValidHostedUrl(v)) {
|
|
335
|
+
return v.includes("localhost")
|
|
336
|
+
? "Inflight needs a hosted URL — localhost isn't accessible to your team. Deploy to Vercel, Netlify, or another hosting provider first."
|
|
337
|
+
: "Must be a hosted URL with a domain (e.g., my-branch.vercel.app)";
|
|
333
338
|
}
|
|
334
339
|
},
|
|
335
340
|
});
|
|
@@ -345,18 +350,33 @@ export async function shareCommand(opts = {}) {
|
|
|
345
350
|
// ── Step 4: Project selection ──
|
|
346
351
|
let selectedProjectId;
|
|
347
352
|
let overrideVersionId;
|
|
348
|
-
const { projects
|
|
353
|
+
const { projects } = await apiGetRecentProjects(auth.apiKey, workspaceId, 20).catch(() => ({
|
|
349
354
|
projects: [],
|
|
350
355
|
}));
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
356
|
+
if (projects.length > 0) {
|
|
357
|
+
// Pre-select project whose latest version matches the current branch
|
|
358
|
+
const currentBranch = gitInfo.branch;
|
|
359
|
+
const branchMatch = currentBranch
|
|
360
|
+
? projects.find((proj) => proj.latestVersion.branch === currentBranch)
|
|
361
|
+
: undefined;
|
|
362
|
+
// Move branch-matched project to the top of the list
|
|
363
|
+
const sortedProjects = branchMatch
|
|
364
|
+
? [branchMatch, ...projects.filter((proj) => proj.projectId !== branchMatch.projectId)]
|
|
365
|
+
: projects;
|
|
366
|
+
const projectChoice = await scrollableSelect({
|
|
367
|
+
message: "Create a new project in Inflight, or update an existing one?",
|
|
368
|
+
maxItems: 5,
|
|
354
369
|
options: [
|
|
355
370
|
{ value: "new", label: "Start a new project" },
|
|
356
|
-
...
|
|
371
|
+
...sortedProjects.map((proj) => ({
|
|
357
372
|
value: proj.projectId,
|
|
358
373
|
label: `"${proj.latestVersion.title}"`,
|
|
359
|
-
hint:
|
|
374
|
+
hint: [
|
|
375
|
+
formatRelativeTime(proj.latestVersion.createdAt),
|
|
376
|
+
proj.latestVersion.branch === currentBranch ? `← current branch (${currentBranch})` : "",
|
|
377
|
+
]
|
|
378
|
+
.filter(Boolean)
|
|
379
|
+
.join(" · "),
|
|
360
380
|
})),
|
|
361
381
|
],
|
|
362
382
|
});
|
|
@@ -367,7 +387,7 @@ export async function shareCommand(opts = {}) {
|
|
|
367
387
|
if (projectChoice !== "new") {
|
|
368
388
|
selectedProjectId = projectChoice;
|
|
369
389
|
// Check for override opportunity — only when latest version has no feedback
|
|
370
|
-
const selectedProject =
|
|
390
|
+
const selectedProject = projects.find((proj) => proj.projectId === selectedProjectId);
|
|
371
391
|
if (selectedProject && selectedProject.latestVersion.commentCount === 0) {
|
|
372
392
|
const overrideChoice = await p.select({
|
|
373
393
|
message: `"${selectedProject.latestVersion.title}" has no feedback yet.`,
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface RecentProject {
|
|
|
17
17
|
stagingUrl: string | null;
|
|
18
18
|
commentCount: number;
|
|
19
19
|
createdAt: number;
|
|
20
|
+
branch: string | null;
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
export declare function apiGetMe(apiKey: string): Promise<{
|
|
@@ -24,7 +25,7 @@ export declare function apiGetMe(apiKey: string): Promise<{
|
|
|
24
25
|
email: string | null;
|
|
25
26
|
workspaces: Workspace[];
|
|
26
27
|
}>;
|
|
27
|
-
export declare function apiGetRecentProjects(apiKey: string, workspaceId: string): Promise<{
|
|
28
|
+
export declare function apiGetRecentProjects(apiKey: string, workspaceId: string, limit?: number): Promise<{
|
|
28
29
|
projects: RecentProject[];
|
|
29
30
|
}>;
|
|
30
31
|
export declare function apiCreateVersion(opts: {
|
package/dist/lib/api.js
CHANGED
|
@@ -7,8 +7,11 @@ export async function apiGetMe(apiKey) {
|
|
|
7
7
|
throw new Error("Invalid API key");
|
|
8
8
|
return res.json();
|
|
9
9
|
}
|
|
10
|
-
export async function apiGetRecentProjects(apiKey, workspaceId) {
|
|
11
|
-
const
|
|
10
|
+
export async function apiGetRecentProjects(apiKey, workspaceId, limit) {
|
|
11
|
+
const params = new URLSearchParams({ workspaceId });
|
|
12
|
+
if (limit)
|
|
13
|
+
params.set("limit", String(limit));
|
|
14
|
+
const res = await fetch(`${API_URL}/api/cli/projects/recent?${params.toString()}`, {
|
|
12
15
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
13
16
|
});
|
|
14
17
|
if (!res.ok) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface Option<T> {
|
|
2
|
+
value: T;
|
|
3
|
+
label?: string;
|
|
4
|
+
hint?: string;
|
|
5
|
+
}
|
|
6
|
+
interface ScrollableSelectOptions<T> {
|
|
7
|
+
message: string;
|
|
8
|
+
options: Option<T>[];
|
|
9
|
+
maxItems?: number;
|
|
10
|
+
initialValue?: T;
|
|
11
|
+
}
|
|
12
|
+
export declare function scrollableSelect<T extends string>(opts: ScrollableSelectOptions<T>): Promise<T | symbol>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Prompt } from "@clack/core";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
const S_STEP_ACTIVE = "◆";
|
|
4
|
+
const S_STEP_CANCEL = "■";
|
|
5
|
+
const S_STEP_SUBMIT = "◇";
|
|
6
|
+
const S_BAR = "│";
|
|
7
|
+
const S_BAR_END = "└";
|
|
8
|
+
const S_RADIO_ACTIVE = "●";
|
|
9
|
+
const S_RADIO_INACTIVE = "○";
|
|
10
|
+
function symbol(state) {
|
|
11
|
+
switch (state) {
|
|
12
|
+
case "initial":
|
|
13
|
+
case "active":
|
|
14
|
+
return pc.cyan(S_STEP_ACTIVE);
|
|
15
|
+
case "cancel":
|
|
16
|
+
return pc.red(S_STEP_CANCEL);
|
|
17
|
+
case "submit":
|
|
18
|
+
return pc.green(S_STEP_SUBMIT);
|
|
19
|
+
default:
|
|
20
|
+
return pc.cyan(S_STEP_ACTIVE);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function scrollableSelect(opts) {
|
|
24
|
+
const options = opts.options;
|
|
25
|
+
const max = Math.min(opts.maxItems ?? 5, Math.max(process.stdout.rows - 4, 5));
|
|
26
|
+
let cursor = 0;
|
|
27
|
+
if (opts.initialValue !== undefined) {
|
|
28
|
+
const idx = options.findIndex((o) => o.value === opts.initialValue);
|
|
29
|
+
if (idx !== -1)
|
|
30
|
+
cursor = idx;
|
|
31
|
+
}
|
|
32
|
+
function styleOption(option, active) {
|
|
33
|
+
const label = option.label ?? String(option.value);
|
|
34
|
+
if (active) {
|
|
35
|
+
return `${pc.green(S_RADIO_ACTIVE)} ${label} ${option.hint ? pc.dim(`(${option.hint})`) : ""}`;
|
|
36
|
+
}
|
|
37
|
+
return `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(label)}`;
|
|
38
|
+
}
|
|
39
|
+
function getWindow() {
|
|
40
|
+
if (options.length <= max) {
|
|
41
|
+
return { start: 0, end: options.length };
|
|
42
|
+
}
|
|
43
|
+
let start = 0;
|
|
44
|
+
if (cursor >= start + max - 3) {
|
|
45
|
+
start = Math.max(Math.min(cursor - max + 3, options.length - max), 0);
|
|
46
|
+
}
|
|
47
|
+
else if (cursor < start + 2) {
|
|
48
|
+
start = Math.max(cursor - 2, 0);
|
|
49
|
+
}
|
|
50
|
+
return { start, end: start + max };
|
|
51
|
+
}
|
|
52
|
+
function renderOptions() {
|
|
53
|
+
const { start, end } = getWindow();
|
|
54
|
+
const above = start;
|
|
55
|
+
const below = options.length - end;
|
|
56
|
+
const lines = options.slice(start, end).map((opt, i) => styleOption(opt, start + i === cursor));
|
|
57
|
+
if (above > 0 || below > 0) {
|
|
58
|
+
const parts = [];
|
|
59
|
+
if (above > 0)
|
|
60
|
+
parts.push(`↑ ${above} more`);
|
|
61
|
+
if (below > 0)
|
|
62
|
+
parts.push(`↓ ${below} more`);
|
|
63
|
+
lines.push(pc.dim(parts.join(" ")));
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
const prompt = new Prompt({
|
|
68
|
+
render() {
|
|
69
|
+
const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
|
|
70
|
+
switch (this.state) {
|
|
71
|
+
case "submit":
|
|
72
|
+
return `${title}${pc.gray(S_BAR)} ${pc.dim(options[cursor].label ?? String(options[cursor].value))}`;
|
|
73
|
+
case "cancel":
|
|
74
|
+
return `${title}${pc.gray(S_BAR)} ${pc.strikethrough(pc.dim(options[cursor].label ?? String(options[cursor].value)))}\n${pc.gray(S_BAR)}`;
|
|
75
|
+
default:
|
|
76
|
+
return `${title}${pc.cyan(S_BAR)} ${renderOptions().join(`\n${pc.cyan(S_BAR)} `)}\n${pc.cyan(S_BAR_END)}\n`;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
prompt.on("cursor", (action) => {
|
|
81
|
+
if (action === "up" || action === "left") {
|
|
82
|
+
if (cursor > 0)
|
|
83
|
+
cursor--;
|
|
84
|
+
}
|
|
85
|
+
else if (action === "down" || action === "right") {
|
|
86
|
+
if (cursor < options.length - 1)
|
|
87
|
+
cursor++;
|
|
88
|
+
}
|
|
89
|
+
prompt.value = options[cursor].value;
|
|
90
|
+
});
|
|
91
|
+
prompt.on("finalize", () => {
|
|
92
|
+
if (prompt.state === "submit") {
|
|
93
|
+
prompt.value = options[cursor].value;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
prompt.value = options[cursor].value;
|
|
97
|
+
return prompt.prompt();
|
|
98
|
+
}
|
|
@@ -159,36 +159,129 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
|
|
|
159
159
|
break;
|
|
160
160
|
}
|
|
161
161
|
if (commitDeploy) {
|
|
162
|
-
pollSpinner.stop("
|
|
162
|
+
pollSpinner.stop("Netlify deployment found!");
|
|
163
163
|
}
|
|
164
164
|
else {
|
|
165
|
-
pollSpinner.stop("
|
|
165
|
+
pollSpinner.stop("Netlify deployment is still building...");
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
|
-
// If we found a commit-specific deployment,
|
|
168
|
+
// If we found a commit-specific deployment, handle based on state
|
|
169
169
|
if (commitDeploy) {
|
|
170
|
-
const
|
|
170
|
+
const commitLabel = pc.bold(commitSha);
|
|
171
171
|
const message = commitDeploy.commitMessage
|
|
172
172
|
? pc.dim(` — ${truncate(commitDeploy.commitMessage.split("\n")[0], 50)}`)
|
|
173
173
|
: "";
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
if (commitDeploy.state === "error") {
|
|
175
|
+
p.log.error(`Netlify deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
|
|
176
|
+
commitDeploy = undefined;
|
|
177
|
+
// Fall through to the picker
|
|
178
|
+
}
|
|
179
|
+
else if (commitDeploy.state !== "ready") {
|
|
180
|
+
// Any non-ready, non-error state (building, enqueued, uploading, preparing, processing, etc.)
|
|
181
|
+
p.log.info(`Netlify deployment for ${commitLabel}${message} — ${pc.yellow(commitDeploy.state)}`);
|
|
182
|
+
const buildingUrl = commitDeploy.deploySslUrl;
|
|
183
|
+
const startTime = Date.now();
|
|
184
|
+
const maxWaitMs = 120_000;
|
|
185
|
+
const pollIntervalMs = 5_000;
|
|
186
|
+
let resolved = false;
|
|
187
|
+
const spinner = p.spinner();
|
|
188
|
+
const updateMessage = () => {
|
|
189
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
190
|
+
spinner.message(`Waiting for Netlify to finish building... (${elapsed}s)`);
|
|
191
|
+
};
|
|
192
|
+
spinner.start("Waiting for Netlify to finish building... (0s)");
|
|
193
|
+
const ticker = setInterval(updateMessage, 1000);
|
|
194
|
+
try {
|
|
195
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
196
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
197
|
+
updateMessage();
|
|
198
|
+
const freshDeps = await getNetlifyDeploys(token, site.siteId, site.siteName, {
|
|
199
|
+
branch: repoMatches ? (gitInfo.branch ?? undefined) : undefined,
|
|
200
|
+
});
|
|
201
|
+
const fresh = freshDeps.find((d) => d.commitRef === commitSha);
|
|
202
|
+
if (!fresh)
|
|
203
|
+
break;
|
|
204
|
+
if (fresh.state === "ready") {
|
|
205
|
+
clearInterval(ticker);
|
|
206
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
207
|
+
spinner.stop(`Netlify deployment ready! (${elapsed}s)`);
|
|
208
|
+
p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(fresh.deploySslUrl)}`);
|
|
209
|
+
return fresh.deploySslUrl;
|
|
210
|
+
}
|
|
211
|
+
if (fresh.state === "error") {
|
|
212
|
+
clearInterval(ticker);
|
|
213
|
+
spinner.stop(pc.red("Netlify deployment failed."));
|
|
214
|
+
p.log.error(`Netlify deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
|
|
215
|
+
commitDeploy = undefined;
|
|
216
|
+
resolved = true;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!resolved) {
|
|
221
|
+
clearInterval(ticker);
|
|
222
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
223
|
+
spinner.stop(`Still building after ${elapsed}s.`);
|
|
224
|
+
const action = await p.select({
|
|
225
|
+
message: "Netlify deployment is still building.",
|
|
226
|
+
options: [
|
|
227
|
+
{ value: "continue", label: "Use it anyway", hint: "URL may not be ready yet" },
|
|
228
|
+
{ value: "pick", label: "Select a different deployment" },
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
if (p.isCancel(action)) {
|
|
232
|
+
p.cancel("Cancelled.");
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
if (action === "continue") {
|
|
236
|
+
p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(buildingUrl)} ${pc.yellow("(building)")}`);
|
|
237
|
+
return buildingUrl;
|
|
238
|
+
}
|
|
239
|
+
commitDeploy = undefined;
|
|
240
|
+
// Fall through to picker
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
clearInterval(ticker);
|
|
245
|
+
spinner.stop("Error checking deployment status.");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
// ready — use it
|
|
250
|
+
p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(commitDeploy.deploySslUrl)}`);
|
|
251
|
+
return commitDeploy.deploySslUrl;
|
|
252
|
+
}
|
|
176
253
|
}
|
|
254
|
+
// Refresh deployments so the picker shows current states
|
|
255
|
+
deploys = await getNetlifyDeploys(token, site.siteId, site.siteName, {
|
|
256
|
+
branch: repoMatches ? (gitInfo.branch ?? undefined) : undefined,
|
|
257
|
+
});
|
|
177
258
|
// Fallback: no commit deployment — let user pick from recent or paste manually
|
|
178
259
|
if (deploys.length === 0) {
|
|
179
260
|
p.log.warn("No deployments found. Paste a URL instead.");
|
|
180
261
|
return null;
|
|
181
262
|
}
|
|
263
|
+
const hasWorkingDeploy = deploys.some((d) => d.state !== "error");
|
|
264
|
+
if (!hasWorkingDeploy) {
|
|
265
|
+
p.log.warn("All recent deployments failed. Fix the build and push again, or paste a URL.");
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
182
268
|
const maxBranch = Math.max(...deploys.map((d) => (d.branch ?? "unknown").length));
|
|
183
269
|
const selected = await p.select({
|
|
184
270
|
message: "No deployment found for current commit. Select one:",
|
|
185
271
|
options: [
|
|
186
272
|
...deploys.map((d) => {
|
|
273
|
+
const isFailed = d.state === "error";
|
|
187
274
|
const branch = (d.branch ?? "unknown").padEnd(maxBranch);
|
|
188
275
|
const ago = timeAgo(d.createdAt).padEnd(8);
|
|
189
|
-
const state = d.state !== "ready" ? ` ${pc.yellow(`(${d.state})`)}` : "";
|
|
190
276
|
const firstLine = (d.commitMessage ?? "No commit message").split("\n")[0];
|
|
191
277
|
const msg = truncate(firstLine, 55);
|
|
278
|
+
if (isFailed) {
|
|
279
|
+
return {
|
|
280
|
+
value: d.deploySslUrl,
|
|
281
|
+
label: pc.dim(`${branch} ${ago} ${pc.red("(failed)")} ${msg}`),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const state = d.state !== "ready" ? ` ${pc.yellow(`(${d.state})`)}` : "";
|
|
192
285
|
return { value: d.deploySslUrl, label: `${branch} ${ago}${state} ${pc.dim(msg)}` };
|
|
193
286
|
}),
|
|
194
287
|
{ value: "manual", label: "Paste a URL manually" },
|
|
@@ -198,7 +291,17 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
|
|
|
198
291
|
p.cancel("Cancelled.");
|
|
199
292
|
process.exit(0);
|
|
200
293
|
}
|
|
201
|
-
|
|
294
|
+
if (selected === "manual")
|
|
295
|
+
return null;
|
|
296
|
+
// Warn if the user picked a failed deployment
|
|
297
|
+
const pickedDeploy = deploys.find((d) => d.deploySslUrl === selected);
|
|
298
|
+
if (pickedDeploy && pickedDeploy.state === "error") {
|
|
299
|
+
p.log.warn("This deployment failed — the URL may not load.");
|
|
300
|
+
const confirm = await p.confirm({ message: "Use it anyway?" });
|
|
301
|
+
if (p.isCancel(confirm) || !confirm)
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return selected;
|
|
202
305
|
}
|
|
203
306
|
function truncate(str, max) {
|
|
204
307
|
return str.length > max ? str.slice(0, max - 1) + "…" : str;
|
package/dist/providers/vercel.js
CHANGED
|
@@ -156,7 +156,7 @@ async function createProjectFlow(projects, ctx) {
|
|
|
156
156
|
await new Promise((r) => setTimeout(r, 2000));
|
|
157
157
|
const deps = await getVercelDeployments(token, teamId, created.id);
|
|
158
158
|
if (deps.length > 0) {
|
|
159
|
-
pollSpinner.stop("
|
|
159
|
+
pollSpinner.stop("Vercel deployment started.");
|
|
160
160
|
return {
|
|
161
161
|
teamId,
|
|
162
162
|
projectId: created.id,
|
|
@@ -166,7 +166,7 @@ async function createProjectFlow(projects, ctx) {
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
-
pollSpinner.stop("
|
|
169
|
+
pollSpinner.stop("Vercel deployment may take a moment to appear.");
|
|
170
170
|
return {
|
|
171
171
|
teamId,
|
|
172
172
|
projectId: created.id,
|
|
@@ -221,36 +221,132 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
|
|
|
221
221
|
break;
|
|
222
222
|
}
|
|
223
223
|
if (commitDeploy) {
|
|
224
|
-
pollSpinner.stop("
|
|
224
|
+
pollSpinner.stop("Vercel deployment found!");
|
|
225
225
|
}
|
|
226
226
|
else {
|
|
227
|
-
pollSpinner.stop("
|
|
227
|
+
pollSpinner.stop("Vercel deployment is still building...");
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
-
// If we found a commit-specific deployment,
|
|
230
|
+
// If we found a commit-specific deployment, handle based on state
|
|
231
231
|
if (commitDeploy) {
|
|
232
|
-
const
|
|
232
|
+
const commitLabel = pc.bold(commitSha);
|
|
233
233
|
const message = commitDeploy.commitMessage
|
|
234
234
|
? pc.dim(` — ${truncate(commitDeploy.commitMessage.split("\n")[0], 50)}`)
|
|
235
235
|
: "";
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
if (commitDeploy.state === "ERROR" || commitDeploy.state === "CANCELED") {
|
|
237
|
+
p.log.error(`Vercel deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
|
|
238
|
+
commitDeploy = undefined;
|
|
239
|
+
// Fall through to the picker
|
|
240
|
+
}
|
|
241
|
+
else if (commitDeploy.state === "BUILDING" ||
|
|
242
|
+
commitDeploy.state === "QUEUED" ||
|
|
243
|
+
commitDeploy.state === "INITIALIZING") {
|
|
244
|
+
// Show what we're waiting on
|
|
245
|
+
p.log.info(`Vercel deployment for ${commitLabel}${message} — ${pc.yellow(commitDeploy.state.toLowerCase())}`);
|
|
246
|
+
// Poll with a ticking timer for up to ~2 minutes
|
|
247
|
+
const buildingUrl = commitDeploy.url;
|
|
248
|
+
const startTime = Date.now();
|
|
249
|
+
const maxWaitMs = 120_000;
|
|
250
|
+
const pollIntervalMs = 5_000;
|
|
251
|
+
let resolved = false;
|
|
252
|
+
const spinner = p.spinner();
|
|
253
|
+
const updateMessage = () => {
|
|
254
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
255
|
+
spinner.message(`Waiting for Vercel to finish building... (${elapsed}s)`);
|
|
256
|
+
};
|
|
257
|
+
spinner.start("Waiting for Vercel to finish building... (0s) ");
|
|
258
|
+
const ticker = setInterval(updateMessage, 1000);
|
|
259
|
+
try {
|
|
260
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
261
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
262
|
+
updateMessage();
|
|
263
|
+
const freshDeps = await getVercelDeployments(token, project.teamId, project.projectId, {
|
|
264
|
+
branch: repoMatches ? gitInfo.branch ?? undefined : undefined,
|
|
265
|
+
});
|
|
266
|
+
const fresh = freshDeps.find((d) => d.commitSha === commitSha);
|
|
267
|
+
if (!fresh)
|
|
268
|
+
break;
|
|
269
|
+
if (fresh.state === "READY") {
|
|
270
|
+
clearInterval(ticker);
|
|
271
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
272
|
+
spinner.stop(`Vercel deployment ready! (${elapsed}s)`);
|
|
273
|
+
p.log.info(`Vercel deployment for ${commitLabel}${message}:\n → ${pc.cyan(fresh.url)}`);
|
|
274
|
+
return fresh.url;
|
|
275
|
+
}
|
|
276
|
+
if (fresh.state === "ERROR" || fresh.state === "CANCELED") {
|
|
277
|
+
clearInterval(ticker);
|
|
278
|
+
spinner.stop(pc.red("Vercel deployment failed."));
|
|
279
|
+
p.log.error(`Vercel deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
|
|
280
|
+
commitDeploy = undefined;
|
|
281
|
+
resolved = true;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (!resolved) {
|
|
286
|
+
clearInterval(ticker);
|
|
287
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
288
|
+
spinner.stop(`Still building after ${elapsed}s.`);
|
|
289
|
+
const action = await p.select({
|
|
290
|
+
message: "Vercel deployment is still building.",
|
|
291
|
+
options: [
|
|
292
|
+
{ value: "continue", label: "Use it anyway", hint: "URL may not be ready yet" },
|
|
293
|
+
{ value: "pick", label: "Select a different deployment" },
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
if (p.isCancel(action)) {
|
|
297
|
+
p.cancel("Cancelled.");
|
|
298
|
+
process.exit(0);
|
|
299
|
+
}
|
|
300
|
+
if (action === "continue") {
|
|
301
|
+
p.log.info(`Vercel deployment for ${commitLabel}${message}:\n → ${pc.cyan(buildingUrl)} ${pc.yellow("(building)")}`);
|
|
302
|
+
return buildingUrl;
|
|
303
|
+
}
|
|
304
|
+
commitDeploy = undefined;
|
|
305
|
+
// Fall through to picker
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
clearInterval(ticker);
|
|
310
|
+
spinner.stop("Error checking deployment status.");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
// READY or any other terminal state — use it
|
|
315
|
+
p.log.info(`Vercel deployment for ${commitLabel}${message}:\n → ${pc.cyan(commitDeploy.url)}`);
|
|
316
|
+
return commitDeploy.url;
|
|
317
|
+
}
|
|
238
318
|
}
|
|
319
|
+
// Refresh deployments so the picker shows current states
|
|
320
|
+
deployments = await getVercelDeployments(token, project.teamId, project.projectId, {
|
|
321
|
+
branch: repoMatches ? gitInfo.branch ?? undefined : undefined,
|
|
322
|
+
});
|
|
239
323
|
// Fallback: no commit deployment found — let user pick from recent or paste manually
|
|
240
324
|
if (deployments.length === 0) {
|
|
241
325
|
p.log.warn("No deployments found. Paste a URL instead.");
|
|
242
326
|
return null;
|
|
243
327
|
}
|
|
244
|
-
const
|
|
328
|
+
const hasWorkingDeployment = deployments.some((d) => d.state !== "ERROR" && d.state !== "CANCELED");
|
|
329
|
+
if (!hasWorkingDeployment) {
|
|
330
|
+
p.log.warn("All recent deployments failed. Fix the build and push again, or paste a URL.");
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const maxBranchPick = Math.max(...deployments.map((d) => (d.branch ?? "unknown").length));
|
|
245
334
|
const selected = await p.select({
|
|
246
335
|
message: "No deployment found for current commit. Select one:",
|
|
247
336
|
options: [
|
|
248
337
|
...deployments.map((d) => {
|
|
249
|
-
const
|
|
338
|
+
const isFailed = d.state === "ERROR" || d.state === "CANCELED";
|
|
339
|
+
const branch = (d.branch ?? "unknown").padEnd(maxBranchPick);
|
|
250
340
|
const ago = timeAgo(d.createdAt).padEnd(8);
|
|
251
|
-
const state = d.state !== "READY" ? ` ${pc.yellow(`(${d.state.toLowerCase()})`)}` : "";
|
|
252
341
|
const firstLine = (d.commitMessage ?? "No commit message").split("\n")[0];
|
|
253
342
|
const msg = truncate(firstLine, 55);
|
|
343
|
+
if (isFailed) {
|
|
344
|
+
return {
|
|
345
|
+
value: d.url,
|
|
346
|
+
label: pc.dim(`${branch} ${ago} ${pc.red("(failed)")} ${msg}`),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const state = d.state !== "READY" ? ` ${pc.yellow(`(${d.state.toLowerCase()})`)}` : "";
|
|
254
350
|
return { value: d.url, label: `${branch} ${ago}${state} ${pc.dim(msg)}` };
|
|
255
351
|
}),
|
|
256
352
|
{ value: "manual", label: "Paste a URL manually" },
|
|
@@ -260,7 +356,17 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
|
|
|
260
356
|
p.cancel("Cancelled.");
|
|
261
357
|
process.exit(0);
|
|
262
358
|
}
|
|
263
|
-
|
|
359
|
+
if (selected === "manual")
|
|
360
|
+
return null;
|
|
361
|
+
// Warn if the user picked a failed deployment
|
|
362
|
+
const pickedDeploy = deployments.find((d) => d.url === selected);
|
|
363
|
+
if (pickedDeploy && (pickedDeploy.state === "ERROR" || pickedDeploy.state === "CANCELED")) {
|
|
364
|
+
p.log.warn("This deployment failed — the URL may not load.");
|
|
365
|
+
const confirm = await p.confirm({ message: "Use it anyway?" });
|
|
366
|
+
if (p.isCancel(confirm) || !confirm)
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
return selected;
|
|
264
370
|
}
|
|
265
371
|
function truncate(str, max) {
|
|
266
372
|
return str.length > max ? str.slice(0, max - 1) + "…" : str;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inflight-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Get feedback directly on your staging URL",
|
|
5
5
|
"bin": {
|
|
6
6
|
"inflight": "dist/index.js",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"prepublishOnly": "npm run build"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@clack/
|
|
22
|
+
"@clack/core": "1.2.0",
|
|
23
|
+
"@clack/prompts": "1.2.0",
|
|
23
24
|
"commander": "12.1.0",
|
|
24
25
|
"open": "11.0.0",
|
|
25
26
|
"picocolors": "1.1.1",
|