inflight-cli 2.0.9 → 2.1.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.
@@ -8,6 +8,20 @@ import { shareCommand } from "./share.js";
8
8
  import { gatherProjectContext, hasInflightWidget, insertWidgetScript } from "../lib/framework.js";
9
9
  import { isGitRepo } from "../lib/git.js";
10
10
  import { installSkill } from "../lib/skill.js";
11
+ function execSyncErrorDetail(err) {
12
+ if (err !== null && typeof err === "object" && "stderr" in err) {
13
+ const b = err.stderr;
14
+ if (Buffer.isBuffer(b) && b.length > 0) {
15
+ const text = b.toString("utf-8").trim();
16
+ if (text)
17
+ return text;
18
+ }
19
+ }
20
+ if (err instanceof Error && err.message) {
21
+ return err.message.trim();
22
+ }
23
+ return "";
24
+ }
11
25
  export async function setupCommand() {
12
26
  const cwd = process.cwd();
13
27
  // ── Step 1: Install agent skill ──
@@ -75,31 +89,43 @@ export async function setupCommand() {
75
89
  else {
76
90
  const spinner = p.spinner();
77
91
  spinner.start("Detecting framework...");
78
- const context = gatherProjectContext(cwd);
79
- const location = await apiDetectWidgetLocation({
80
- apiKey: auth.apiKey,
81
- fileTree: context.fileTree,
82
- fileContents: context.fileContents,
83
- });
84
92
  let inserted = false;
85
- if (location.file && location.insertAfter && location.confidence === "high") {
86
- const result = insertWidgetScript(cwd, location.file, location.insertAfter, widgetId);
87
- if (result) {
88
- spinner.stop(`Detected ${pc.bold(location.framework ?? "framework")} — widget script tag added to ${pc.cyan(location.file)}`);
89
- inserted = true;
93
+ try {
94
+ const context = gatherProjectContext(cwd);
95
+ const location = await apiDetectWidgetLocation({
96
+ apiKey: auth.apiKey,
97
+ fileTree: context.fileTree,
98
+ fileContents: context.fileContents,
99
+ });
100
+ if (location.file && location.insertAfter && location.confidence === "high") {
101
+ const result = insertWidgetScript(cwd, location.file, location.insertAfter, widgetId);
102
+ if (result) {
103
+ spinner.stop(`Detected ${pc.bold(location.framework ?? "framework")} — widget script tag added to ${pc.cyan(location.file)}`);
104
+ inserted = true;
105
+ }
106
+ else {
107
+ spinner.stop("Could not update the detected file. Add the snippet manually below.");
108
+ }
109
+ }
110
+ else {
111
+ spinner.stop("Could not auto-detect where to add the widget.");
90
112
  }
91
113
  }
92
- else {
93
- spinner.stop("Could not auto-detect where to add the widget.");
114
+ catch {
115
+ spinner.stop("Could not analyze your project.");
94
116
  }
95
117
  if (!inserted) {
96
118
  p.log.message(`Add this snippet to your root HTML layout, just before ${pc.cyan("</body>")}:\n\n` +
97
119
  pc.dim(` <script src="https://www.inflight.co/widget.js" data-workspace="${widgetId}" async></script>`));
98
- await p.text({
120
+ const waited = await p.text({
99
121
  message: "Press enter when you've added it",
100
122
  defaultValue: "",
101
123
  placeholder: "",
102
124
  });
125
+ if (p.isCancel(waited)) {
126
+ p.cancel("Cancelled.");
127
+ process.exit(0);
128
+ }
103
129
  if (!hasInflightWidget(cwd)) {
104
130
  const skip = await p.confirm({
105
131
  message: "Widget script not detected. Continue anyway?",
@@ -138,14 +164,14 @@ export async function setupCommand() {
138
164
  cwd: gitRoot,
139
165
  stdio: "pipe",
140
166
  });
141
- p.log.success("Changes committed.");
142
- const spinner = p.spinner();
143
- spinner.start("Pushing...");
144
167
  execSync("git push", { cwd: gitRoot, stdio: "pipe" });
145
- spinner.stop("Pushed. When sharing, pick a deployment that includes the script tag.");
168
+ p.log.success("Pushed. When sharing, pick a deployment that includes the script tag.");
146
169
  }
147
- catch {
148
- p.log.warn("Could not push. Push manually before sharing.");
170
+ catch (err) {
171
+ const detail = execSyncErrorDetail(err);
172
+ p.log.warn(detail
173
+ ? `Commit or push failed:\n${pc.dim(detail)}\n\nPush manually before sharing.`
174
+ : "Commit or push failed. Push manually before sharing.");
149
175
  }
150
176
  }
151
177
  else {
@@ -77,6 +77,10 @@ export async function shareCommand(opts = {}) {
77
77
  }
78
78
  writeWorkspaceConfig({ workspaceId });
79
79
  }
80
+ if (!workspaceId) {
81
+ p.log.error("No workspace configured. Run " + pc.cyan("inflight workspaces") + " or " + pc.cyan("inflight setup") + ".");
82
+ process.exit(1);
83
+ }
80
84
  // Resolve staging URL
81
85
  const providerChoice = await p.select({
82
86
  message: "Where is your staging URL hosted?",
@@ -118,7 +122,7 @@ export async function shareCommand(opts = {}) {
118
122
  if (!stagingUrl.startsWith("http")) {
119
123
  stagingUrl = `https://${stagingUrl}`;
120
124
  }
121
- const result = await apiCreateVersion({
125
+ await apiCreateVersion({
122
126
  apiKey: auth.apiKey,
123
127
  workspaceId,
124
128
  stagingUrl,
@@ -1,7 +1,7 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
3
  import { readVercelConfig, writeVercelConfig } from "../lib/config.js";
4
- import { ensureVercelCli, ensureVercelAuth, getVercelToken, getVercelTeams, getVercelProjects, getRecentDeployments, getBranchAliasUrl, } from "../lib/vercel.js";
4
+ import { ensureVercelCli, ensureVercelAuth, getVercelToken, getVercelTeams, getVercelProjects, getRecentDeployments, getBranchAlias, } from "../lib/vercel.js";
5
5
  import { pickVercelProject } from "../providers/vercel.js";
6
6
  // --- Action handlers ---
7
7
  async function vercelSetup(opts) {
@@ -116,9 +116,9 @@ async function branchUrl(opts) {
116
116
  teamId = teamId ?? config.teamId;
117
117
  projectId = projectId ?? config.projectId;
118
118
  }
119
- const url = await getBranchAliasUrl(token, teamId, projectId, opts.branch);
120
- if (url) {
121
- console.log(JSON.stringify({ url, branch: opts.branch }));
119
+ const alias = await getBranchAlias(token, teamId, projectId, opts.branch);
120
+ if (alias) {
121
+ console.log(JSON.stringify({ url: alias.url, state: alias.state, branch: opts.branch }));
122
122
  }
123
123
  else {
124
124
  console.log(JSON.stringify({ url: null, branch: opts.branch, message: "No deployment found for this branch." }));
package/dist/lib/git.d.ts CHANGED
@@ -20,6 +20,12 @@ export interface GitDiffResult {
20
20
  }
21
21
  export declare function getDefaultBranch(cwd: string): string;
22
22
  export declare function getRemoteUrl(cwd: string): string | null;
23
+ export interface GitRepo {
24
+ owner: string;
25
+ name: string;
26
+ provider: "github" | "gitlab" | "bitbucket" | "unknown";
27
+ }
28
+ export declare function parseGitRepo(remoteUrl: string): GitRepo | null;
23
29
  /**
24
30
  * Get a structured diff result for the share API, supporting multiple scope modes.
25
31
  */
@@ -34,3 +40,5 @@ export declare function parseDiffStat(diffStat: string): Array<{
34
40
  }>;
35
41
  export declare function getGitInfo(cwd: string): GitInfo;
36
42
  export declare function isGitRepo(cwd: string): boolean;
43
+ /** Returns the root directory of the git repo, or null if not in one. */
44
+ export declare function getGitRoot(cwd: string): string | null;
package/dist/lib/git.js CHANGED
@@ -85,6 +85,38 @@ export function getDefaultBranch(cwd) {
85
85
  export function getRemoteUrl(cwd) {
86
86
  return run("git remote get-url origin", cwd);
87
87
  }
88
+ export function parseGitRepo(remoteUrl) {
89
+ let host = null;
90
+ let path = null;
91
+ const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
92
+ if (sshMatch) {
93
+ host = sshMatch[1];
94
+ path = sshMatch[2];
95
+ }
96
+ if (!path) {
97
+ try {
98
+ const url = new URL(remoteUrl);
99
+ host = url.hostname;
100
+ path = url.pathname.replace(/^\//, "").replace(/\.git$/, "");
101
+ }
102
+ catch { }
103
+ }
104
+ if (!host || !path)
105
+ return null;
106
+ const parts = path.split("/");
107
+ if (parts.length < 2)
108
+ return null;
109
+ const owner = parts[0];
110
+ const name = parts[1];
111
+ let provider = "unknown";
112
+ if (host.includes("github"))
113
+ provider = "github";
114
+ else if (host.includes("gitlab"))
115
+ provider = "gitlab";
116
+ else if (host.includes("bitbucket"))
117
+ provider = "bitbucket";
118
+ return { owner, name, provider };
119
+ }
88
120
  /**
89
121
  * Get a structured diff result for the share API, supporting multiple scope modes.
90
122
  */
@@ -215,3 +247,7 @@ export function getGitInfo(cwd) {
215
247
  export function isGitRepo(cwd) {
216
248
  return run("git rev-parse --git-dir", cwd) !== null;
217
249
  }
250
+ /** Returns the root directory of the git repo, or null if not in one. */
251
+ export function getGitRoot(cwd) {
252
+ return run("git rev-parse --show-toplevel", cwd);
253
+ }
@@ -11,6 +11,19 @@ export declare function getVercelToken(): string | null;
11
11
  * For interactive commands only.
12
12
  */
13
13
  export declare function ensureVercelAuth(): Promise<string | null>;
14
+ interface LocalVercelProject {
15
+ orgId: string;
16
+ projectId: string;
17
+ }
18
+ /**
19
+ * Reads `.vercel/project.json` from the given root directory.
20
+ * Created by `vercel link` or `vercel deploy`.
21
+ */
22
+ export declare function readLocalVercelProject(root: string): LocalVercelProject | null;
23
+ /**
24
+ * Writes `.vercel/project.json` at the given root directory and ensures `.vercel` is gitignored.
25
+ */
26
+ export declare function writeLocalVercelProject(root: string, orgId: string, projectId: string): void;
14
27
  export interface VercelTeam {
15
28
  id: string;
16
29
  name: string;
@@ -22,6 +35,7 @@ export interface VercelProject {
22
35
  }
23
36
  export interface VercelDeployment {
24
37
  url: string;
38
+ state: string;
25
39
  branch: string | null;
26
40
  commitSha: string | null;
27
41
  commitMessage: string | null;
@@ -29,11 +43,45 @@ export interface VercelDeployment {
29
43
  }
30
44
  export declare function getVercelTeams(token: string): Promise<VercelTeam[]>;
31
45
  export declare function getVercelProjects(token: string, teamId: string): Promise<VercelProject[]>;
46
+ /**
47
+ * Fetches details for a single Vercel project by ID.
48
+ */
49
+ export declare function getVercelProjectDetail(token: string, projectId: string, teamId: string): Promise<VercelProject | null>;
50
+ export interface VercelProjectWithLink {
51
+ id: string;
52
+ name: string;
53
+ teamId: string;
54
+ teamName: string;
55
+ link?: {
56
+ org?: string;
57
+ repo?: string;
58
+ repoId?: number;
59
+ type?: string;
60
+ };
61
+ }
62
+ /**
63
+ * Fetches all projects across all teams with their link info.
64
+ * Used for client-side repo matching.
65
+ */
66
+ export declare function fetchAllProjectsWithLinks(token: string): Promise<VercelProjectWithLink[]>;
67
+ /**
68
+ * Matches Vercel projects against a git remote's owner/repo.
69
+ * Exact match on link.org and link.repo.
70
+ */
71
+ export declare function matchProjectsByRepo(projects: VercelProjectWithLink[], gitOwner: string, gitRepo: string): VercelProjectWithLink[];
72
+ /**
73
+ * Creates a new Vercel project linked to a git repository.
74
+ */
75
+ export declare function createVercelProject(token: string, teamId: string, name: string, repo: string, repoType: "github" | "gitlab" | "bitbucket"): Promise<VercelProject>;
32
76
  /**
33
77
  * Fetches the branch alias URL (stable, auto-updates with each push).
34
78
  * Returns null if no deployment exists for this branch.
35
79
  */
36
- export declare function getBranchAliasUrl(token: string, teamId: string, projectId: string, branch: string | null): Promise<string | null>;
80
+ export interface BranchAlias {
81
+ url: string;
82
+ state: string;
83
+ }
84
+ export declare function getBranchAlias(token: string, teamId: string, projectId: string, branch: string | null): Promise<BranchAlias | null>;
37
85
  /**
38
86
  * Fetches recent deployments for a project.
39
87
  */
@@ -41,3 +89,4 @@ export declare function getRecentDeployments(token: string, teamId: string, proj
41
89
  limit?: number;
42
90
  branch?: string;
43
91
  }): Promise<VercelDeployment[]>;
92
+ export {};
@@ -1,5 +1,5 @@
1
1
  import { execSync, spawn, exec } from "child_process";
2
- import { existsSync, readFileSync } from "fs";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir, platform } from "os";
5
5
  import { promisify } from "util";
@@ -105,6 +105,42 @@ export async function ensureVercelAuth() {
105
105
  const auth = readVercelAuth();
106
106
  return auth?.token ?? null;
107
107
  }
108
+ /**
109
+ * Reads `.vercel/project.json` from the given root directory.
110
+ * Created by `vercel link` or `vercel deploy`.
111
+ */
112
+ export function readLocalVercelProject(root) {
113
+ const projectPath = join(root, ".vercel", "project.json");
114
+ if (!existsSync(projectPath))
115
+ return null;
116
+ try {
117
+ const data = JSON.parse(readFileSync(projectPath, "utf-8"));
118
+ if (typeof data.orgId === "string" && typeof data.projectId === "string") {
119
+ return { orgId: data.orgId, projectId: data.projectId };
120
+ }
121
+ }
122
+ catch { }
123
+ return null;
124
+ }
125
+ /**
126
+ * Writes `.vercel/project.json` at the given root directory and ensures `.vercel` is gitignored.
127
+ */
128
+ export function writeLocalVercelProject(root, orgId, projectId) {
129
+ const vercelDir = join(root, ".vercel");
130
+ mkdirSync(vercelDir, { recursive: true });
131
+ writeFileSync(join(vercelDir, "project.json"), JSON.stringify({ orgId, projectId }, null, 2) + "\n");
132
+ // Ensure .vercel is in .gitignore
133
+ const gitignorePath = join(root, ".gitignore");
134
+ if (existsSync(gitignorePath)) {
135
+ const content = readFileSync(gitignorePath, "utf-8");
136
+ if (!content.split("\n").some((line) => line.trim() === ".vercel")) {
137
+ appendFileSync(gitignorePath, "\n.vercel\n");
138
+ }
139
+ }
140
+ else {
141
+ writeFileSync(gitignorePath, ".vercel\n");
142
+ }
143
+ }
108
144
  // --- API calls (all take token + IDs explicitly) ---
109
145
  export async function getVercelTeams(token) {
110
146
  const res = await fetch("https://api.vercel.com/v2/teams", {
@@ -126,17 +162,78 @@ export async function getVercelProjects(token, teamId) {
126
162
  return data.projects;
127
163
  }
128
164
  /**
129
- * Fetches the branch alias URL (stable, auto-updates with each push).
130
- * Returns null if no deployment exists for this branch.
165
+ * Fetches details for a single Vercel project by ID.
166
+ */
167
+ export async function getVercelProjectDetail(token, projectId, teamId) {
168
+ const params = new URLSearchParams({ teamId });
169
+ const res = await fetch(`https://api.vercel.com/v10/projects/${encodeURIComponent(projectId)}?${params}`, {
170
+ headers: { Authorization: `Bearer ${token}` },
171
+ });
172
+ if (!res.ok)
173
+ return null;
174
+ const data = (await res.json());
175
+ return { id: data.id, name: data.name };
176
+ }
177
+ /**
178
+ * Fetches all projects across all teams with their link info.
179
+ * Used for client-side repo matching.
180
+ */
181
+ export async function fetchAllProjectsWithLinks(token) {
182
+ const teams = await getVercelTeams(token);
183
+ const all = [];
184
+ const fetches = teams.map(async (team) => {
185
+ const params = new URLSearchParams({ teamId: team.id, limit: "100" });
186
+ const res = await fetch(`https://api.vercel.com/v10/projects?${params}`, {
187
+ headers: { Authorization: `Bearer ${token}` },
188
+ });
189
+ if (!res.ok)
190
+ return;
191
+ const data = (await res.json());
192
+ for (const p of data.projects) {
193
+ all.push({ ...p, teamId: team.id, teamName: team.name });
194
+ }
195
+ });
196
+ await Promise.all(fetches);
197
+ return all;
198
+ }
199
+ /**
200
+ * Matches Vercel projects against a git remote's owner/repo.
201
+ * Exact match on link.org and link.repo.
131
202
  */
132
- export async function getBranchAliasUrl(token, teamId, projectId, branch) {
203
+ export function matchProjectsByRepo(projects, gitOwner, gitRepo) {
204
+ const linked = projects.filter((p) => p.link?.org && p.link?.repo);
205
+ return linked.filter((p) => p.link?.org === gitOwner && p.link?.repo === gitRepo);
206
+ }
207
+ /**
208
+ * Creates a new Vercel project linked to a git repository.
209
+ */
210
+ export async function createVercelProject(token, teamId, name, repo, repoType) {
211
+ const params = new URLSearchParams({ teamId });
212
+ const res = await fetch(`https://api.vercel.com/v11/projects${params ? `?${params}` : ""}`, {
213
+ method: "POST",
214
+ headers: {
215
+ Authorization: `Bearer ${token}`,
216
+ "Content-Type": "application/json",
217
+ },
218
+ body: JSON.stringify({
219
+ name,
220
+ gitRepository: { type: repoType, repo },
221
+ }),
222
+ });
223
+ if (!res.ok) {
224
+ const body = (await res.json().catch(() => null));
225
+ throw new Error(body?.error?.message ?? `Failed to create project (${res.status})`);
226
+ }
227
+ const data = (await res.json());
228
+ return { id: data.id, name: data.name };
229
+ }
230
+ export async function getBranchAlias(token, teamId, projectId, branch) {
133
231
  if (!branch)
134
232
  return null;
135
233
  const params = new URLSearchParams({
136
234
  projectId,
137
235
  teamId,
138
236
  limit: "1",
139
- state: "READY",
140
237
  branch,
141
238
  });
142
239
  const res = await fetch(`https://api.vercel.com/v6/deployments?${params}`, {
@@ -156,7 +253,10 @@ export async function getBranchAliasUrl(token, teamId, projectId, branch) {
156
253
  if (!aliasRes.ok)
157
254
  return null;
158
255
  const aliasData = (await aliasRes.json());
159
- return aliasData.automaticAliases?.[0] ?? null;
256
+ const url = aliasData.automaticAliases?.[0];
257
+ if (!url)
258
+ return null;
259
+ return { url, state: deploy.state };
160
260
  }
161
261
  /**
162
262
  * Fetches recent deployments for a project.
@@ -166,7 +266,6 @@ export async function getRecentDeployments(token, teamId, projectId, opts) {
166
266
  projectId,
167
267
  teamId,
168
268
  limit: String(opts?.limit ?? 10),
169
- state: "READY",
170
269
  });
171
270
  if (opts?.branch) {
172
271
  params.set("branch", opts.branch);
@@ -179,6 +278,7 @@ export async function getRecentDeployments(token, teamId, projectId, opts) {
179
278
  const data = (await res.json());
180
279
  return data.deployments.map((d) => ({
181
280
  url: d.url,
281
+ state: d.state,
182
282
  branch: d.meta?.githubCommitRef ?? d.meta?.gitlabCommitRef ?? d.meta?.bitbucketCommitRef ?? null,
183
283
  commitSha: (d.meta?.githubCommitSha ?? d.meta?.gitlabCommitSha ?? d.meta?.bitbucketCommitSha ?? null)?.slice(0, 7) ??
184
284
  null,
@@ -1,8 +1,8 @@
1
1
  import type { GitInfo } from "../lib/git.js";
2
2
  import type { VercelConfig } from "../lib/config.js";
3
3
  /**
4
- * Interactive team/project picker. Saves selection to global config.
5
- * Reused by both `inflight vercel` and the share flow.
4
+ * Interactive project picker. Saves selection to global config.
5
+ * Used by `inflight vercel` command for explicit manual override.
6
6
  */
7
7
  export declare function pickVercelProject(token: string): Promise<VercelConfig | null>;
8
8
  export declare function resolveVercelUrl(cwd: string, gitInfo: GitInfo): Promise<string | null>;
@@ -1,83 +1,189 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
- import { readVercelConfig, writeVercelConfig } from "../lib/config.js";
4
- import { ensureVercelCli, ensureVercelAuth, getVercelTeams, getVercelProjects, getBranchAliasUrl, getRecentDeployments, } from "../lib/vercel.js";
3
+ import { parseGitRepo, getGitRoot } from "../lib/git.js";
4
+ import { writeVercelConfig } from "../lib/config.js";
5
+ import { ensureVercelCli, ensureVercelAuth, readLocalVercelProject, writeLocalVercelProject, getVercelProjectDetail, fetchAllProjectsWithLinks, matchProjectsByRepo, createVercelProject, getBranchAlias, getRecentDeployments, } from "../lib/vercel.js";
6
+ // --- Auto-detection ---
5
7
  /**
6
- * Interactive team/project picker. Saves selection to global config.
7
- * Reused by both `inflight vercel` and the share flow.
8
+ * Auto-detect the Vercel project for the current git repo.
9
+ * Returns null only if user cancels or no projects exist at all.
10
+ *
11
+ * 1. `.vercel/project.json` at git root (instant, no API)
12
+ * 2. Fetch all projects, exact match on git remote
13
+ * 3. Multiple matches → pick from matches
14
+ * 4. Zero matches → pick from all projects
8
15
  */
9
- export async function pickVercelProject(token) {
10
- const teams = await getVercelTeams(token);
11
- if (teams.length === 0) {
12
- p.log.error("No Vercel teams found.");
16
+ async function autoDetectProject(cwd, gitInfo, token) {
17
+ const gitRoot = getGitRoot(cwd);
18
+ // Cache result to .vercel/project.json so subsequent runs skip API calls
19
+ const cacheResult = (project) => {
20
+ if (gitRoot) {
21
+ writeLocalVercelProject(gitRoot, project.teamId, project.projectId);
22
+ }
23
+ return project;
24
+ };
25
+ // --- Fast path: .vercel/project.json ---
26
+ if (gitRoot) {
27
+ const localProject = readLocalVercelProject(gitRoot);
28
+ if (localProject) {
29
+ const detail = await getVercelProjectDetail(token, localProject.projectId, localProject.orgId);
30
+ if (detail) {
31
+ p.log.info(`Detected Vercel project: ${pc.bold(detail.name)}`);
32
+ return { teamId: localProject.orgId, projectId: detail.id, projectName: detail.name };
33
+ }
34
+ }
35
+ }
36
+ // --- Fetch all projects ---
37
+ const spinner = p.spinner();
38
+ spinner.start("Detecting Vercel project...");
39
+ let allProjects;
40
+ try {
41
+ allProjects = await fetchAllProjectsWithLinks(token);
42
+ }
43
+ catch {
44
+ spinner.stop("Could not fetch Vercel projects.");
13
45
  return null;
14
46
  }
47
+ if (allProjects.length === 0) {
48
+ spinner.stop("No Vercel projects found.");
49
+ return null;
50
+ }
51
+ // --- Try exact match on git remote ---
52
+ const gitRepo = gitInfo.remoteUrl ? parseGitRepo(gitInfo.remoteUrl) : null;
53
+ if (gitRepo) {
54
+ const matches = matchProjectsByRepo(allProjects, gitRepo.owner, gitRepo.name);
55
+ if (matches.length === 1) {
56
+ spinner.stop(`Detected Vercel project: ${pc.bold(matches[0].name)}`);
57
+ return cacheResult({
58
+ teamId: matches[0].teamId,
59
+ projectId: matches[0].id,
60
+ projectName: matches[0].name,
61
+ });
62
+ }
63
+ if (matches.length > 1) {
64
+ spinner.stop(`Found ${matches.length} Vercel projects for this repo.`);
65
+ const picked = await pickFromList(allProjects);
66
+ return picked ? cacheResult(picked) : null;
67
+ }
68
+ }
69
+ // --- No match ---
70
+ spinner.stop("Could not auto-detect Vercel project.");
71
+ const canCreate = gitRepo && gitRepo.provider !== "unknown";
72
+ const picked = await pickFromList(allProjects, canCreate ? { token, gitRepo } : undefined);
73
+ return picked ? cacheResult(picked) : null;
74
+ }
75
+ async function pickFromList(projects, createCtx) {
76
+ const maxName = Math.max(...projects.map((proj) => proj.name.length));
77
+ const selected = await p.select({
78
+ message: "Select a Vercel project",
79
+ options: [
80
+ ...projects.map((proj) => ({
81
+ value: proj,
82
+ label: proj.teamName ? `${proj.name.padEnd(maxName)} ${pc.dim(`(${proj.teamName})`)}` : proj.name,
83
+ })),
84
+ ...(createCtx
85
+ ? [{ value: "create", label: pc.dim("Create new Vercel project") }]
86
+ : []),
87
+ ],
88
+ });
89
+ if (p.isCancel(selected)) {
90
+ p.cancel("Cancelled.");
91
+ process.exit(0);
92
+ }
93
+ if (selected === "create" && createCtx) {
94
+ return createProjectFlow(projects, createCtx);
95
+ }
96
+ const match = selected;
97
+ return { teamId: match.teamId, projectId: match.id, projectName: match.name };
98
+ }
99
+ async function createProjectFlow(projects, ctx) {
100
+ const { token, gitRepo } = ctx;
101
+ // Pick team from unique teams
102
+ const teams = [...new Map(projects.map((proj) => [proj.teamId, proj.teamName])).entries()];
15
103
  let teamId;
16
- let teamName;
17
104
  if (teams.length === 1) {
18
- teamId = teams[0].id;
19
- teamName = teams[0].name;
105
+ teamId = teams[0][0];
20
106
  }
21
107
  else {
22
- const selected = await p.select({
23
- message: "Select a Vercel team",
24
- options: teams.map((t) => ({ value: t.id, label: t.name })),
108
+ const selectedTeam = await p.select({
109
+ message: "Which Vercel team?",
110
+ options: teams.map(([id, name]) => ({ value: id, label: name })),
25
111
  });
26
- if (p.isCancel(selected)) {
112
+ if (p.isCancel(selectedTeam)) {
27
113
  p.cancel("Cancelled.");
28
- return null;
114
+ process.exit(0);
29
115
  }
30
- teamId = selected;
31
- teamName = teams.find((t) => t.id === teamId)?.name ?? teamId;
116
+ teamId = selectedTeam;
117
+ }
118
+ const spinner = p.spinner();
119
+ spinner.start("Creating Vercel project...");
120
+ try {
121
+ const created = await createVercelProject(token, teamId, gitRepo.name, `${gitRepo.owner}/${gitRepo.name}`, gitRepo.provider);
122
+ spinner.stop(`Created ${pc.bold(created.name)}`);
123
+ return { teamId, projectId: created.id, projectName: created.name };
124
+ }
125
+ catch (e) {
126
+ spinner.stop(e.message);
127
+ return null;
32
128
  }
33
- const projects = await getVercelProjects(token, teamId);
34
- if (projects.length === 0) {
35
- p.log.error("No Vercel projects found for this team.");
129
+ }
130
+ // --- Manual picker (used by `inflight vercel` command) ---
131
+ /**
132
+ * Interactive project picker. Saves selection to global config.
133
+ * Used by `inflight vercel` command for explicit manual override.
134
+ */
135
+ export async function pickVercelProject(token) {
136
+ const allProjects = await fetchAllProjectsWithLinks(token);
137
+ if (allProjects.length === 0) {
138
+ p.log.error("No Vercel projects found.");
36
139
  return null;
37
140
  }
38
- const selectedProject = await p.select({
141
+ const selected = await p.select({
39
142
  message: "Select a Vercel project",
40
- options: projects.map((proj) => ({ value: proj.id, label: proj.name })),
143
+ options: allProjects.map((proj) => ({
144
+ value: proj,
145
+ label: proj.teamName ? `${proj.name} ${pc.dim(`(${proj.teamName})`)}` : proj.name,
146
+ })),
41
147
  });
42
- if (p.isCancel(selectedProject)) {
148
+ if (p.isCancel(selected)) {
43
149
  p.cancel("Cancelled.");
44
150
  return null;
45
151
  }
46
- const projectId = selectedProject;
47
- const projectName = projects.find((proj) => proj.id === projectId)?.name ?? projectId;
48
- const config = { teamId, teamName, projectId, projectName };
152
+ const match = selected;
153
+ const config = {
154
+ teamId: match.teamId,
155
+ teamName: match.teamName,
156
+ projectId: match.id,
157
+ projectName: match.name,
158
+ };
49
159
  writeVercelConfig(config);
50
160
  return config;
51
161
  }
162
+ // --- Main resolve function ---
52
163
  export async function resolveVercelUrl(cwd, gitInfo) {
53
- // Ensure Vercel CLI is installed (only logs if installing)
54
164
  const cliOk = await ensureVercelCli((msg) => p.log.step(msg));
55
165
  if (!cliOk) {
56
166
  p.log.error("Failed to install Vercel CLI. Install manually: " + pc.cyan("npm install -g vercel"));
57
167
  return null;
58
168
  }
59
- // Ensure auth
60
169
  const token = await ensureVercelAuth();
61
170
  if (!token) {
62
171
  p.log.error("Vercel login failed.");
63
172
  return null;
64
173
  }
65
- // Get or pick Vercel project
66
- let config = readVercelConfig();
67
- if (!config) {
68
- config = await pickVercelProject(token);
69
- if (!config)
70
- return null;
71
- }
174
+ const project = await autoDetectProject(cwd, gitInfo, token);
175
+ if (!project)
176
+ return null;
72
177
  // Fetch branch alias
73
- const branchAlias = await getBranchAliasUrl(token, config.teamId, config.projectId, gitInfo.branch);
178
+ const branchAlias = await getBranchAlias(token, project.teamId, project.projectId, gitInfo.branch);
74
179
  if (branchAlias) {
75
- p.log.info(`Branch preview (auto-updates with each push):\n ${pc.cyan(branchAlias)}`);
180
+ const stateLabel = branchAlias.state !== "READY" ? ` ${pc.yellow(`(${branchAlias.state.toLowerCase()})`)}` : "";
181
+ p.log.info(`Branch preview (auto-updates with each push):\n → ${pc.cyan(branchAlias.url)}${stateLabel}`);
76
182
  const choice = await p.select({
77
- message: "Use this URL, or pick a different deployment?",
183
+ message: "Use this URL, or pick a specific deployment?",
78
184
  options: [
79
185
  { value: "branch", label: "Use branch preview (recommended)" },
80
- { value: "recent", label: "Pick from recent deployments" },
186
+ { value: "recent", label: "Pick a specific deployment" },
81
187
  { value: "manual", label: "Paste a URL manually" },
82
188
  ],
83
189
  });
@@ -86,26 +192,27 @@ export async function resolveVercelUrl(cwd, gitInfo) {
86
192
  process.exit(0);
87
193
  }
88
194
  if (choice === "branch")
89
- return branchAlias;
195
+ return branchAlias.url;
90
196
  if (choice === "manual")
91
197
  return null;
92
198
  }
93
199
  // Show recent deployments
94
- const recent = await getRecentDeployments(token, config.teamId, config.projectId);
200
+ const recent = await getRecentDeployments(token, project.teamId, project.projectId);
95
201
  if (recent.length === 0) {
96
202
  p.log.warn("No deployments found. Paste a URL instead.");
97
203
  return null;
98
204
  }
99
205
  const maxBranch = Math.max(...recent.map((d) => (d.branch ?? "unknown").length));
100
206
  const selected = await p.select({
101
- message: "Select a deployment",
207
+ message: "Select a specific deployment",
102
208
  options: [
103
209
  ...recent.map((d) => {
104
210
  const branch = (d.branch ?? "unknown").padEnd(maxBranch);
105
211
  const ago = timeAgo(d.createdAt).padEnd(8);
212
+ const state = d.state !== "READY" ? ` ${pc.yellow(`(${d.state.toLowerCase()})`)}` : "";
106
213
  const firstLine = (d.commitMessage ?? "No commit message").split("\n")[0];
107
214
  const message = truncate(firstLine, 55);
108
- return { value: d.url, label: `${branch} ${ago} ${pc.dim(message)}` };
215
+ return { value: d.url, label: `${branch} ${ago}${state} ${pc.dim(message)}` };
109
216
  }),
110
217
  { value: "manual", label: "Paste a URL manually" },
111
218
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inflight-cli",
3
- "version": "2.0.9",
3
+ "version": "2.1.0",
4
4
  "description": "Get feedback directly on your staging URL",
5
5
  "bin": {
6
6
  "inflight": "dist/index.js",