inflight-cli 2.1.5 → 2.1.7

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.
@@ -1,10 +1,27 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
3
  import { readGlobalAuth, readWorkspaceConfig, writeWorkspaceConfig } from "../lib/config.js";
4
- import { getGitInfo, getGitSyncState, generateCommitMessage, commitAndPush, pushBranch } from "../lib/git.js";
4
+ import { getGitInfo, getGitSyncState, generateCommitMessage, commitAndPush, pushBranch, hasCommitsAhead, } from "../lib/git.js";
5
5
  import open from "open";
6
6
  import { providers } from "../providers/index.js";
7
- import { apiGetMe, apiCreateVersion } from "../lib/api.js";
7
+ import { apiGetMe, apiCreateVersion, apiGetRecentProjects } from "../lib/api.js";
8
+ function formatRelativeTime(timestampMs) {
9
+ const seconds = Math.floor((Date.now() - timestampMs) / 1000);
10
+ if (seconds < 60)
11
+ return "just now";
12
+ const minutes = Math.floor(seconds / 60);
13
+ if (minutes < 60)
14
+ return `${minutes}m ago`;
15
+ const hours = Math.floor(minutes / 60);
16
+ if (hours < 24)
17
+ return `${hours}h ago`;
18
+ const days = Math.floor(hours / 24);
19
+ if (days === 1)
20
+ return "yesterday";
21
+ if (days < 7)
22
+ return `${days}d ago`;
23
+ return new Date(timestampMs).toLocaleDateString("en-US", { month: "short", day: "numeric" });
24
+ }
8
25
  /**
9
26
  * Checks local git state and prompts the user to commit/push if needed.
10
27
  * Returns { justPushed: true } if changes were pushed, { justPushed: false } otherwise.
@@ -126,7 +143,13 @@ async function checkAndSyncGit(cwd) {
126
143
  }
127
144
  }
128
145
  if (state.status === "no_remote") {
129
- // Working tree is clean (uncommitted was checked first in getGitSyncState), just need to push
146
+ // Working tree is clean (uncommitted was checked first in getGitSyncState).
147
+ // Check if this branch actually has commits ahead of the default branch —
148
+ // if not, pushing won't trigger a deployment.
149
+ if (!hasCommitsAhead(cwd)) {
150
+ p.log.info(`Branch ${pc.bold(branch)} has no new commits — using existing deployments.`);
151
+ return { justPushed: false };
152
+ }
130
153
  p.log.warn(`Branch ${pc.bold(branch)} hasn't been pushed yet — no deployment exists.`);
131
154
  const confirm = await p.confirm({
132
155
  message: "Push to create a deployment?",
@@ -154,14 +177,15 @@ async function checkAndSyncGit(cwd) {
154
177
  }
155
178
  export async function shareCommand(opts = {}) {
156
179
  const cwd = process.cwd();
180
+ // TODO: Add a step to login if not authenticated
157
181
  // ── Step 1: Auth ──
158
182
  const auth = readGlobalAuth();
159
183
  if (!auth) {
160
184
  if (opts.json) {
161
- console.log(JSON.stringify({ error: "not_authenticated", message: "Not logged in. Run inflight login first." }));
185
+ console.log(JSON.stringify({ error: "not_authenticated", message: "Not logged in. Run inflight setup first." }));
162
186
  }
163
187
  else {
164
- p.log.error("Not logged in. Run " + pc.cyan("inflight login") + " first.");
188
+ p.log.error("Not logged in. Run " + pc.cyan("inflight setup") + " first.");
165
189
  }
166
190
  process.exit(1);
167
191
  }
@@ -296,16 +320,75 @@ export async function shareCommand(opts = {}) {
296
320
  if (!stagingUrl.startsWith("http")) {
297
321
  stagingUrl = `https://${stagingUrl}`;
298
322
  }
323
+ // ── Step 4: Project selection ──
324
+ let selectedProjectId;
325
+ let overrideVersionId;
326
+ const { projects: recentProjects } = await apiGetRecentProjects(auth.apiKey, workspaceId).catch(() => ({
327
+ projects: [],
328
+ }));
329
+ if (recentProjects.length > 0) {
330
+ const projectChoice = await p.select({
331
+ message: "Add to an existing version or start fresh?",
332
+ options: [
333
+ ...recentProjects.map((proj) => ({
334
+ value: proj.projectId,
335
+ label: `"${proj.latestVersion.title}"`,
336
+ hint: `created ${formatRelativeTime(proj.latestVersion.createdAt)}`,
337
+ })),
338
+ { value: "new", label: "Start fresh" },
339
+ ],
340
+ });
341
+ if (p.isCancel(projectChoice)) {
342
+ p.cancel("Cancelled.");
343
+ process.exit(0);
344
+ }
345
+ if (projectChoice !== "new") {
346
+ selectedProjectId = projectChoice;
347
+ // Check for override opportunity — only when latest version has no feedback
348
+ const selectedProject = recentProjects.find((proj) => proj.projectId === selectedProjectId);
349
+ if (selectedProject && selectedProject.latestVersion.commentCount === 0) {
350
+ const overrideChoice = await p.select({
351
+ message: `"${selectedProject.latestVersion.title}" has no feedback yet.`,
352
+ options: [
353
+ {
354
+ value: "override",
355
+ label: "Update its staging URL",
356
+ hint: `replace with ${new URL(stagingUrl).hostname}`,
357
+ },
358
+ {
359
+ value: "new_version",
360
+ label: "Add a new version",
361
+ hint: "keep both in version history",
362
+ },
363
+ ],
364
+ });
365
+ if (p.isCancel(overrideChoice)) {
366
+ p.cancel("Cancelled.");
367
+ process.exit(0);
368
+ }
369
+ if (overrideChoice === "override") {
370
+ overrideVersionId = selectedProject.latestVersion.id;
371
+ }
372
+ }
373
+ }
374
+ }
299
375
  await apiCreateVersion({
300
376
  apiKey: auth.apiKey,
301
377
  workspaceId,
302
378
  stagingUrl,
303
379
  gitInfo,
380
+ ...(selectedProjectId && { projectId: selectedProjectId }),
381
+ ...(overrideVersionId && { overrideVersionId }),
304
382
  }).catch((e) => {
305
383
  p.log.error(e.message);
306
384
  process.exit(1);
307
385
  });
308
386
  p.log.info(`Staging URL: ${pc.cyan(stagingUrl)}`);
309
- p.outro(pc.green("✓ Inflight added to your staging URL") + " — opening in browser...");
387
+ if (overrideVersionId) {
388
+ p.outro(pc.green("✓ Staging URL updated") + " — opening in browser...");
389
+ }
390
+ else {
391
+ p.outro(pc.green("✓ Inflight added to your staging URL") + " — opening in browser...");
392
+ }
310
393
  await open(stagingUrl);
311
394
  }
package/dist/lib/api.d.ts CHANGED
@@ -9,16 +9,31 @@ export interface CreateVersionResult {
9
9
  versionId: string;
10
10
  inflightUrl: string;
11
11
  }
12
+ export interface RecentProject {
13
+ projectId: string;
14
+ latestVersion: {
15
+ id: string;
16
+ title: string;
17
+ stagingUrl: string | null;
18
+ commentCount: number;
19
+ createdAt: number;
20
+ };
21
+ }
12
22
  export declare function apiGetMe(apiKey: string): Promise<{
13
23
  name: string | null;
14
24
  email: string | null;
15
25
  workspaces: Workspace[];
16
26
  }>;
27
+ export declare function apiGetRecentProjects(apiKey: string, workspaceId: string): Promise<{
28
+ projects: RecentProject[];
29
+ }>;
17
30
  export declare function apiCreateVersion(opts: {
18
31
  apiKey: string;
19
32
  workspaceId: string;
20
33
  stagingUrl: string;
21
34
  gitInfo: GitInfo;
35
+ projectId?: string;
36
+ overrideVersionId?: string;
22
37
  }): Promise<CreateVersionResult>;
23
38
  export interface WidgetLocationResult {
24
39
  file: string | null;
package/dist/lib/api.js CHANGED
@@ -7,6 +7,16 @@ 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)}`, {
12
+ headers: { Authorization: `Bearer ${apiKey}` },
13
+ });
14
+ if (!res.ok) {
15
+ const body = await res.json().catch(() => ({ error: res.statusText }));
16
+ throw new Error(body.error ?? `API error ${res.status}`);
17
+ }
18
+ return res.json();
19
+ }
10
20
  export async function apiCreateVersion(opts) {
11
21
  const res = await fetch(`${API_URL}/api/cli/version/create`, {
12
22
  method: "POST",
@@ -18,6 +28,8 @@ export async function apiCreateVersion(opts) {
18
28
  workspaceId: opts.workspaceId,
19
29
  stagingUrl: opts.stagingUrl,
20
30
  gitInfo: opts.gitInfo,
31
+ ...(opts.projectId && { projectId: opts.projectId }),
32
+ ...(opts.overrideVersionId && { overrideVersionId: opts.overrideVersionId }),
21
33
  }),
22
34
  });
23
35
  if (!res.ok) {
package/dist/lib/git.d.ts CHANGED
@@ -74,4 +74,9 @@ export declare function commitAndPush(cwd: string, message: string, branch: stri
74
74
  /**
75
75
  * Pushes existing commits. Uses `-u` if no upstream exists.
76
76
  */
