inflight-cli 2.1.4 → 2.1.6

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,24 +1,178 @@
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 } 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
7
  import { apiGetMe, apiCreateVersion } from "../lib/api.js";
8
+ /**
9
+ * Checks local git state and prompts the user to commit/push if needed.
10
+ * Returns { justPushed: true } if changes were pushed, { justPushed: false } otherwise.
11
+ * Provider-agnostic — works for Vercel, Netlify, or any future provider.
12
+ */
13
+ async function checkAndSyncGit(cwd) {
14
+ const state = getGitSyncState(cwd);
15
+ if (state.status === "clean" || state.status === "detached") {
16
+ return { justPushed: false };
17
+ }
18
+ // After ruling out clean/detached, branch is guaranteed non-null
19
+ if (!state.branch)
20
+ return { justPushed: false };
21
+ const { branch } = state;
22
+ if (state.status === "uncommitted") {
23
+ // Show changed files
24
+ const maxFiles = 8;
25
+ const display = state.changedFiles.slice(0, maxFiles);
26
+ const lines = display.map((line) => {
27
+ const trimmed = line.trim();
28
+ // Parse git porcelain status: "M file.ts", "?? file.ts", "A file.ts", etc.
29
+ const match = trimmed.match(/^(\S+)\s+(.+)$/);
30
+ if (!match)
31
+ return ` ${pc.dim(trimmed)}`;
32
+ const status = match[1];
33
+ const file = match[2];
34
+ const label = status === "??"
35
+ ? "new"
36
+ : status === "M"
37
+ ? "modified"
38
+ : status === "A"
39
+ ? "added"
40
+ : status === "D"
41
+ ? "deleted"
42
+ : status === "R"
43
+ ? "renamed"
44
+ : "changed";
45
+ return ` ${pc.dim(file)} ${pc.dim(`(${label})`)}`;
46
+ });
47
+ if (state.changedFiles.length > maxFiles) {
48
+ lines.push(` ${pc.dim(`... and ${state.changedFiles.length - maxFiles} more`)}`);
49
+ }
50
+ lines.push("", pc.yellow("Your deployment won't include these changes."));
51
+ p.log.warn("You have uncommitted changes:\n" + lines.join("\n"));
52
+ const action = await p.select({
53
+ message: "What would you like to do?",
54
+ options: [
55
+ { value: "commit_push", label: "Commit & push (recommended)" },
56
+ { value: "continue", label: "Continue anyway — use existing deployment" },
57
+ ],
58
+ });
59
+ if (p.isCancel(action)) {
60
+ p.cancel("Cancelled.");
61
+ process.exit(0);
62
+ }
63
+ if (action === "continue") {
64
+ return { justPushed: false };
65
+ }
66
+ // Commit & push flow
67
+ const defaultMessage = generateCommitMessage(state.branch);
68
+ const message = await p.text({
69
+ message: "Commit message",
70
+ initialValue: defaultMessage,
71
+ validate: (v) => {
72
+ if (!v.trim())
73
+ return "Commit message is required";
74
+ },
75
+ });
76
+ if (p.isCancel(message)) {
77
+ p.cancel("Cancelled.");
78
+ process.exit(0);
79
+ }
80
+ const spinner = p.spinner();
81
+ spinner.start("Committing and pushing...");
82
+ try {
83
+ commitAndPush(cwd, message.trim(), branch);
84
+ spinner.stop("Changes pushed!");
85
+ return { justPushed: true };
86
+ }
87
+ catch (e) {
88
+ spinner.stop(`Push failed: ${e.message}`);
89
+ p.log.warn("Continuing with existing deployment.");
90
+ return { justPushed: false };
91
+ }
92
+ }
93
+ if (state.status === "unpushed") {
94
+ // Show unpushed commits
95
+ const commitLines = state.unpushedCommits.slice(0, 5).map((line) => ` ${pc.dim(line)}`);
96
+ if (state.unpushedCommits.length > 5) {
97
+ commitLines.push(` ${pc.dim(`... and ${state.unpushedCommits.length - 5} more`)}`);
98
+ }
99
+ commitLines.push("", pc.yellow("Your deployment won't include these commits."));
100
+ p.log.warn("You have unpushed commits:\n" + commitLines.join("\n"));
101
+ const action = await p.select({
102
+ message: "What would you like to do?",
103
+ options: [
104
+ { value: "push", label: "Push (recommended)" },
105
+ { value: "continue", label: "Continue anyway — use existing deployment" },
106
+ ],
107
+ });
108
+ if (p.isCancel(action)) {
109
+ p.cancel("Cancelled.");
110
+ process.exit(0);
111
+ }
112
+ if (action === "continue") {
113
+ return { justPushed: false };
114
+ }
115
+ const spinner = p.spinner();
116
+ spinner.start("Pushing...");
117
+ try {
118
+ pushBranch(cwd, branch);
119
+ spinner.stop("Changes pushed!");
120
+ return { justPushed: true };
121
+ }
122
+ catch (e) {
123
+ spinner.stop(`Push failed: ${e.message}`);
124
+ p.log.warn("Continuing with existing deployment.");
125
+ return { justPushed: false };
126
+ }
127
+ }
128
+ if (state.status === "no_remote") {
129
+ // Working tree is clean (uncommitted was checked first in getGitSyncState).
130
+ // Check if this branch actually has commits ahead of the default branch —
131
+ // if not, pushing won't trigger a deployment.
132
+ if (!hasCommitsAhead(cwd)) {
133
+ p.log.info(`Branch ${pc.bold(branch)} has no new commits — using existing deployments.`);
134
+ return { justPushed: false };
135
+ }
136
+ p.log.warn(`Branch ${pc.bold(branch)} hasn't been pushed yet — no deployment exists.`);
137
+ const confirm = await p.confirm({
138
+ message: "Push to create a deployment?",
139
+ });
140
+ if (p.isCancel(confirm)) {
141
+ p.cancel("Cancelled.");
142
+ process.exit(0);
143
+ }
144
+ if (!confirm) {
145
+ return { justPushed: false };
146
+ }
147
+ const spinner = p.spinner();
148
+ spinner.start("Pushing...");
149
+ try {
150
+ pushBranch(cwd, branch);
151
+ spinner.stop("Branch pushed!");
152
+ return { justPushed: true };
153
+ }
154
+ catch (e) {
155
+ spinner.stop(`Push failed: ${e.message}`);
156
+ return { justPushed: false };
157
+ }
158
+ }
159
+ return { justPushed: false };
160
+ }
8
161
  export async function shareCommand(opts = {}) {
9
162
  const cwd = process.cwd();
163
+ // TODO: Add a step to login if not authenticated
10
164
  // ── Step 1: Auth ──
11
165
  const auth = readGlobalAuth();
12
166
  if (!auth) {
13
167
  if (opts.json) {
14
- console.log(JSON.stringify({ error: "not_authenticated", message: "Not logged in. Run inflight login first." }));
168
+ console.log(JSON.stringify({ error: "not_authenticated", message: "Not logged in. Run inflight setup first." }));
15
169
  }
16
170
  else {
17
- p.log.error("Not logged in. Run " + pc.cyan("inflight login") + " first.");
171
+ p.log.error("Not logged in. Run " + pc.cyan("inflight setup") + " first.");
18
172
  }
19
173
  process.exit(1);
20
174
  }
21
- const gitInfo = getGitInfo(cwd);
175
+ let gitInfo = getGitInfo(cwd);
22
176
  // ── Step 2: Resolve workspace ──
23
177
  const me = await apiGetMe(auth.apiKey).catch((e) => {
24
178
  if (opts.json) {
@@ -118,7 +272,12 @@ export async function shareCommand(opts = {}) {
118
272
  let stagingUrl;
119
273
  const provider = providers.find((prov) => prov.id === providerChoice);
120
274
  if (provider) {
121
- stagingUrl = (await provider.resolve(cwd, gitInfo)) ?? undefined;
275
+ const { justPushed } = await checkAndSyncGit(cwd);
276
+ // Re-fetch git info after potential commit/push so provider AND apiCreateVersion see latest state
277
+ if (justPushed) {
278
+ gitInfo = getGitInfo(cwd);
279
+ }
280
+ stagingUrl = (await provider.resolve(cwd, gitInfo, { justPushed })) ?? undefined;
122
281
  }
123
282
  if (!stagingUrl) {
124
283
  const input = await p.text({
package/dist/lib/git.d.ts CHANGED
@@ -42,3 +42,41 @@ export declare function getGitInfo(cwd: string): GitInfo;
42
42
  export declare function isGitRepo(cwd: string): boolean;
43
43
  /** Returns the root directory of the git repo, or null if not in one. */
44
44
  export declare function getGitRoot(cwd: string): string | null;
45
+ export interface GitSyncState {
46
+ /** "clean" = nothing to do, "uncommitted" = dirty working tree, "unpushed" = clean but commits not pushed, "no_remote" = branch has never been pushed */
47
+ status: "clean" | "uncommitted" | "unpushed" | "no_remote" | "detached";
48
+ /** Short file status lines from `git status --porcelain` (only when uncommitted) */
49
+ changedFiles: string[];
50
+ /** Unpushed commit summary lines: "abc1234 commit message" (only when unpushed) */
51
+ unpushedCommits: string[];
52
+ /** Current branch name (null if detached) */
53
+ branch: string | null;
54
+ }
55
+ /**
56
+ * Determines whether the local repo has changes that haven't been deployed.
57
+ * Priority: detached > uncommitted > unpushed > no_remote > clean
58
+ */
59
+ export declare function getGitSyncState(cwd: string): GitSyncState;
60
+ /**
61
+ * Auto-generates a short commit message from the branch name.
62
+ * "feature/new-login-page" → "Update new login page"
63
+ * "fix-header-alignment" → "Update header alignment"
64
+ * "main" → "Update main"
65
+ */
66
+ export declare function generateCommitMessage(branch: string | null): string;
67
+ /**
68
+ * Stages all changes, commits with the given message, and pushes.
69
+ * Uses `git add -A` (tracked + untracked — .gitignore handles exclusions).
70
+ * Pushes with `-u` if the branch has no upstream.
71
+ * Throws on failure.
72
+ */
73
+ export declare function commitAndPush(cwd: string, message: string, branch: string): void;
74
+ /**
75
+ * Pushes existing commits. Uses `-u` if no upstream exists.
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;
82
+ export declare function pushBranch(cwd: string, branch: string): void;
package/dist/lib/git.js CHANGED
@@ -251,3 +251,85 @@ export function isGitRepo(cwd) {
251
251
  export function getGitRoot(cwd) {
252
252
  return run("git rev-parse --show-toplevel", cwd);
253
253
  }
254
+ /**
255
+ * Determines whether the local repo has changes that haven't been deployed.
256
+ * Priority: detached > uncommitted > unpushed > no_remote > clean
257
+ */
258
+ export function getGitSyncState(cwd) {
259
+ const branch = run("git rev-parse --abbrev-ref HEAD", cwd);
260
+ // Detached HEAD — can't push meaningfully
261
+ if (!branch || branch === "HEAD") {
262
+ return { status: "detached", changedFiles: [], unpushedCommits: [], branch: null };
263
+ }
264
+ // No origin remote — can't push anywhere
265
+ if (!run("git remote get-url origin", cwd)) {
266
+ return { status: "clean", changedFiles: [], unpushedCommits: [], branch };
267
+ }
268
+ // Check for uncommitted changes (staged + unstaged + untracked)
269
+ const porcelain = run("git status --porcelain", cwd) ?? "";
270
+ const changedFiles = porcelain.split("\n").filter((l) => l.trim().length > 0);
271
+ if (changedFiles.length > 0) {
272
+ return { status: "uncommitted", changedFiles, unpushedCommits: [], branch };
273
+ }
274
+ // Check if branch has a remote tracking branch
275
+ const upstream = run(`git rev-parse --abbrev-ref ${branch}@{upstream}`, cwd);
276
+ if (!upstream) {
277
+ return { status: "no_remote", changedFiles: [], unpushedCommits: [], branch };
278
+ }
279
+ // Check for unpushed commits
280
+ const log = run(`git log ${upstream}..HEAD --oneline`, cwd) ?? "";
281
+ if (log.length > 0) {
282
+ const unpushedCommits = log.split("\n").filter((l) => l.trim().length > 0);
283
+ return { status: "unpushed", changedFiles: [], unpushedCommits, branch };
284
+ }
285
+ return { status: "clean", changedFiles: [], unpushedCommits: [], branch };
286
+ }
287
+ /**
288
+ * Auto-generates a short commit message from the branch name.
289
+ * "feature/new-login-page" → "Update new login page"
290
+ * "fix-header-alignment" → "Update header alignment"
291
+ * "main" → "Update main"
292
+ */
293
+ export function generateCommitMessage(branch) {
294
+ if (!branch)
295
+ return "Update changes";
296
+ // Strip common prefixes: feature/, fix/, feat/, chore/, etc.
297
+ const stripped = branch.replace(/^(feature|fix|feat|chore|bugfix|hotfix|release)\/?/i, "");
298
+ const humanized = (stripped || branch).replace(/[-_/]/g, " ").replace(/\s+/g, " ").trim();
299
+ return humanized ? `Update ${humanized}` : "Update changes";
300
+ }
301
+ /**
302
+ * Stages all changes, commits with the given message, and pushes.
303
+ * Uses `git add -A` (tracked + untracked — .gitignore handles exclusions).
304
+ * Pushes with `-u` if the branch has no upstream.
305
+ * Throws on failure.
306
+ */
307
+ export function commitAndPush(cwd, message, branch) {
308
+ // Stage all changes (tracked + untracked — .gitignore handles exclusions)
309
+ execFileSync("git", ["add", "-A"], { cwd, stdio: "pipe" });
310
+ // Commit — use execFileSync to avoid shell injection via commit message
311
+ execFileSync("git", ["commit", "-m", message], { cwd, stdio: "pipe" });
312
+ // Push
313
+ pushBranch(cwd, branch);
314
+ }
315
+ /**
316
+ * Pushes existing commits. Uses `-u` if no upstream exists.
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
+ }
327
+ export function pushBranch(cwd, branch) {
328
+ const upstream = run(`git rev-parse --abbrev-ref ${branch}@{upstream}`, cwd);
329
+ if (upstream) {
330
+ execFileSync("git", ["push"], { cwd, stdio: "pipe" });
331
+ }
332
+ else {
333
+ execFileSync("git", ["push", "-u", "origin", branch], { cwd, stdio: "pipe" });
334
+ }
335
+ }
@@ -1,7 +1,11 @@
1
1
  import type { GitInfo } from "../lib/git.js";
2
+ export interface ProviderResolveOptions {
3
+ /** True if the user just committed/pushed during this session — provider should poll for new deployment */
4
+ justPushed?: boolean;
5
+ }
2
6
  export interface Provider {
3
7
  id: string;
4
8
  label: string;
5
- resolve: (cwd: string, gitInfo: GitInfo) => Promise<string | null>;
9
+ resolve: (cwd: string, gitInfo: GitInfo, opts?: ProviderResolveOptions) => Promise<string | null>;
6
10
  }
7
11
  export declare const providers: Provider[];
@@ -1,8 +1,9 @@
1
1
  import type { GitInfo } from "../lib/git.js";
2
+ import type { ProviderResolveOptions } from "./index.js";
2
3
  import type { VercelConfig } from "../lib/config.js";
3
4
  /**
4
5
  * Interactive project picker. Saves selection to global config.
5
6
  * Used by `inflight vercel` command for explicit manual override.
6
7
  */
7
8
  export declare function pickVercelProject(token: string): Promise<VercelConfig | null>;
8
- export declare function resolveVercelUrl(cwd: string, gitInfo: GitInfo): Promise<string | null>;
9
+ export declare function resolveVercelUrl(cwd: string, gitInfo: GitInfo, opts?: ProviderResolveOptions): Promise<string | null>;
@@ -182,7 +182,7 @@ export async function pickVercelProject(token) {
182
182
  return config;
183
183
  }
184
184
  // --- Main resolve function ---
185
- export async function resolveVercelUrl(cwd, gitInfo) {
185
+ export async function resolveVercelUrl(cwd, gitInfo, opts) {
186
186
  const cliOk = await ensureVercelCli((msg) => p.log.step(msg));
187
187
  if (!cliOk) {
188
188
  p.log.error("Failed to install Vercel CLI. Install manually: " + pc.cyan("npm install -g vercel"));
@@ -196,8 +196,24 @@ export async function resolveVercelUrl(cwd, gitInfo) {
196
196
  const project = await autoDetectProject(cwd, gitInfo, token);
197
197
  if (!project)
198
198
  return null;
199
- // Fetch branch alias
200
- const branchAlias = await getBranchAlias(token, project.teamId, project.projectId, gitInfo.branch);
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) {
202
+ const pollSpinner = p.spinner();
203
+ pollSpinner.start("Waiting for Vercel deployment...");
204
+ for (let i = 0; i < 30; i++) {
205
+ await new Promise((r) => setTimeout(r, 2000));
206
+ branchAlias = await getBranchAlias(token, project.teamId, project.projectId, gitInfo.branch);
207
+ if (branchAlias)
208
+ break;
209
+ }
210
+ if (branchAlias) {
211
+ pollSpinner.stop("Deployment found!");
212
+ }
213
+ else {
214
+ pollSpinner.stop("Deployment is still building...");
215
+ }
216
+ }
201
217
  if (branchAlias) {
202
218
  const stateLabel = branchAlias.state !== "READY" ? ` ${pc.yellow(`(${branchAlias.state.toLowerCase()})`)}` : "";
203
219
  p.log.info(`Branch preview (auto-updates with each push):\n → ${pc.cyan(branchAlias.url)}${stateLabel}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inflight-cli",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "description": "Get feedback directly on your staging URL",
5
5
  "bin": {
6
6
  "inflight": "dist/index.js",
@@ -19,16 +19,16 @@
19
19
  "prepublishOnly": "npm run build"
20
20
  },
21
21
  "dependencies": {
22
- "@clack/prompts": "^0.8.2",
23
- "commander": "^12.1.0",
24
- "open": "^11.0.0",
25
- "picocolors": "^1.1.1",
26
- "update-notifier": "^7.3.1"
22
+ "@clack/prompts": "0.8.2",
23
+ "commander": "12.1.0",
24
+ "open": "11.0.0",
25
+ "picocolors": "1.1.1",
26
+ "update-notifier": "7.3.1"
27
27
  },
28
28
  "devDependencies": {
29
- "@types/node": "^22.0.0",
30
- "@types/update-notifier": "^6.0.8",
31
- "tsx": "^4.19.0",
32
- "typescript": "^5.6.0"
29
+ "@types/node": "22.0.0",
30
+ "@types/update-notifier": "6.0.8",
31
+ "tsx": "4.19.0",
32
+ "typescript": "5.9.3"
33
33
  }
34
34
  }