inflight-cli 1.1.4 → 2.0.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.
@@ -1,68 +1,112 @@
1
- import { execSync, spawnSync, spawn } from "child_process";
1
+ import { execSync, spawn, exec } from "child_process";
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { homedir } from "os";
5
- /**
6
- * Returns the vercel command to use — the installed CLI if available,
7
- * otherwise falls back to npx so users don't need a global install.
8
- */
9
- function getVercelCmd() {
4
+ import { homedir, platform } from "os";
5
+ import { promisify } from "util";
6
+ const execAsync = promisify(exec);
7
+ // --- Vercel CLI management ---
8
+ /** Checks if the Vercel CLI is installed globally. */
9
+ function hasVercelCli() {
10
10
  try {
11
11
  execSync("vercel --version", { stdio: "pipe" });
12
- return { cmd: "vercel", args: [] };
12
+ return true;
13
13
  }
14
14
  catch {
15
- return { cmd: "npx", args: ["--yes", "vercel"] };
15
+ return false;
16
16
  }
17
17
  }
18
- /** Reads the auth token saved by the Vercel CLI after login. */
19
- function getVercelAuthToken() {
20
- const candidates = [
21
- join(homedir(), "Library", "Application Support", "com.vercel.cli", "auth.json"), // macOS
22
- join(homedir(), ".local", "share", "com.vercel.cli", "auth.json"), // Linux
23
- join(homedir(), ".config", "vercel", "auth.json"), // older versions
24
- ];
25
- for (const p of candidates) {
26
- if (existsSync(p)) {
27
- try {
28
- const data = JSON.parse(readFileSync(p, "utf-8"));
29
- return data.token ?? null;
30
- }
31
- catch {
32
- continue;
33
- }
34
- }
18
+ /** Ensures the Vercel CLI is available installs globally if missing. */
19
+ export async function ensureVercelCli(log) {
20
+ if (hasVercelCli())
21
+ return true;
22
+ log?.("Installing Vercel CLI...");
23
+ try {
24
+ await execAsync("npm install -g vercel");
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
35
29
  }
36
- return null;
37
30
  }
38
- export function isVercelLoggedIn() {
39
- return getVercelAuthToken() !== null;
31
+ /** Returns the Vercel CLI config directory for the current platform. */
32
+ function getVercelConfigDir() {
33
+ if (platform() === "darwin") {
34
+ return join(homedir(), "Library", "Application Support", "com.vercel.cli");
35
+ }
36
+ if (platform() === "win32") {
37
+ return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "com.vercel.cli");
38
+ }
39
+ return join(homedir(), ".local", "share", "com.vercel.cli");
40
40
  }
41
- export function isVercelLinked(cwd) {
42
- return existsSync(join(cwd, ".vercel", "project.json"));
41
+ /** Reads the Vercel CLI auth file. */
42
+ function readVercelAuth() {
43
+ const authPath = join(getVercelConfigDir(), "auth.json");
44
+ if (!existsSync(authPath))
45
+ return null;
46
+ try {
47
+ const data = JSON.parse(readFileSync(authPath, "utf-8"));
48
+ return data.token ? data : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
43
53
  }
44
- /** Opens browser-based Vercel login — suppresses CLI output, browser opens automatically. */
45
- export function vercelLogin() {
46
- const { cmd, args } = getVercelCmd();
47
- const result = spawnSync(cmd, [...args, "login"], { stdio: "pipe" });
48
- return result.status === 0;
54
+ /**
55
+ * Gets a valid Vercel token. Refreshes silently via `vercel whoami` if expired.
56
+ * Does NOT prompt for login — returns null if no valid token available.
57
+ */
58
+ export function getVercelToken() {
59
+ const auth = readVercelAuth();
60
+ if (!auth)
61
+ return null;
62
+ // Token still valid
63
+ if (auth.expiresAt > Date.now() / 1000) {
64
+ return auth.token;
65
+ }
66
+ // Token expired — try silent refresh
67
+ try {
68
+ execSync("vercel whoami", { stdio: "pipe" });
69
+ const refreshed = readVercelAuth();
70
+ if (refreshed && refreshed.expiresAt > Date.now() / 1000) {
71
+ return refreshed.token;
72
+ }
73
+ }
74
+ catch {
75
+ // Refresh failed
76
+ }
77
+ return null;
49
78
  }
50
- /** Links the directory to a specific Vercel project silently. */
51
- export function vercelLink(cwd, projectId) {
52
- const { cmd, args } = getVercelCmd();
53
- return new Promise((resolve) => {
54
- const child = spawn(cmd, [...args, "link", "--yes", "--project", projectId], {
55
- stdio: "pipe",
56
- cwd,
79
+ /**
80
+ * Ensures a valid Vercel auth token is available.
81
+ * Checks existing token refreshes if expired → opens browser login if needed.
82
+ * For interactive commands only.
83
+ */
84
+ export async function ensureVercelAuth() {
85
+ // Try existing/refreshed token first
86
+ const existing = getVercelToken();
87
+ if (existing)
88
+ return existing;
89
+ // Need browser login — capture output to suppress Vercel's default messages
90
+ const ok = await new Promise((resolve) => {
91
+ const child = spawn("vercel", ["login"], { stdio: "pipe" });
92
+ child.stdout?.on("data", (data) => {
93
+ const text = data.toString();
94
+ // Only show the verification URL
95
+ const urlMatch = text.match(/(https:\/\/vercel\.com\/oauth\/device\S*)/);
96
+ if (urlMatch) {
97
+ process.stdout.write(` Visit ${urlMatch[1]}\n`);
98
+ }
57
99
  });
58
100
  child.on("close", (code) => resolve(code === 0));
59
101
  child.on("error", () => resolve(false));
60
102
  });
103
+ if (!ok)
104
+ return null;
105
+ const auth = readVercelAuth();
106
+ return auth?.token ?? null;
61
107
  }
62
- export async function getVercelTeams() {
63
- const token = getVercelAuthToken();
64
- if (!token)
65
- return [];
108
+ // --- API calls (all take token + IDs explicitly) ---
109
+ export async function getVercelTeams(token) {
66
110
  const res = await fetch("https://api.vercel.com/v2/teams", {
67
111
  headers: { Authorization: `Bearer ${token}` },
68
112
  });
@@ -71,10 +115,7 @@ export async function getVercelTeams() {
71
115
  const data = (await res.json());
72
116
  return data.teams;
73
117
  }
74
- export async function getVercelProjects(teamId) {
75
- const token = getVercelAuthToken();
76
- if (!token)
77
- return [];
118
+ export async function getVercelProjects(token, teamId) {
78
119
  const params = new URLSearchParams({ teamId, limit: "100" });
79
120
  const res = await fetch(`https://api.vercel.com/v10/projects?${params}`, {
80
121
  headers: { Authorization: `Bearer ${token}` },
@@ -84,32 +125,16 @@ export async function getVercelProjects(teamId) {
84
125
  const data = (await res.json());
85
126
  return data.projects;
86
127
  }
87
- /** Reads the project + team IDs from `.vercel/project.json`. */
88
- function getVercelProjectConfig(cwd) {
89
- const configPath = join(cwd, ".vercel", "project.json");
90
- if (!existsSync(configPath))
91
- return null;
92
- try {
93
- return JSON.parse(readFileSync(configPath, "utf-8"));
94
- }
95
- catch {
96
- return null;
97
- }
98
- }
99
128
  /**
100
129
  * Fetches the branch alias URL (stable, auto-updates with each push).
101
130
  * Returns null if no deployment exists for this branch.
102
131
  */
103
- export async function getBranchAliasUrl(cwd, branch) {
132
+ export async function getBranchAliasUrl(token, teamId, projectId, branch) {
104
133
  if (!branch)
105
134
  return null;
106
- const token = getVercelAuthToken();
107
- const project = getVercelProjectConfig(cwd);
108
- if (!token || !project)
109
- return null;
110
135
  const params = new URLSearchParams({
111
- projectId: project.projectId,
112
- teamId: project.orgId,
136
+ projectId,
137
+ teamId,
113
138
  limit: "1",
114
139
  state: "READY",
115
140
  branch,
@@ -124,30 +149,28 @@ export async function getBranchAliasUrl(cwd, branch) {
124
149
  if (!deploy)
125
150
  return null;
126
151
  // Fetch the automatic alias for this deployment
127
- const aliasParams = new URLSearchParams({ teamId: project.orgId });
152
+ const aliasParams = new URLSearchParams({ teamId });
128
153
  const aliasRes = await fetch(`https://api.vercel.com/v13/deployments/${deploy.uid}?${aliasParams}`, {
129
154
  headers: { Authorization: `Bearer ${token}` },
130
155
  });
131
156
  if (!aliasRes.ok)
132
157
  return null;
133
158
  const aliasData = (await aliasRes.json());
134
- const alias = aliasData.automaticAliases?.[0];
135
- return alias ?? null;
159
+ return aliasData.automaticAliases?.[0] ?? null;
136
160
  }
137
161
  /**
138
- * Fetches recent deployments for the project.
162
+ * Fetches recent deployments for a project.
139
163
  */
140
- export async function getRecentDeployments(cwd, limit = 10) {
141
- const token = getVercelAuthToken();
142
- const project = getVercelProjectConfig(cwd);
143
- if (!token || !project)
144
- return [];
164
+ export async function getRecentDeployments(token, teamId, projectId, opts) {
145
165
  const params = new URLSearchParams({
146
- projectId: project.projectId,
147
- teamId: project.orgId,
148
- limit: String(limit),
166
+ projectId,
167
+ teamId,
168
+ limit: String(opts?.limit ?? 10),
149
169
  state: "READY",
150
170
  });
171
+ if (opts?.branch) {
172
+ params.set("branch", opts.branch);
173
+ }
151
174
  const res = await fetch(`https://api.vercel.com/v6/deployments?${params}`, {
152
175
  headers: { Authorization: `Bearer ${token}` },
153
176
  });
@@ -157,7 +180,8 @@ export async function getRecentDeployments(cwd, limit = 10) {
157
180
  return data.deployments.map((d) => ({
158
181
  url: d.url,
159
182
  branch: d.meta?.githubCommitRef ?? d.meta?.gitlabCommitRef ?? d.meta?.bitbucketCommitRef ?? null,
160
- commitSha: (d.meta?.githubCommitSha ?? d.meta?.gitlabCommitSha ?? d.meta?.bitbucketCommitSha ?? null)?.slice(0, 7) ?? null,
183
+ commitSha: (d.meta?.githubCommitSha ?? d.meta?.gitlabCommitSha ?? d.meta?.bitbucketCommitSha ?? null)?.slice(0, 7) ??
184
+ null,
161
185
  commitMessage: d.meta?.githubCommitMessage ?? d.meta?.gitlabCommitMessage ?? d.meta?.bitbucketCommitMessage ?? null,
162
186
  createdAt: d.created,
163
187
  }));
@@ -1,2 +1,8 @@
1
1
  import type { GitInfo } from "../lib/git.js";
2
+ import type { VercelConfig } from "../lib/config.js";
3
+ /**
4
+ * Interactive team/project picker. Saves selection to global config.
5
+ * Reused by both `inflight vercel` and the share flow.
6
+ */
7
+ export declare function pickVercelProject(token: string): Promise<VercelConfig | null>;
2
8
  export declare function resolveVercelUrl(cwd: string, gitInfo: GitInfo): Promise<string | null>;
@@ -1,64 +1,76 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
- import { isVercelLoggedIn, isVercelLinked, vercelLogin, vercelLink, getVercelTeams, getVercelProjects, getBranchAliasUrl, getRecentDeployments, } from "../lib/vercel.js";
4
- export async function resolveVercelUrl(cwd, gitInfo) {
5
- const spinner = p.spinner();
6
- // Ensure logged in
7
- const loggedIn = isVercelLoggedIn();
8
- if (!loggedIn) {
9
- p.log.info("Opening browser to log in to Vercel...");
10
- const ok = vercelLogin();
11
- if (!ok) {
12
- p.log.error("Vercel login failed.");
13
- process.exit(1);
14
- }
3
+ import { readVercelConfig, writeVercelConfig } from "../lib/config.js";
4
+ import { ensureVercelCli, ensureVercelAuth, getVercelTeams, getVercelProjects, getBranchAliasUrl, getRecentDeployments, } from "../lib/vercel.js";
5
+ /**
6
+ * Interactive team/project picker. Saves selection to global config.
7
+ * Reused by both `inflight vercel` and the share flow.
8
+ */
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.");
13
+ return null;
15
14
  }
16
- // Ensure project is linked
17
- const linked = isVercelLinked(cwd);
18
- if (!linked) {
19
- const teams = await getVercelTeams();
20
- if (teams.length === 0) {
21
- p.log.error("No Vercel teams found.");
22
- process.exit(1);
23
- }
24
- let teamId;
25
- if (teams.length === 1) {
26
- teamId = teams[0].id;
27
- }
28
- else {
29
- const selectedTeam = await p.select({
30
- message: "Select a Vercel team",
31
- options: teams.map((t) => ({ value: t.id, label: t.name })),
32
- });
33
- if (p.isCancel(selectedTeam)) {
34
- p.cancel("Cancelled.");
35
- process.exit(0);
36
- }
37
- teamId = selectedTeam;
38
- }
39
- const projects = await getVercelProjects(teamId);
40
- if (projects.length === 0) {
41
- p.log.error("No Vercel projects found.");
42
- process.exit(1);
43
- }
44
- const selectedProject = await p.select({
45
- message: "Select a Vercel project",
46
- options: projects.map((proj) => ({ value: proj.id, label: proj.name })),
15
+ let teamId;
16
+ let teamName;
17
+ if (teams.length === 1) {
18
+ teamId = teams[0].id;
19
+ teamName = teams[0].name;
20
+ }
21
+ else {
22
+ const selected = await p.select({
23
+ message: "Select a Vercel team",
24
+ options: teams.map((t) => ({ value: t.id, label: t.name })),
47
25
  });
48
- if (p.isCancel(selectedProject)) {
26
+ if (p.isCancel(selected)) {
49
27
  p.cancel("Cancelled.");
50
- process.exit(0);
51
- }
52
- spinner.start("Linking to Vercel project...");
53
- const ok = await vercelLink(cwd, selectedProject);
54
- spinner.stop(ok ? "Linked to Vercel project" : "");
55
- if (!ok) {
56
- p.log.error("Vercel link failed.");
57
- process.exit(1);
28
+ return null;
58
29
  }
30
+ teamId = selected;
31
+ teamName = teams.find((t) => t.id === teamId)?.name ?? teamId;
32
+ }
33
+ const projects = await getVercelProjects(token, teamId);
34
+ if (projects.length === 0) {
35
+ p.log.error("No Vercel projects found for this team.");
36
+ return null;
37
+ }
38
+ const selectedProject = await p.select({
39
+ message: "Select a Vercel project",
40
+ options: projects.map((proj) => ({ value: proj.id, label: proj.name })),
41
+ });
42
+ if (p.isCancel(selectedProject)) {
43
+ p.cancel("Cancelled.");
44
+ return null;
45
+ }
46
+ const projectId = selectedProject;
47
+ const projectName = projects.find((proj) => proj.id === projectId)?.name ?? projectId;
48
+ const config = { teamId, teamName, projectId, projectName };
49
+ writeVercelConfig(config);
50
+ return config;
51
+ }
52
+ export async function resolveVercelUrl(cwd, gitInfo) {
53
+ // Ensure Vercel CLI is installed (only logs if installing)
54
+ const cliOk = await ensureVercelCli((msg) => p.log.step(msg));
55
+ if (!cliOk) {
56
+ p.log.error("Failed to install Vercel CLI. Install manually: " + pc.cyan("npm install -g vercel"));
57
+ return null;
58
+ }
59
+ // Ensure auth
60
+ const token = await ensureVercelAuth();
61
+ if (!token) {
62
+ p.log.error("Vercel login failed.");
63
+ return null;
64
+ }
65
+ // Get or pick Vercel project
66
+ let config = readVercelConfig();
67
+ if (!config) {
68
+ config = await pickVercelProject(token);
69
+ if (!config)
70
+ return null;
59
71
  }
60
72
  // Fetch branch alias
61
- const branchAlias = await getBranchAliasUrl(cwd, gitInfo.branch);
73
+ const branchAlias = await getBranchAliasUrl(token, config.teamId, config.projectId, gitInfo.branch);
62
74
  if (branchAlias) {
63
75
  p.log.info(`Branch preview (auto-updates with each push):\n → ${pc.cyan(branchAlias)}`);
64
76
  const choice = await p.select({
@@ -78,8 +90,8 @@ export async function resolveVercelUrl(cwd, gitInfo) {
78
90
  if (choice === "manual")
79
91
  return null;
80
92
  }
81
- // Show recent deployments (either user chose "recent" or no branch alias found)
82
- const recent = await getRecentDeployments(cwd);
93
+ // Show recent deployments
94
+ const recent = await getRecentDeployments(token, config.teamId, config.projectId);
83
95
  if (recent.length === 0) {
84
96
  p.log.warn("No deployments found. Paste a URL instead.");
85
97
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inflight-cli",
3
- "version": "1.1.4",
3
+ "version": "2.0.0",
4
4
  "description": "Get feedback directly on your staging URL",
5
5
  "bin": {
6
6
  "inflight": "dist/index.js"
@@ -14,7 +14,8 @@
14
14
  "homepage": "https://www.inflight.co",
15
15
  "scripts": {
16
16
  "build": "tsc && chmod +x dist/index.js",
17
- "dev": "tsx src/index.ts"
17
+ "dev": "tsx src/index.ts",
18
+ "prepublishOnly": "npm run build"
18
19
  },
19
20
  "dependencies": {
20
21
  "@clack/prompts": "^0.8.2",