77
+ /**
78
+ * Returns true if the current branch has commits ahead of the default branch (main/master).
79
+ * Used to avoid pushing branches with no new commits (which won't trigger a deployment).
80
+ */
81
+ export declare function hasCommitsAhead(cwd: string): boolean;
77
82
  export declare function pushBranch(cwd: string, branch: string): void;
package/dist/lib/git.js CHANGED
@@ -315,6 +315,15 @@ export function commitAndPush(cwd, message, branch) {
315
315
  /**
316
316
  * Pushes existing commits. Uses `-u` if no upstream exists.
317
317
  */
318
+ /**
319
+ * Returns true if the current branch has commits ahead of the default branch (main/master).
320
+ * Used to avoid pushing branches with no new commits (which won't trigger a deployment).
321
+ */
322
+ export function hasCommitsAhead(cwd) {
323
+ const defaultBranch = getDefaultBranch(cwd);
324
+ const count = run(`git rev-list ${defaultBranch}..HEAD --count`, cwd);
325
+ return count !== null && count !== "0";
326
+ }
318
327
  export function pushBranch(cwd, branch) {
319
328
  const upstream = run(`git rev-parse --abbrev-ref ${branch}@{upstream}`, cwd);
320
329
  if (upstream) {
@@ -3,7 +3,7 @@ import pc from "picocolors";
3
3
  import { execSync } from "child_process";
4
4
  import { parseGitRepo, getGitRoot } from "../lib/git.js";
5
5
  import { writeVercelConfig } from "../lib/config.js";
6
- import { ensureVercelCli, ensureVercelAuth, readLocalVercelProject, writeLocalVercelProject, getVercelProjectDetail, fetchAllProjectsWithLinks, matchProjectsByRepo, createVercelProject, getBranchAlias, getRecentDeployments, } from "../lib/vercel.js";
6
+ import { ensureVercelCli, ensureVercelAuth, readLocalVercelProject, writeLocalVercelProject, getVercelProjectDetail, fetchAllProjectsWithLinks, matchProjectsByRepo, createVercelProject, getRecentDeployments, } from "../lib/vercel.js";
7
7
  // --- Auto-detection ---
8
8
  /**
9
9
  * Auto-detect the Vercel project for the current git repo.
@@ -29,7 +29,7 @@ async function autoDetectProject(cwd, gitInfo, token) {
29
29
  if (localProject) {
30
30
  const detail = await getVercelProjectDetail(token, localProject.projectId, localProject.orgId);
31
31
  if (detail) {
32
- p.log.info(`Detected Vercel project: ${pc.bold(detail.name)}`);
32
+ // p.log.info(`Detected Vercel project: ${pc.bold(detail.name)}`);
33
33
  return { teamId: localProject.orgId, projectId: detail.id, projectName: detail.name };
34
34
  }
35
35
  }
@@ -196,61 +196,57 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
196
196
  const project = await autoDetectProject(cwd, gitInfo, token);
197
197
  if (!project)
198
198
  return null;
199
- // Fetch branch alias — if just pushed, poll until it appears
200
- let branchAlias = await getBranchAlias(token, project.teamId, project.projectId, gitInfo.branch);
201
- if (!branchAlias && opts?.justPushed) {
199
+ const commitSha = gitInfo.commitShort?.slice(0, 7) ?? null;
200
+ // Try to find a deployment matching the current commit
201
+ let deployments = await getRecentDeployments(token, project.teamId, project.projectId, {
202
+ branch: gitInfo.branch ?? undefined,
203
+ });
204
+ let commitDeploy = deployments.find((d) => d.commitSha === commitSha);
205
+ // If just pushed, poll until the commit deployment appears
206
+ if (!commitDeploy && opts?.justPushed) {
202
207
  const pollSpinner = p.spinner();
203
208
  pollSpinner.start("Waiting for Vercel deployment...");
204
209
  for (let i = 0; i < 30; i++) {
205
210
  await new Promise((r) => setTimeout(r, 2000));
206
- branchAlias = await getBranchAlias(token, project.teamId, project.projectId, gitInfo.branch);
207
- if (branchAlias)
211
+ deployments = await getRecentDeployments(token, project.teamId, project.projectId, {
212
+ branch: gitInfo.branch ?? undefined,
213
+ });
214
+ commitDeploy = deployments.find((d) => d.commitSha === commitSha);
215
+ if (commitDeploy)
208
216
  break;
209
217
  }
210
- if (branchAlias) {
218
+ if (commitDeploy) {
211
219
  pollSpinner.stop("Deployment found!");
212
220
  }
213
221
  else {
214
222
  pollSpinner.stop("Deployment is still building...");
215
223
  }
216
224
  }
217
- if (branchAlias) {
218
- const stateLabel = branchAlias.state !== "READY" ? ` ${pc.yellow(`(${branchAlias.state.toLowerCase()})`)}` : "";
219
- p.log.info(`Branch preview (auto-updates with each push):\n → ${pc.cyan(branchAlias.url)}${stateLabel}`);
220
- const choice = await p.select({
221
- message: "Use this URL, or pick a specific deployment?",
222
- options: [
223
- { value: "branch", label: "Use branch preview (recommended)" },
224
- { value: "recent", label: "Pick a specific deployment" },
225
- { value: "manual", label: "Paste a URL manually" },
226
- ],
227
- });
228
- if (p.isCancel(choice)) {
229
- p.cancel("Cancelled.");
230
- process.exit(0);
231
- }
232
- if (choice === "branch")
233
- return branchAlias.url;
234
- if (choice === "manual")
235
- return null;
225
+ // If we found a commit-specific deployment, use it automatically
226
+ if (commitDeploy) {
227
+ const stateLabel = commitDeploy.state !== "READY" ? ` ${pc.yellow(`(${commitDeploy.state.toLowerCase()})`)}` : "";
228
+ const message = commitDeploy.commitMessage
229
+ ? pc.dim(` ${truncate(commitDeploy.commitMessage.split("\n")[0], 50)}`)
230
+ : "";
231
+ p.log.info(`Deployment for ${pc.bold(commitSha)}${message}:\n → ${pc.cyan(commitDeploy.url)}${stateLabel}`);
232
+ return commitDeploy.url;
236
233
  }
237
- // Show recent deployments
238
- const recent = await getRecentDeployments(token, project.teamId, project.projectId);
239
- if (recent.length === 0) {
234
+ // Fallback: no commit deployment found — let user pick from recent or paste manually
235
+ if (deployments.length === 0) {
240
236
  p.log.warn("No deployments found. Paste a URL instead.");
241
237
  return null;
242
238
  }
243
- const maxBranch = Math.max(...recent.map((d) => (d.branch ?? "unknown").length));
239
+ const maxBranch = Math.max(...deployments.map((d) => (d.branch ?? "unknown").length));
244
240
  const selected = await p.select({
245
- message: "Select a specific deployment",
241
+ message: "No deployment found for current commit. Select one:",
246
242
  options: [
247
- ...recent.map((d) => {
243
+ ...deployments.map((d) => {
248
244
  const branch = (d.branch ?? "unknown").padEnd(maxBranch);
249
245
  const ago = timeAgo(d.createdAt).padEnd(8);
250
246
  const state = d.state !== "READY" ? ` ${pc.yellow(`(${d.state.toLowerCase()})`)}` : "";
251
247
  const firstLine = (d.commitMessage ?? "No commit message").split("\n")[0];
252
- const message = truncate(firstLine, 55);
253
- return { value: d.url, label: `${branch} ${ago}${state} ${pc.dim(message)}` };
248
+ const msg = truncate(firstLine, 55);
249
+ return { value: d.url, label: `${branch} ${ago}${state} ${pc.dim(msg)}` };
254
250
  }),
255
251
  { value: "manual", label: "Paste a URL manually" },
256
252
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inflight-cli",
3
- "version": "2.1.5",
3
+ "version": "2.1.7",
4
4
  "description": "Get feedback directly on your staging URL",
5
5
  "bin": {
6
6
  "inflight": "dist/index.js",