inflight-cli 2.4.0 → 2.5.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.
@@ -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";
@@ -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 isLocalhostUrl(url) {
26
+ function isValidHostedUrl(url) {
26
27
  try {
27
28
  const hostname = new URL(url.startsWith("http") ? url : `https://${url}`).hostname;
28
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0" || hostname === "[::1]";
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 (isLocalhostUrl(stagingUrl)) {
263
- const message = "Inflight needs a hosted URL — localhost isn't accessible to your team. Deploy to Vercel, Netlify, or another hosting provider first.";
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: "localhost_url", message }));
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 (isLocalhostUrl(v)) {
332
- return "Inflight needs a hosted URL — localhost isn't accessible to your team. Deploy to Vercel, Netlify, or another hosting provider first.";
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,30 @@ export async function shareCommand(opts = {}) {
345
350
  // ── Step 4: Project selection ──
346
351
  let selectedProjectId;
347
352
  let overrideVersionId;
348
- const { projects: recentProjects } = await apiGetRecentProjects(auth.apiKey, workspaceId).catch(() => ({
353
+ const { projects } = await apiGetRecentProjects(auth.apiKey, workspaceId, 20).catch(() => ({
349
354
  projects: [],
350
355
  }));
351
- if (recentProjects.length > 0) {
352
- const projectChoice = await p.select({
353
- message: "Add to an existing project or start a new one?",
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
+ const projectChoice = await scrollableSelect({
363
+ message: "Create a new project in Inflight, or update an existing one?",
364
+ maxItems: 5,
365
+ initialValue: branchMatch?.projectId,
354
366
  options: [
355
367
  { value: "new", label: "Start a new project" },
356
- ...recentProjects.map((proj) => ({
368
+ ...projects.map((proj) => ({
357
369
  value: proj.projectId,
358
370
  label: `"${proj.latestVersion.title}"`,
359
- hint: `created ${formatRelativeTime(proj.latestVersion.createdAt)}`,
371
+ hint: [
372
+ formatRelativeTime(proj.latestVersion.createdAt),
373
+ proj.latestVersion.branch === currentBranch ? "← current branch" : "",
374
+ ]
375
+ .filter(Boolean)
376
+ .join(" · "),
360
377
  })),
361
378
  ],
362
379
  });
@@ -367,7 +384,7 @@ export async function shareCommand(opts = {}) {
367
384
  if (projectChoice !== "new") {
368
385
  selectedProjectId = projectChoice;
369
386
  // Check for override opportunity — only when latest version has no feedback
370
- const selectedProject = recentProjects.find((proj) => proj.projectId === selectedProjectId);
387
+ const selectedProject = projects.find((proj) => proj.projectId === selectedProjectId);
371
388
  if (selectedProject && selectedProject.latestVersion.commentCount === 0) {
372
389
  const overrideChoice = await p.select({
373
390
  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 res = await fetch(`${API_URL}/api/cli/projects/recent?workspaceId=${encodeURIComponent(workspaceId)}`, {
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("Deployment found!");
162
+ pollSpinner.stop("Netlify deployment found!");
163
163
  }
164
164
  else {
165
- pollSpinner.stop("Deployment is still building...");
165
+ pollSpinner.stop("Netlify deployment is still building...");
166
166
  }
167
167
  }
168
- // If we found a commit-specific deployment, use it automatically
168
+ // If we found a commit-specific deployment, handle based on state
169
169
  if (commitDeploy) {
170
- const stateLabel = commitDeploy.state !== "ready" ? ` ${pc.yellow(`(${commitDeploy.state})`)}` : "";
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
- p.log.info(`Deployment for ${pc.bold(commitSha)}${message}:\n → ${pc.cyan(commitDeploy.deploySslUrl)}${stateLabel}`);
175
- return commitDeploy.deploySslUrl;
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
- return selected === "manual" ? null : selected;
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;
@@ -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("Deployment started.");
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("Deployment may take a moment to appear.");
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("Deployment found!");
224
+ pollSpinner.stop("Vercel deployment found!");
225
225
  }
226
226
  else {
227
- pollSpinner.stop("Deployment is still building...");
227
+ pollSpinner.stop("Vercel deployment is still building...");
228
228
  }
229
229
  }
230
- // If we found a commit-specific deployment, use it automatically
230
+ // If we found a commit-specific deployment, handle based on state
231
231
  if (commitDeploy) {
232
- const stateLabel = commitDeploy.state !== "READY" ? ` ${pc.yellow(`(${commitDeploy.state.toLowerCase()})`)}` : "";
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
- p.log.info(`Deployment for ${pc.bold(commitSha)}${message}:\n → ${pc.cyan(commitDeploy.url)}${stateLabel}`);
237
- return commitDeploy.url;
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 maxBranch = Math.max(...deployments.map((d) => (d.branch ?? "unknown").length));
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 branch = (d.branch ?? "unknown").padEnd(maxBranch);
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
- return selected === "manual" ? null : selected;
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.4.0",
3
+ "version": "2.5.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/prompts": "0.8.2",
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",