inflight-cli 2.1.6 → 2.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.
@@ -5,7 +5,7 @@ import { readGlobalAuth, readWorkspaceConfig, writeWorkspaceConfig } from "../li
5
5
  import { apiGetMe, apiDetectWidgetLocation } from "../lib/api.js";
6
6
  import { loginCommand } from "./login.js";
7
7
  import { shareCommand } from "./share.js";
8
- import { gatherProjectContext, hasInflightWidget, insertWidgetScript } from "../lib/framework.js";
8
+ import { gatherProjectContext, insertWidgetScript } from "../lib/framework.js";
9
9
  import { isGitRepo } from "../lib/git.js";
10
10
  import { installSkill } from "../lib/skill.js";
11
11
  function execSyncErrorDetail(err) {
@@ -83,7 +83,10 @@ export async function setupCommand() {
83
83
  process.exit(1);
84
84
  }
85
85
  // ── Step 4: Add widget script tag ──
86
- if (hasInflightWidget(cwd)) {
86
+ const hasWidget = (fileContents) => Object.values(fileContents).some((c) => c.includes("inflight.co/widget.js"));
87
+ const context = gatherProjectContext(cwd);
88
+ const alreadyHasWidget = hasWidget(context.fileContents);
89
+ if (alreadyHasWidget) {
87
90
  // Already present — move on silently
88
91
  }
89
92
  else {
@@ -91,14 +94,13 @@ export async function setupCommand() {
91
94
  spinner.start("Detecting framework...");
92
95
  let inserted = false;
93
96
  try {
94
- const context = gatherProjectContext(cwd);
95
97
  const location = await apiDetectWidgetLocation({
96
98
  apiKey: auth.apiKey,
97
99
  fileTree: context.fileTree,
98
100
  fileContents: context.fileContents,
99
101
  });
100
102
  if (location.file && location.insertAfter && location.confidence === "high") {
101
- const result = insertWidgetScript(cwd, location.file, location.insertAfter, widgetId);
103
+ const result = insertWidgetScript(context.root, location.file, location.insertAfter, widgetId);
102
104
  if (result) {
103
105
  spinner.stop(`Detected ${pc.bold(location.framework ?? "framework")} — widget script tag added to ${pc.cyan(location.file)}`);
104
106
  inserted = true;
@@ -126,7 +128,9 @@ export async function setupCommand() {
126
128
  p.cancel("Cancelled.");
127
129
  process.exit(0);
128
130
  }
129
- if (!hasInflightWidget(cwd)) {
131
+ // Re-scan to check if user added the widget manually
132
+ const rescan = gatherProjectContext(cwd);
133
+ if (!hasWidget(rescan.fileContents)) {
130
134
  const skip = await p.confirm({
131
135
  message: "Widget script not detected. Continue anyway?",
132
136
  initialValue: false,
@@ -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, hasCommitsAhead } 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.
@@ -303,16 +320,75 @@ export async function shareCommand(opts = {}) {
303
320
  if (!stagingUrl.startsWith("http")) {
304
321
  stagingUrl = `https://${stagingUrl}`;
305
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
+ }
306
375
  await apiCreateVersion({
307
376
  apiKey: auth.apiKey,
308
377
  workspaceId,
309
378
  stagingUrl,
310
379
  gitInfo,
380
+ ...(selectedProjectId && { projectId: selectedProjectId }),
381
+ ...(overrideVersionId && { overrideVersionId }),
311
382
  }).catch((e) => {
312
383
  p.log.error(e.message);
313
384
  process.exit(1);
314
385
  });
315
386
  p.log.info(`Staging URL: ${pc.cyan(stagingUrl)}`);
316
- 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
+ }
317
393
  await open(stagingUrl);
318
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) {
@@ -5,6 +5,7 @@
5
5
  export declare function gatherProjectContext(cwd: string): {
6
6
  fileTree: string[];
7
7
  fileContents: Record<string, string>;
8
+ root: string;
8
9
  };
9
10
  /**
10
11
  * Returns true if any candidate file in the project already has the Inflight widget script.
@@ -14,4 +15,4 @@ export declare function hasInflightWidget(cwd: string): boolean;
14
15
  * Inserts the script tag into a file at the specified location.
15
16
  * Returns the file path that was modified, or null if insertion failed.
16
17
  */
17
- export declare function insertWidgetScript(cwd: string, file: string, insertAfter: string, widgetId: string): string | null;
18
+ export declare function insertWidgetScript(root: string, file: string, insertAfter: string, widgetId: string): string | null;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs";
2
2
  import { join, relative } from "path";
3
+ import { getGitRoot } from "./git.js";
3
4
  /** File patterns that are likely layout/root files where a script tag belongs */
4
5
  const CANDIDATE_PATTERNS = [
5
6
  "package.json",
@@ -27,10 +28,12 @@ const CANDIDATE_PATTERNS = [
27
28
  export function gatherProjectContext(cwd) {
28
29
  const fileTree = [];
29
30
  const fileContents = {};
30
- const dirsToScan = [cwd];
31
+ // Use git root so we scan the full repo, not just wherever the user ran the CLI from
32
+ const root = getGitRoot(cwd) ?? cwd;
33
+ const dirsToScan = [root];
31
34
  // If monorepo, add common subdirectories
32
35
  for (const dir of ["apps", "packages", "projects", "services", "libs"]) {
33
- const baseDir = join(cwd, dir);
36
+ const baseDir = join(root, dir);
34
37
  if (!existsSync(baseDir))
35
38
  continue;
36
39
  try {
@@ -49,7 +52,7 @@ export function gatherProjectContext(cwd) {
49
52
  // Add shallow file listing (1 level deep + key subdirs)
50
53
  try {
51
54
  for (const entry of readdirSync(dir)) {
52
- fileTree.push(relative(cwd, join(dir, entry)));
55
+ fileTree.push(relative(root, join(dir, entry)));
53
56
  }
54
57
  // Also list key subdirectories
55
58
  for (const sub of ["app", "src", "src/app", "src/layouts", "pages", "src/pages"]) {
@@ -57,7 +60,7 @@ export function gatherProjectContext(cwd) {
57
60
  if (!existsSync(subDir))
58
61
  continue;
59
62
  for (const entry of readdirSync(subDir)) {
60
- fileTree.push(relative(cwd, join(subDir, entry)));
63
+ fileTree.push(relative(root, join(subDir, entry)));
61
64
  }
62
65
  }
63
66
  }
@@ -67,7 +70,7 @@ export function gatherProjectContext(cwd) {
67
70
  // Read candidate files
68
71
  for (const pattern of CANDIDATE_PATTERNS) {
69
72
  const filePath = join(dir, pattern);
70
- const relPath = relative(cwd, filePath);
73
+ const relPath = relative(root, filePath);
71
74
  if (existsSync(filePath) && !fileContents[relPath]) {
72
75
  try {
73
76
  const content = readFileSync(filePath, "utf-8");
@@ -85,7 +88,7 @@ export function gatherProjectContext(cwd) {
85
88
  try {
86
89
  for (const f of readdirSync(layoutsDir).filter((f) => f.endsWith(".astro"))) {
87
90
  const filePath = join(layoutsDir, f);
88
- const relPath = relative(cwd, filePath);
91
+ const relPath = relative(root, filePath);
89
92
  if (!fileContents[relPath]) {
90
93
  const content = readFileSync(filePath, "utf-8");
91
94
  fileContents[relPath] = content.length > 3000 ? content.slice(0, 3000) + "\n... (truncated)" : content;
@@ -97,7 +100,7 @@ export function gatherProjectContext(cwd) {
97
100
  }
98
101
  }
99
102
  }
100
- return { fileTree, fileContents };
103
+ return { fileTree, fileContents, root };
101
104
  }
102
105
  /**
103
106
  * Returns true if any candidate file in the project already has the Inflight widget script.
@@ -110,9 +113,9 @@ export function hasInflightWidget(cwd) {
110
113
  * Inserts the script tag into a file at the specified location.
111
114
  * Returns the file path that was modified, or null if insertion failed.
112
115
  */
113
- export function insertWidgetScript(cwd, file, insertAfter, widgetId) {
116
+ export function insertWidgetScript(root, file, insertAfter, widgetId) {
114
117
  try {
115
- const filePath = join(cwd, file);
118
+ const filePath = join(root, file);
116
119
  const content = readFileSync(filePath, "utf-8");
117
120
  if (content.includes("inflight.co/widget.js")) {
118
121
  return file; // Already present
@@ -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.6",
3
+ "version": "2.2.0",
4
4
  "description": "Get feedback directly on your staging URL",
5
5
  "bin": {
6
6
  "inflight": "dist/index.js",