inflight-cli 2.7.0 → 2.8.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.
@@ -3,47 +3,74 @@ import pc from "picocolors";
3
3
  import { readGlobalAuth, writeGlobalAuth } from "../lib/config.js";
4
4
  import { apiGetMe } from "../lib/api.js";
5
5
  import { API_URL, WEB_URL } from "../lib/env.js";
6
+ import { isAgent, agentError } from "../lib/agent.js";
6
7
  const POLL_INTERVAL_MS = 2000;
7
8
  const POLL_TIMEOUT_MS = 5 * 60 * 1000;
8
9
  export async function loginCommand() {
9
10
  const existingAuth = readGlobalAuth();
10
11
  if (existingAuth) {
11
- const spinner = p.spinner();
12
- spinner.start("Checking existing session...");
13
- const me = await apiGetMe(existingAuth.apiKey).catch(() => null);
14
- if (me?.email) {
15
- spinner.stop(pc.green(`✓ Logged in as ${pc.bold(me.email)}`));
16
- return;
12
+ if (!isAgent) {
13
+ const spinner = p.spinner();
14
+ spinner.start("Checking existing session...");
15
+ const me = await apiGetMe(existingAuth.apiKey).catch(() => null);
16
+ if (me?.email) {
17
+ spinner.stop(pc.green(`✓ Logged in as ${pc.bold(me.email)}`));
18
+ return;
19
+ }
20
+ spinner.stop("Session expired — re-authenticating...");
21
+ }
22
+ else {
23
+ const me = await apiGetMe(existingAuth.apiKey).catch(() => null);
24
+ if (me?.email)
25
+ return;
17
26
  }
18
- spinner.stop("Session expired — re-authenticating...");
19
27
  }
20
28
  const sessionId = crypto.randomUUID();
21
29
  const authUrl = `${WEB_URL}/cli/connect?session_id=${sessionId}`;
22
- p.log.info("Opening browser to authenticate with Inflight...");
23
- p.log.info(`Opening ${pc.cyan(authUrl)}`);
30
+ if (!isAgent) {
31
+ p.log.info("Opening browser to authenticate with Inflight...");
32
+ p.log.info(`Opening ${pc.cyan(authUrl)}`);
33
+ }
24
34
  const { default: open } = await import("open");
25
35
  await open(authUrl);
26
- const spinner = p.spinner();
27
- spinner.start("Waiting for browser authentication...");
28
- const apiKey = await pollForApiKey(sessionId);
29
- if (!apiKey) {
30
- spinner.stop("Authentication timed out.");
31
- p.log.error("No response after 5 minutes. Please try again.");
32
- process.exit(1);
36
+ if (!isAgent) {
37
+ const spinner = p.spinner();
38
+ spinner.start("Waiting for login");
39
+ const apiKey = await pollForApiKey(sessionId);
40
+ if (!apiKey) {
41
+ spinner.stop("Authentication timed out.");
42
+ p.log.error("No response after 5 minutes. Please try again.");
43
+ process.exit(1);
44
+ }
45
+ spinner.message("Validating...");
46
+ const me = await apiGetMe(apiKey).catch((e) => {
47
+ spinner.stop("Validation failed.");
48
+ p.log.error(e.message);
49
+ process.exit(1);
50
+ });
51
+ if (!me.email) {
52
+ spinner.stop("Validation failed.");
53
+ p.log.error("No email associated with this account.");
54
+ process.exit(1);
55
+ }
56
+ writeGlobalAuth({ apiKey });
57
+ spinner.stop(pc.green(`✓ Logged in as ${pc.bold(me.email)}`));
33
58
  }
34
- spinner.message("Validating...");
35
- const me = await apiGetMe(apiKey).catch((e) => {
36
- spinner.stop("Validation failed.");
37
- p.log.error(e.message);
38
- process.exit(1);
39
- });
40
- if (!me.email) {
41
- spinner.stop("Validation failed.");
42
- p.log.error("No email associated with this account. Please sign up at inflight.co first.");
43
- process.exit(1);
59
+ else {
60
+ const apiKey = await pollForApiKey(sessionId);
61
+ if (!apiKey) {
62
+ agentError({
63
+ type: "auth_timeout",
64
+ message: "Authentication timed out. Approve the browser prompt and try again.",
65
+ suggestion: "inflight login",
66
+ });
67
+ }
68
+ const me = await apiGetMe(apiKey).catch(() => null);
69
+ if (!me?.email) {
70
+ agentError({ type: "auth_failed", message: "Could not validate account." });
71
+ }
72
+ writeGlobalAuth({ apiKey });
44
73
  }
45
- writeGlobalAuth({ apiKey });
46
- spinner.stop(pc.green(`✓ Logged in as ${pc.bold(me.email)}`));
47
74
  }
48
75
  async function pollForApiKey(sessionId) {
49
76
  const deadline = Date.now() + POLL_TIMEOUT_MS;
@@ -55,14 +82,10 @@ async function pollForApiKey(sessionId) {
55
82
  if (api_key)
56
83
  return api_key;
57
84
  }
58
- // 404 = not ready yet, keep polling
59
- // 500+ = server error, stop
60
85
  if (res.status >= 500)
61
86
  return null;
62
87
  }
63
- catch {
64
- // Network error (offline, DNS, etc.) — keep polling
65
- }
88
+ catch { }
66
89
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
67
90
  }
68
91
  return null;
@@ -1 +1,5 @@
1
- export declare function setupCommand(): Promise<void>;
1
+ export interface SetupOptions {
2
+ json?: boolean;
3
+ workspace?: string;
4
+ }
5
+ export declare function setupCommand(opts?: SetupOptions): Promise<void>;
@@ -1,12 +1,14 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
3
  import { execSync } from "child_process";
4
- import { readGlobalAuth, readWorkspaceConfig, writeWorkspaceConfig } from "../lib/config.js";
4
+ import { readGlobalAuth, writeWorkspaceConfig } from "../lib/config.js";
5
5
  import { apiGetMe, apiDetectWidgetLocation } from "../lib/api.js";
6
6
  import { loginCommand } from "./login.js";
7
7
  import { shareCommand } from "./share.js";
8
8
  import { gatherProjectContext, insertWidgetScript } from "../lib/framework.js";
9
9
  import { isGitRepo } from "../lib/git.js";
10
+ import { isAgent, agentSuccess, agentError } from "../lib/agent.js";
11
+ import { resolveWorkspace } from "../lib/resolve-workspace.js";
10
12
  function execSyncErrorDetail(err) {
11
13
  if (err !== null && typeof err === "object" && "stderr" in err) {
12
14
  const b = err.stderr;
@@ -21,10 +23,10 @@ function execSyncErrorDetail(err) {
21
23
  }
22
24
  return "";
23
25
  }
24
- export async function setupCommand() {
26
+ export async function setupCommand(opts = {}) {
25
27
  const cwd = process.cwd();
26
28
  // ── Pre-flight: warn if not a git repo ──
27
- if (!isGitRepo(cwd)) {
29
+ if (!isGitRepo(cwd) && !isAgent) {
28
30
  p.log.warn("This directory is not a git repository.\n" +
29
31
  " Inflight works best inside a git repo so it can track branches and commits.");
30
32
  }
@@ -35,52 +37,61 @@ export async function setupCommand() {
35
37
  await loginCommand();
36
38
  auth = readGlobalAuth();
37
39
  if (!auth) {
40
+ if (isAgent)
41
+ agentError({ type: "auth_failed", message: "Login failed." });
38
42
  p.log.error("Login failed.");
39
43
  process.exit(1);
40
44
  }
41
45
  }
42
46
  // ── Step 3: Resolve workspace ──
43
47
  const me = await apiGetMe(auth.apiKey).catch((e) => {
48
+ if (isAgent)
49
+ agentError({ type: "api_error", message: e.message });
44
50
  p.log.error(e.message);
45
51
  process.exit(1);
46
52
  });
47
- if (alreadyLoggedIn && me.email) {
53
+ if (!isAgent && alreadyLoggedIn && me.email) {
48
54
  p.log.success(`Logged in as ${pc.bold(me.email)}`);
49
55
  }
50
- const workspaces = me.workspaces;
51
- let workspaceId;
52
- // Check if a workspace is already configured and still valid
53
- const existingConfig = readWorkspaceConfig();
54
- const existingWorkspace = existingConfig ? workspaces.find((w) => w.id === existingConfig.workspaceId) : null;
55
- if (existingWorkspace) {
56
- workspaceId = existingWorkspace.id;
57
- p.log.success(`Workspace: ${pc.bold(existingWorkspace.name)} ${pc.dim("(change anytime with inflight workspace)")}`);
58
- }
59
- else if (workspaces.length === 0) {
60
- p.log.error("No workspaces found. Create one at " + pc.cyan("inflight.co") + " first.");
61
- process.exit(1);
62
- }
63
- else if (workspaces.length === 1) {
64
- workspaceId = workspaces[0].id;
65
- p.log.success(`Workspace: ${pc.bold(workspaces[0].name)}`);
66
- }
67
- else {
68
- const selected = await p.select({
69
- message: "Select a workspace " + pc.dim("(change anytime with inflight workspace)"),
70
- options: workspaces.map((w) => ({ value: w.id, label: w.name })),
71
- });
72
- if (p.isCancel(selected)) {
73
- p.cancel("Cancelled.");
74
- process.exit(0);
75
- }
76
- workspaceId = selected;
77
- }
56
+ const workspaceId = await resolveWorkspace(me.workspaces, {
57
+ explicitId: opts.workspace,
58
+ commandForNext: "inflight setup",
59
+ });
78
60
  writeWorkspaceConfig({ workspaceId });
79
- const widgetId = workspaces.find((w) => w.id === workspaceId)?.widgetId;
61
+ if (!isAgent) {
62
+ const wsName = me.workspaces.find((w) => w.id === workspaceId)?.name;
63
+ if (wsName)
64
+ p.log.success(`Workspace: ${pc.bold(wsName)} ${pc.dim("(change anytime with inflight workspace)")}`);
65
+ }
66
+ const widgetId = me.workspaces.find((w) => w.id === workspaceId)?.widgetId;
80
67
  if (!widgetId) {
68
+ if (isAgent)
69
+ agentError({ type: "no_widget_id", message: "Could not find widget ID for this workspace." });
81
70
  p.log.error("Could not find widget ID for this workspace.");
82
71
  process.exit(1);
83
72
  }
73
+ // ── Agent mode: return JSON with instructions and exit ──
74
+ if (isAgent) {
75
+ const context = gatherProjectContext(cwd);
76
+ const alreadyHasWidget = Object.values(context.fileContents).some((c) => c.includes("inflight.co/widget.js"));
77
+ const scriptTag = `<script src="https://www.inflight.co/widget.js" data-workspace="${widgetId}" async></script>`;
78
+ const nextSteps = alreadyHasWidget
79
+ ? ["Widget already installed. Run `inflight share` to share your staging URL."]
80
+ : [
81
+ "Insert the scriptTag into the project's root layout file, just before </body> (or as the last child of <body> in JSX/TSX files).",
82
+ "Common locations: app/layout.tsx (Next.js), index.html (Vite/CRA), app/root.tsx (Remix), src/app.html (SvelteKit).",
83
+ "Commit and push the change so it's included in the next deployment.",
84
+ "Then run `inflight share` to share the staging URL for feedback.",
85
+ ];
86
+ agentSuccess({
87
+ workspaceId,
88
+ workspaceName: me.workspaces.find((w) => w.id === workspaceId)?.name ?? null,
89
+ widgetId,
90
+ scriptTag,
91
+ alreadyInstalled: alreadyHasWidget,
92
+ nextSteps,
93
+ });
94
+ }
84
95
  // ── Step 4: Add widget script tag ──
85
96
  const hasWidget = (fileContents) => Object.values(fileContents).some((c) => c.includes("inflight.co/widget.js"));
86
97
  const context = gatherProjectContext(cwd);
@@ -1,5 +1,11 @@
1
1
  export interface ShareOptions {
2
2
  url?: string;
3
3
  json?: boolean;
4
+ workspace?: string;
5
+ project?: string;
6
+ provider?: string;
7
+ deployment?: string;
8
+ override?: boolean;
9
+ skipGitCheck?: boolean;
4
10
  }
5
11
  export declare function shareCommand(opts?: ShareOptions): Promise<void>;
@@ -1,11 +1,15 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
- import { readGlobalAuth, readWorkspaceConfig, writeWorkspaceConfig } from "../lib/config.js";
4
- import { getGitInfo, getGitSyncState, generateCommitMessage, commitAndPush, pushBranch, hasCommitsAhead, } from "../lib/git.js";
3
+ import { readGlobalAuth } from "../lib/config.js";
4
+ import { getGitInfo, getGitSyncState, generateCommitMessage, commitAndPush, pushBranch, hasCommitsAhead, getGitRoot, } from "../lib/git.js";
5
5
  import open from "open";
6
6
  import { providers } from "../providers/index.js";
7
7
  import { apiGetMe, apiCreateVersion, apiGetRecentProjects } from "../lib/api.js";
8
8
  import { scrollableSelect } from "../lib/scrollable-select.js";
9
+ import { isAgent, agentSuccess, agentActionRequired, agentError } from "../lib/agent.js";
10
+ import { resolveWorkspace } from "../lib/resolve-workspace.js";
11
+ import { readLocalVercelProject } from "../lib/vercel.js";
12
+ import { readLocalNetlifySite } from "../lib/netlify.js";
9
13
  function formatRelativeTime(timestampMs) {
10
14
  const seconds = Math.floor((Date.now() - timestampMs) / 1000);
11
15
  if (seconds < 60)
@@ -32,12 +36,29 @@ function isValidHostedUrl(url) {
32
36
  return false;
33
37
  }
34
38
  }
39
+ /**
40
+ * Builds a next_command string carrying forward relevant opts from the current run.
41
+ */
42
+ function buildNextCommand(base, opts) {
43
+ const parts = [base];
44
+ if (opts.workspace)
45
+ parts.push(`--workspace ${opts.workspace}`);
46
+ if (opts.provider)
47
+ parts.push(`--provider ${opts.provider}`);
48
+ if (opts.deployment)
49
+ parts.push(`--deployment ${opts.deployment}`);
50
+ if (opts.project)
51
+ parts.push(`--project ${opts.project}`);
52
+ if (opts.override)
53
+ parts.push("--override");
54
+ return parts.join(" ");
55
+ }
35
56
  /**
36
57
  * Checks local git state and prompts the user to commit/push if needed.
37
58
  * Returns { justPushed: true } if changes were pushed, { justPushed: false } otherwise.
38
59
  * Provider-agnostic — works for Vercel, Netlify, or any future provider.
39
60
  */
40
- async function checkAndSyncGit(cwd) {
61
+ async function checkAndSyncGit(cwd, opts = {}) {
41
62
  const state = getGitSyncState(cwd);
42
63
  if (state.status === "clean" || state.status === "detached") {
43
64
  return { justPushed: false };
@@ -74,6 +95,17 @@ async function checkAndSyncGit(cwd) {
74
95
  if (state.changedFiles.length > maxFiles) {
75
96
  lines.push(` ${pc.dim(`... and ${state.changedFiles.length - maxFiles} more`)}`);
76
97
  }
98
+ if (isAgent) {
99
+ agentActionRequired({
100
+ type: "git_uncommitted",
101
+ message: "You have uncommitted changes. Your deployment won't include them.",
102
+ choices: [
103
+ { id: "commit_push", label: "Commit and push these changes" },
104
+ { id: "continue", label: "Continue without committing" },
105
+ ],
106
+ nextCommand: buildNextCommand("inflight share --skip-git-check", opts),
107
+ });
108
+ }
77
109
  lines.push("", pc.yellow("Your deployment won't include these changes."));
78
110
  p.log.warn("You have uncommitted changes:\n" + lines.join("\n"));
79
111
  const action = await p.select({
@@ -128,6 +160,17 @@ async function checkAndSyncGit(cwd) {
128
160
  if (state.unpushedCommits.length > 5) {
129
161
  commitLines.push(` ${pc.dim(`... and ${state.unpushedCommits.length - 5} more`)}`);
130
162
  }
163
+ if (isAgent) {
164
+ agentActionRequired({
165
+ type: "git_unpushed",
166
+ message: "You have unpushed commits. Your deployment won't include them.",
167
+ choices: [
168
+ { id: "push", label: "Push these commits" },
169
+ { id: "continue", label: "Continue without pushing" },
170
+ ],
171
+ nextCommand: buildNextCommand("inflight share --skip-git-check", opts),
172
+ });
173
+ }
131
174
  commitLines.push("", pc.yellow("Your deployment won't include these commits."));
132
175
  p.log.warn("You have unpushed commits:\n" + commitLines.join("\n"));
133
176
  const action = await p.select({
@@ -170,6 +213,17 @@ async function checkAndSyncGit(cwd) {
170
213
  p.log.info(`Branch ${pc.bold(branch)} has no new commits — using existing deployments.`);
171
214
  return { justPushed: false };
172
215
  }
216
+ if (isAgent) {
217
+ agentActionRequired({
218
+ type: "git_no_remote",
219
+ message: `Branch "${branch}" hasn't been pushed yet — no deployment exists.`,
220
+ choices: [
221
+ { id: "push", label: "Push to create a deployment" },
222
+ { id: "continue", label: "Continue without pushing" },
223
+ ],
224
+ nextCommand: buildNextCommand("inflight share --skip-git-check", opts),
225
+ });
226
+ }
173
227
  p.log.warn(`Branch ${pc.bold(branch)} hasn't been pushed yet — no deployment exists.`);
174
228
  const confirm = await p.confirm({
175
229
  message: "Push to create a deployment?",
@@ -200,12 +254,171 @@ async function checkAndSyncGit(cwd) {
200
254
  }
201
255
  return { justPushed: false };
202
256
  }
257
+ /**
258
+ * Agent mode share flow.
259
+ * Mirrors every human prompt with action_required JSON.
260
+ * Polling runs normally (no spinners).
261
+ * Git is NOT touched — if there's dirty state, action_required is returned
262
+ * and the agent handles git itself, then re-runs with --skip-git-check.
263
+ */
264
+ async function agentShareFlow(cwd, apiKey, workspaceId, opts) {
265
+ // ── Git sync — report state, don't touch git ──
266
+ if (!opts.skipGitCheck) {
267
+ // checkAndSyncGit will call agentActionRequired and exit if git is dirty
268
+ await checkAndSyncGit(cwd, opts);
269
+ // If we reach here, git is clean
270
+ }
271
+ const gitInfo = getGitInfo(cwd);
272
+ // ── Resolve staging URL ──
273
+ let stagingUrl;
274
+ if (opts.deployment) {
275
+ // --deployment flag provided
276
+ stagingUrl = opts.deployment;
277
+ if (!stagingUrl.startsWith("http"))
278
+ stagingUrl = `https://${stagingUrl}`;
279
+ }
280
+ else {
281
+ // Determine provider
282
+ let providerId = opts.provider;
283
+ if (!providerId) {
284
+ // Auto-detect from local config files
285
+ const gitRoot = getGitRoot(cwd);
286
+ if (gitRoot && readLocalVercelProject(gitRoot)) {
287
+ providerId = "vercel";
288
+ }
289
+ else if (gitRoot && readLocalNetlifySite(gitRoot)) {
290
+ providerId = "netlify";
291
+ }
292
+ else {
293
+ agentActionRequired({
294
+ type: "choose_provider",
295
+ message: "Could not auto-detect deployment provider.",
296
+ choices: [
297
+ { id: "vercel", label: "Vercel" },
298
+ { id: "netlify", label: "Netlify" },
299
+ ],
300
+ nextCommand: "inflight share --skip-git-check --provider <ID>",
301
+ });
302
+ }
303
+ }
304
+ const provider = providers.find((prov) => prov.id === providerId);
305
+ if (!provider) {
306
+ agentError({
307
+ type: "invalid_provider",
308
+ message: `Unknown provider "${providerId}". Use "vercel" or "netlify".`,
309
+ });
310
+ }
311
+ stagingUrl = (await provider.resolve(cwd, gitInfo, {})) ?? undefined;
312
+ if (stagingUrl && !stagingUrl.startsWith("http")) {
313
+ stagingUrl = `https://${stagingUrl}`;
314
+ }
315
+ if (!stagingUrl) {
316
+ agentError({
317
+ type: "no_deployment",
318
+ message: "Could not find a deployment URL. Provide one with --url.",
319
+ suggestion: "inflight share --url <staging-url>",
320
+ });
321
+ }
322
+ }
323
+ // ── Resolve project ──
324
+ let selectedProjectId;
325
+ let overrideVersionId;
326
+ if (opts.project) {
327
+ if (opts.project !== "new") {
328
+ selectedProjectId = opts.project;
329
+ }
330
+ }
331
+ else {
332
+ const { projects } = await apiGetRecentProjects(apiKey, workspaceId, 20).catch(() => ({
333
+ projects: [],
334
+ }));
335
+ if (projects.length > 0) {
336
+ const currentBranch = gitInfo.branch;
337
+ agentActionRequired({
338
+ type: "choose_project",
339
+ message: "Select a project or create new.",
340
+ choices: [
341
+ { id: "new", label: "Start a new project" },
342
+ ...projects.map((proj) => ({
343
+ id: proj.projectId,
344
+ label: proj.latestVersion.title,
345
+ hint: [
346
+ proj.latestVersion.branch === currentBranch ? "current branch" : proj.latestVersion.branch,
347
+ proj.latestVersion.branch === currentBranch ? "(recommended)" : undefined,
348
+ ]
349
+ .filter(Boolean)
350
+ .join(" ") || undefined,
351
+ })),
352
+ ],
353
+ nextCommand: `inflight share --skip-git-check --deployment ${stagingUrl} --project <ID>`,
354
+ });
355
+ }
356
+ }
357
+ // ── Resolve override vs new version ──
358
+ if (selectedProjectId && !opts.override) {
359
+ const { projects } = await apiGetRecentProjects(apiKey, workspaceId, 20).catch(() => ({
360
+ projects: [],
361
+ }));
362
+ const selectedProject = projects.find((proj) => proj.projectId === selectedProjectId);
363
+ if (selectedProject && selectedProject.latestVersion.commentCount === 0) {
364
+ agentActionRequired({
365
+ type: "choose_override",
366
+ message: `"${selectedProject.latestVersion.title}" has no feedback yet.`,
367
+ choices: [
368
+ {
369
+ id: "override",
370
+ label: "Update its staging URL",
371
+ hint: `replace with ${new URL(stagingUrl).hostname}`,
372
+ },
373
+ {
374
+ id: "new_version",
375
+ label: "Add a new version",
376
+ hint: "keep both in version history",
377
+ },
378
+ ],
379
+ nextCommand: `inflight share --skip-git-check --deployment ${stagingUrl} --project ${selectedProjectId} --override`,
380
+ });
381
+ }
382
+ }
383
+ if (opts.override && selectedProjectId) {
384
+ const { projects } = await apiGetRecentProjects(apiKey, workspaceId, 20).catch(() => ({
385
+ projects: [],
386
+ }));
387
+ const selectedProject = projects.find((proj) => proj.projectId === selectedProjectId);
388
+ if (selectedProject) {
389
+ overrideVersionId = selectedProject.latestVersion.id;
390
+ }
391
+ }
392
+ // ── Create version ──
393
+ const result = await apiCreateVersion({
394
+ apiKey,
395
+ workspaceId,
396
+ stagingUrl: stagingUrl,
397
+ gitInfo,
398
+ ...(selectedProjectId && { projectId: selectedProjectId }),
399
+ ...(overrideVersionId && { overrideVersionId }),
400
+ }).catch((e) => {
401
+ agentError({ type: "create_failed", message: e.message });
402
+ });
403
+ agentSuccess({
404
+ stagingUrl,
405
+ ...result,
406
+ isOverride: !!overrideVersionId,
407
+ });
408
+ }
203
409
  export async function shareCommand(opts = {}) {
204
410
  const cwd = process.cwd();
205
411
  // TODO: Add a step to login if not authenticated
206
412
  // ── Step 1: Auth ──
207
413
  const auth = readGlobalAuth();
208
414
  if (!auth) {
415
+ if (isAgent) {
416
+ agentError({
417
+ type: "not_authenticated",
418
+ message: "Not logged in. Run inflight setup first.",
419
+ suggestion: "inflight setup",
420
+ });
421
+ }
209
422
  if (opts.json) {
210
423
  console.log(JSON.stringify({ error: "not_authenticated", message: "Not logged in. Run inflight setup first." }));
211
424
  }
@@ -217,6 +430,8 @@ export async function shareCommand(opts = {}) {
217
430
  let gitInfo = getGitInfo(cwd);
218
431
  // ── Step 2: Resolve workspace ──
219
432
  const me = await apiGetMe(auth.apiKey).catch((e) => {
433
+ if (isAgent)
434
+ agentError({ type: "api_error", message: e.message });
220
435
  if (opts.json) {
221
436
  console.log(JSON.stringify({ error: "api_error", message: e.message }));
222
437
  }
@@ -225,50 +440,10 @@ export async function shareCommand(opts = {}) {
225
440
  }
226
441
  process.exit(1);
227
442
  });
228
- const workspaces = me.workspaces;
229
- let workspaceId;
230
- const savedConfig = readWorkspaceConfig();
231
- const savedWorkspace = savedConfig ? workspaces.find((w) => w.id === savedConfig.workspaceId) : null;
232
- if (savedWorkspace) {
233
- workspaceId = savedWorkspace.id;
234
- }
235
- else if (workspaces.length === 0) {
236
- if (opts.json) {
237
- console.log(JSON.stringify({
238
- error: "no_workspaces",
239
- message: "No workspaces found. Create one at inflight.co first.",
240
- }));
241
- }
242
- else {
243
- p.log.error("No workspaces found. Create one at " + pc.cyan("inflight.co") + " first.");
244
- }
245
- process.exit(1);
246
- }
247
- else if (workspaces.length === 1) {
248
- workspaceId = workspaces[0].id;
249
- if (!opts.json)
250
- p.log.success(`Workspace: ${pc.bold(workspaces[0].name)}`);
251
- }
252
- else {
253
- if (opts.json) {
254
- console.log(JSON.stringify({
255
- error: "no_workspace_set",
256
- message: "Multiple workspaces found. Run 'inflight workspace --set=ID' first.",
257
- workspaces: workspaces.map((w) => ({ id: w.id, name: w.name })),
258
- }));
259
- process.exit(1);
260
- }
261
- const selected = await p.select({
262
- message: "Select a workspace " + pc.dim("(change anytime with inflight workspace)"),
263
- options: workspaces.map((w) => ({ value: w.id, label: w.name })),
264
- });
265
- if (p.isCancel(selected)) {
266
- p.cancel("Cancelled.");
267
- process.exit(0);
268
- }
269
- workspaceId = selected;
270
- }
271
- writeWorkspaceConfig({ workspaceId });
443
+ const workspaceId = await resolveWorkspace(me.workspaces, {
444
+ explicitId: opts.workspace,
445
+ commandForNext: "inflight share",
446
+ });
272
447
  // ── Fast path: URL provided (agent / scripting) ──
273
448
  if (opts.url) {
274
449
  let stagingUrl = opts.url;
@@ -279,6 +454,8 @@ export async function shareCommand(opts = {}) {
279
454
  const message = stagingUrl.includes("localhost")
280
455
  ? "Inflight needs a hosted URL — localhost isn't accessible to your team. Deploy to Vercel, Netlify, or another hosting provider first."
281
456
  : "Must be a hosted URL with a domain (e.g., my-branch.vercel.app)";
457
+ if (isAgent)
458
+ agentError({ type: "invalid_url", message });
282
459
  if (opts.json) {
283
460
  console.log(JSON.stringify({ error: "invalid_url", message }));
284
461
  }
@@ -292,7 +469,10 @@ export async function shareCommand(opts = {}) {
292
469
  workspaceId,
293
470
  stagingUrl,
294
471
  gitInfo,
472
+ ...(opts.project && opts.project !== "new" && { projectId: opts.project }),
295
473
  }).catch((e) => {
474
+ if (isAgent)
475
+ agentError({ type: "create_failed", message: e.message });
296
476
  if (opts.json) {
297
477
  console.log(JSON.stringify({ error: "create_failed", message: e.message }));
298
478
  }
@@ -301,6 +481,8 @@ export async function shareCommand(opts = {}) {
301
481
  }
302
482
  process.exit(1);
303
483
  });
484
+ if (isAgent)
485
+ agentSuccess({ stagingUrl, ...result });
304
486
  if (opts.json) {
305
487
  console.log(JSON.stringify({ success: true, stagingUrl, ...result }));
306
488
  }
@@ -311,6 +493,11 @@ export async function shareCommand(opts = {}) {
311
493
  await open(stagingUrl);
312
494
  return;
313
495
  }
496
+ // ── Agent mode: structured flow with action_required for every choice ──
497
+ if (isAgent) {
498
+ await agentShareFlow(cwd, auth.apiKey, workspaceId, opts);
499
+ return;
500
+ }
314
501
  // Resolve staging URL
315
502
  const providerChoice = await p.select({
316
503
  message: "Where is your staging URL hosted?",
package/dist/index.js CHANGED
@@ -18,13 +18,24 @@ program
18
18
  .description("Get feedback directly on your staging URL")
19
19
  .version(version)
20
20
  .enablePositionalOptions();
21
- program.command("setup").description("Set up Inflight in your project").action(setupCommand);
21
+ program
22
+ .command("setup")
23
+ .description("Set up Inflight in your project")
24
+ .option("--json", "Output as JSON (auto-enabled for non-TTY)")
25
+ .option("--workspace <id>", "Workspace ID (skip selection)")
26
+ .action((opts) => setupCommand(opts));
22
27
  program.command("login").description("Authenticate with your Inflight account").action(loginCommand);
23
28
  program
24
29
  .command("share")
25
30
  .description("Get feedback on your staging URL")
26
31
  .option("--url <url>", "Staging URL (skips provider selection)")
27
- .option("--json", "Output result as JSON")
32
+ .option("--json", "Output result as JSON (auto-enabled for non-TTY)")
33
+ .option("--workspace <id>", "Workspace ID (skip selection)")
34
+ .option("--project <id>", "Project ID, or 'new' to create")
35
+ .option("--provider <id>", "Deployment provider: vercel, netlify")
36
+ .option("--deployment <url>", "Specific deployment URL")
37
+ .option("--override", "Override latest version instead of creating new")
38
+ .option("--skip-git-check", "Skip git state check (use after agent handled git)")
28
39
  .action((opts) => shareCommand(opts));
29
40
  program
30
41
  .command("workspace")
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Agent mode detection and structured JSON output.
3
+ *
4
+ * When !process.stdout.isTTY (piped output, agent calling CLI),
5
+ * all interactive prompts are replaced with auto-resolution or
6
+ * structured JSON responses the agent can parse and act on.
7
+ */
8
+ export declare const isAgent: boolean;
9
+ export interface AgentChoice {
10
+ id: string;
11
+ label: string;
12
+ hint?: string;
13
+ }
14
+ export declare function agentSuccess(data: Record<string, unknown>): never;
15
+ export declare function agentActionRequired(opts: {
16
+ type: string;
17
+ message: string;
18
+ choices: AgentChoice[];
19
+ nextCommand: string;
20
+ }): never;
21
+ export declare function agentError(opts: {
22
+ type: string;
23
+ message: string;
24
+ suggestion?: string;
25
+ }): never;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Agent mode detection and structured JSON output.
3
+ *
4
+ * When !process.stdout.isTTY (piped output, agent calling CLI),
5
+ * all interactive prompts are replaced with auto-resolution or
6
+ * structured JSON responses the agent can parse and act on.
7
+ */
8
+ export const isAgent = !process.stdout.isTTY;
9
+ export function agentSuccess(data) {
10
+ console.log(JSON.stringify({ status: "success", data }));
11
+ process.exit(0);
12
+ }
13
+ export function agentActionRequired(opts) {
14
+ console.log(JSON.stringify({
15
+ status: "action_required",
16
+ type: opts.type,
17
+ message: opts.message,
18
+ choices: opts.choices,
19
+ next_command: opts.nextCommand,
20
+ }));
21
+ process.exit(0);
22
+ }
23
+ export function agentError(opts) {
24
+ console.log(JSON.stringify({
25
+ status: "error",
26
+ type: opts.type,
27
+ message: opts.message,
28
+ ...(opts.suggestion && { suggestion: opts.suggestion }),
29
+ }));
30
+ process.exit(1);
31
+ }
@@ -0,0 +1,15 @@
1
+ import type { Workspace } from "./api.js";
2
+ /**
3
+ * Resolves the active workspace ID.
4
+ *
5
+ * Resolution order:
6
+ * 1. Explicit --workspace flag
7
+ * 2. Saved config from prior session
8
+ * 3. Single workspace → auto-select
9
+ * 4. Multiple → action_required (agent) or p.select (human)
10
+ * 5. Zero → error
11
+ */
12
+ export declare function resolveWorkspace(workspaces: Workspace[], opts?: {
13
+ explicitId?: string;
14
+ commandForNext?: string;
15
+ }): Promise<string>;
@@ -0,0 +1,77 @@
1
+ // apps/cli/src/lib/resolve-workspace.ts
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { isAgent, agentActionRequired, agentError } from "./agent.js";
5
+ import { readWorkspaceConfig, writeWorkspaceConfig } from "./config.js";
6
+ /**
7
+ * Resolves the active workspace ID.
8
+ *
9
+ * Resolution order:
10
+ * 1. Explicit --workspace flag
11
+ * 2. Saved config from prior session
12
+ * 3. Single workspace → auto-select
13
+ * 4. Multiple → action_required (agent) or p.select (human)
14
+ * 5. Zero → error
15
+ */
16
+ export async function resolveWorkspace(workspaces, opts = {}) {
17
+ const { explicitId, commandForNext = "inflight share" } = opts;
18
+ // Explicit flag
19
+ if (explicitId) {
20
+ const match = workspaces.find((w) => w.id === explicitId);
21
+ if (!match) {
22
+ if (isAgent) {
23
+ agentError({
24
+ type: "invalid_workspace",
25
+ message: `Workspace "${explicitId}" not found.`,
26
+ suggestion: `Valid IDs: ${workspaces.map((w) => w.id).join(", ")}`,
27
+ });
28
+ }
29
+ p.log.error(`Workspace "${explicitId}" not found.`);
30
+ process.exit(1);
31
+ }
32
+ writeWorkspaceConfig({ workspaceId: match.id });
33
+ return match.id;
34
+ }
35
+ // Saved config — return silently (avoids duplicate log when setup calls share)
36
+ const savedConfig = readWorkspaceConfig();
37
+ const savedWorkspace = savedConfig ? workspaces.find((w) => w.id === savedConfig.workspaceId) : null;
38
+ if (savedWorkspace) {
39
+ return savedWorkspace.id;
40
+ }
41
+ // Zero
42
+ if (workspaces.length === 0) {
43
+ if (isAgent) {
44
+ agentError({ type: "no_workspaces", message: "No workspaces found. Create one at inflight.co first." });
45
+ }
46
+ p.log.error("No workspaces found. Create one at " + pc.cyan("inflight.co") + " first.");
47
+ process.exit(1);
48
+ }
49
+ // Single → auto-select
50
+ if (workspaces.length === 1) {
51
+ const ws = workspaces[0];
52
+ writeWorkspaceConfig({ workspaceId: ws.id });
53
+ if (!isAgent)
54
+ p.log.success(`Workspace: ${pc.bold(ws.name)}`);
55
+ return ws.id;
56
+ }
57
+ // Multiple
58
+ if (isAgent) {
59
+ agentActionRequired({
60
+ type: "choose_workspace",
61
+ message: "Multiple workspaces found. Re-run with --workspace <id>.",
62
+ choices: workspaces.map((w) => ({ id: w.id, label: w.name })),
63
+ nextCommand: `${commandForNext} --workspace <ID>`,
64
+ });
65
+ }
66
+ const selected = await p.select({
67
+ message: "Select a workspace " + pc.dim("(change anytime with inflight workspace)"),
68
+ options: workspaces.map((w) => ({ value: w.id, label: w.name })),
69
+ });
70
+ if (p.isCancel(selected)) {
71
+ p.cancel("Cancelled.");
72
+ process.exit(0);
73
+ }
74
+ const workspaceId = selected;
75
+ writeWorkspaceConfig({ workspaceId });
76
+ return workspaceId;
77
+ }
@@ -1,6 +1,7 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
3
  import { parseGitRepo, getGitRoot } from "../lib/git.js";
4
+ import { isAgent } from "../lib/agent.js";
4
5
  import { writeNetlifyConfig } from "../lib/config.js";
5
6
  import { ensureNetlifyCli, ensureNetlifyAuth, readLocalNetlifySite, writeLocalNetlifySite, getNetlifySiteById, getNetlifySites, getNetlifyDeploys, matchSitesByRepo, } from "../lib/netlify.js";
6
7
  // --- Auto-detection ---
@@ -37,20 +38,20 @@ async function autoDetectSite(cwd, gitInfo, token) {
37
38
  }
38
39
  }
39
40
  // --- Fetch all sites ---
40
- const spinner = p.spinner();
41
- spinner.start("Detecting Netlify site...");
41
+ const spinner = !isAgent ? p.spinner() : null;
42
+ spinner?.start("Detecting Netlify site...");
42
43
  let allSites;
43
44
  try {
44
45
  allSites = await getNetlifySites(token);
45
46
  }
46
47
  catch (e) {
47
- spinner.stop("Could not fetch Netlify sites.");
48
- if (e instanceof Error)
48
+ spinner?.stop("Could not fetch Netlify sites.");
49
+ if (!isAgent && e instanceof Error)
49
50
  p.log.message(pc.dim(e.message));
50
51
  return null;
51
52
  }
52
53
  if (allSites.length === 0) {
53
- spinner.stop("No Netlify sites found.");
54
+ spinner?.stop("No Netlify sites found.");
54
55
  return null;
55
56
  }
56
57
  // --- Try exact match on git remote ---
@@ -58,7 +59,7 @@ async function autoDetectSite(cwd, gitInfo, token) {
58
59
  if (gitRepo) {
59
60
  const matches = matchSitesByRepo(allSites, gitRepo.owner, gitRepo.name);
60
61
  if (matches.length === 1) {
61
- spinner.stop(`Detected Netlify site: ${pc.bold(matches[0].name)}`);
62
+ spinner?.stop(`Detected Netlify site: ${pc.bold(matches[0].name)}`);
62
63
  return cacheResult({
63
64
  siteId: matches[0].id,
64
65
  siteName: matches[0].name,
@@ -67,17 +68,19 @@ async function autoDetectSite(cwd, gitInfo, token) {
67
68
  });
68
69
  }
69
70
  if (matches.length > 1) {
70
- spinner.stop(`Found ${matches.length} Netlify sites for this repo.`);
71
+ spinner?.stop(`Found ${matches.length} Netlify sites for this repo.`);
71
72
  const picked = await pickFromList(matches);
72
73
  return picked ? cacheResult(picked) : null;
73
74
  }
74
75
  }
75
76
  // --- No match ---
76
- spinner.stop("Could not auto-detect Netlify site.");
77
+ spinner?.stop("Could not auto-detect Netlify site.");
77
78
  const picked = await pickFromList(allSites);
78
79
  return picked ? cacheResult(picked) : null;
79
80
  }
80
81
  async function pickFromList(sites) {
82
+ if (isAgent)
83
+ return null;
81
84
  const maxName = Math.max(...sites.map((s) => s.name.length));
82
85
  const selected = await p.select({
83
86
  message: "Select a Netlify site",
@@ -102,9 +105,12 @@ async function pickFromList(sites) {
102
105
  export async function pickNetlifySite(token) {
103
106
  const allSites = await getNetlifySites(token);
104
107
  if (allSites.length === 0) {
105
- p.log.error("No Netlify sites found.");
108
+ if (!isAgent)
109
+ p.log.error("No Netlify sites found.");
106
110
  return null;
107
111
  }
112
+ if (isAgent)
113
+ return null;
108
114
  const selected = await p.select({
109
115
  message: "Select a Netlify site",
110
116
  options: allSites.map((s) => ({
@@ -127,18 +133,22 @@ export async function pickNetlifySite(token) {
127
133
  }
128
134
  // --- Main resolve function ---
129
135
  export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
130
- const cli = await ensureNetlifyCli((msg) => p.log.step(msg));
136
+ const cli = await ensureNetlifyCli((msg) => { if (!isAgent)
137
+ p.log.step(msg); });
131
138
  if (!cli.ok) {
132
- p.log.error("Could not install the Netlify CLI automatically.");
133
- if (cli.error) {
134
- p.log.message(pc.dim(cli.error.trim()));
139
+ if (!isAgent) {
140
+ p.log.error("Could not install the Netlify CLI automatically.");
141
+ if (cli.error) {
142
+ p.log.message(pc.dim(cli.error.trim()));
143
+ }
144
+ p.log.info(`Paste the error above into your AI agent — it can fix this for you.\n\nThen re-run ${pc.cyan("inflight share")}.`);
135
145
  }
136
- p.log.info(`Paste the error above into your AI agent — it can fix this for you.\n\nThen re-run ${pc.cyan("inflight share")}.`);
137
146
  process.exit(0);
138
147
  }
139
148
  const token = await ensureNetlifyAuth();
140
149
  if (!token) {
141
- p.log.error("Netlify login failed.");
150
+ if (!isAgent)
151
+ p.log.error("Netlify login failed.");
142
152
  return null;
143
153
  }
144
154
  const site = await autoDetectSite(cwd, gitInfo, token);
@@ -156,8 +166,8 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
156
166
  let commitDeploy = repoMatches ? deploys.find((d) => d.commitRef === commitSha) : undefined;
157
167
  // If just pushed, poll until the commit deployment appears
158
168
  if (!commitDeploy && repoMatches && opts?.justPushed) {
159
- const pollSpinner = p.spinner({ indicator: "timer" });
160
- pollSpinner.start("Waiting for Netlify to pick up your push...");
169
+ const pollSpinner = !isAgent ? p.spinner({ indicator: "timer" }) : null;
170
+ pollSpinner?.start("Waiting for deploy");
161
171
  for (let i = 0; i < 30; i++) {
162
172
  await new Promise((r) => setTimeout(r, 2000));
163
173
  deploys = await getNetlifyDeploys(token, site.siteId, site.siteName, {
@@ -168,10 +178,10 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
168
178
  break;
169
179
  }
170
180
  if (commitDeploy) {
171
- pollSpinner.clear();
181
+ pollSpinner?.clear();
172
182
  }
173
183
  else {
174
- pollSpinner.stop("No deployment detected yet — Netlify may still be processing.");
184
+ pollSpinner?.stop("No deployment detected yet — Netlify may still be processing.");
175
185
  }
176
186
  }
177
187
  // If we found a commit-specific deployment, handle based on state
@@ -181,20 +191,22 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
181
191
  ? pc.dim(` — ${truncate(commitDeploy.commitMessage.split("\n")[0], 50)}`)
182
192
  : "";
183
193
  if (commitDeploy.state === "error") {
184
- p.log.error(`Netlify deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
194
+ if (!isAgent)
195
+ p.log.error(`Netlify deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
185
196
  commitDeploy = undefined;
186
197
  // Fall through to the picker
187
198
  }
188
199
  else if (commitDeploy.state !== "ready") {
189
200
  // Any non-ready, non-error state (building, enqueued, uploading, preparing, processing, etc.)
190
- p.log.info(`Netlify deployment for ${commitLabel}${message} — ${pc.yellow(commitDeploy.state)}`);
201
+ if (!isAgent)
202
+ p.log.info(`Netlify deployment for ${commitLabel}${message} — ${pc.yellow(commitDeploy.state)}`);
191
203
  const buildingUrl = commitDeploy.deploySslUrl;
192
204
  const startTime = Date.now();
193
205
  const maxWaitMs = 120_000;
194
206
  const pollIntervalMs = 5_000;
195
207
  let resolved = false;
196
- const spinner = p.spinner({ indicator: "timer" });
197
- spinner.start("Waiting for Netlify to finish building...");
208
+ const spinner = !isAgent ? p.spinner({ indicator: "timer" }) : null;
209
+ spinner?.start("Waiting for build");
198
210
  try {
199
211
  while (Date.now() - startTime < maxWaitMs) {
200
212
  await new Promise((r) => setTimeout(r, pollIntervalMs));
@@ -205,20 +217,24 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
205
217
  if (!fresh)
206
218
  break;
207
219
  if (fresh.state === "ready") {
208
- spinner.stop("Netlify deployment ready!");
209
- p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(fresh.deploySslUrl)}`);
220
+ spinner?.stop("Netlify deployment ready!");
221
+ if (!isAgent)
222
+ p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(fresh.deploySslUrl)}`);
210
223
  return fresh.deploySslUrl;
211
224
  }
212
225
  if (fresh.state === "error") {
213
- spinner.stop(pc.red("Netlify deployment failed."));
214
- p.log.error(`Netlify deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
226
+ spinner?.stop(pc.red("Netlify deployment failed."));
227
+ if (!isAgent)
228
+ p.log.error(`Netlify deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
215
229
  commitDeploy = undefined;
216
230
  resolved = true;
217
231
  break;
218
232
  }
219
233
  }
220
234
  if (!resolved) {
221
- spinner.stop("Still building...");
235
+ spinner?.stop("Still building...");
236
+ if (isAgent)
237
+ return buildingUrl;
222
238
  const action = await p.select({
223
239
  message: "Netlify deployment is still building.",
224
240
  options: [
@@ -239,14 +255,15 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
239
255
  }
240
256
  }
241
257
  catch (e) {
242
- spinner.stop("Error checking deployment status.");
243
- if (e instanceof Error)
258
+ spinner?.stop("Error checking deployment status.");
259
+ if (!isAgent && e instanceof Error)
244
260
  p.log.message(pc.dim(e.message));
245
261
  }
246
262
  }
247
263
  else {
248
264
  // ready — use it
249
- p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(commitDeploy.deploySslUrl)}`);
265
+ if (!isAgent)
266
+ p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(commitDeploy.deploySslUrl)}`);
250
267
  return commitDeploy.deploySslUrl;
251
268
  }
252
269
  }
@@ -256,14 +273,20 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
256
273
  });
257
274
  // Fallback: no commit deployment — let user pick from recent or paste manually
258
275
  if (deploys.length === 0) {
259
- p.log.warn("No deployments found. Paste a URL instead.");
276
+ if (!isAgent)
277
+ p.log.warn("No deployments found. Paste a URL instead.");
260
278
  return null;
261
279
  }
262
280
  const hasWorkingDeploy = deploys.some((d) => d.state !== "error");
263
281
  if (!hasWorkingDeploy) {
264
- p.log.warn("All recent deployments failed. Fix the build and push again, or paste a URL.");
282
+ if (!isAgent)
283
+ p.log.warn("All recent deployments failed. Fix the build and push again, or paste a URL.");
265
284
  return null;
266
285
  }
286
+ if (isAgent) {
287
+ const ready = deploys.find((d) => d.state === "ready");
288
+ return ready?.deploySslUrl ?? deploys[0]?.deploySslUrl ?? null;
289
+ }
267
290
  const maxBranch = Math.max(...deploys.map((d) => (d.branch ?? "unknown").length));
268
291
  const selected = await p.select({
269
292
  message: "No deployment found for current commit. Select one:",
@@ -295,6 +318,8 @@ export async function resolveNetlifyUrl(cwd, gitInfo, opts) {
295
318
  // Warn if the user picked a failed deployment
296
319
  const pickedDeploy = deploys.find((d) => d.deploySslUrl === selected);
297
320
  if (pickedDeploy && pickedDeploy.state === "error") {
321
+ if (isAgent)
322
+ return null;
298
323
  p.log.warn("This deployment failed — the URL may not load.");
299
324
  const confirm = await p.confirm({ message: "Use it anyway?" });
300
325
  if (p.isCancel(confirm) || !confirm)
@@ -2,6 +2,7 @@ import * as p from "@clack/prompts";
2
2
  import pc from "picocolors";
3
3
  import { execSync } from "child_process";
4
4
  import { parseGitRepo, getGitRoot } from "../lib/git.js";
5
+ import { isAgent } from "../lib/agent.js";
5
6
  import { ensureVercelCli, ensureVercelAuth, readLocalVercelProject, writeLocalVercelProject, getVercelProjectById, getVercelProjects, matchVercelProjectsByRepo, createVercelProject, getVercelDeployments, } from "../lib/vercel.js";
6
7
  // --- Auto-detection ---
7
8
  /**
@@ -40,20 +41,21 @@ async function autoDetectProject(cwd, gitInfo, token) {
40
41
  }
41
42
  }
42
43
  // --- Fetch all projects ---
43
- const spinner = p.spinner();
44
- spinner.start("Detecting Vercel project...");
44
+ const spinner = !isAgent ? p.spinner() : null;
45
+ spinner?.start("Detecting Vercel project...");
45
46
  let allProjects;
46
47
  try {
47
48
  allProjects = await getVercelProjects(token);
48
49
  }
49
50
  catch (e) {
50
- spinner.stop("Could not fetch Vercel projects.");
51
+ spinner?.stop("Could not fetch Vercel projects.");
51
52
  if (e instanceof Error)
52
- p.log.message(pc.dim(e.message));
53
+ if (!isAgent)
54
+ p.log.message(pc.dim(e.message));
53
55
  return null;
54
56
  }
55
57
  if (allProjects.length === 0) {
56
- spinner.stop("No Vercel projects found.");
58
+ spinner?.stop("No Vercel projects found.");
57
59
  return null;
58
60
  }
59
61
  // --- Try exact match on git remote ---
@@ -61,7 +63,7 @@ async function autoDetectProject(cwd, gitInfo, token) {
61
63
  if (gitRepo) {
62
64
  const matches = matchVercelProjectsByRepo(allProjects, gitRepo.owner, gitRepo.name);
63
65
  if (matches.length === 1) {
64
- spinner.stop(`Detected Vercel project: ${pc.bold(matches[0].name)}`);
66
+ spinner?.stop(`Detected Vercel project: ${pc.bold(matches[0].name)}`);
65
67
  return cacheResult({
66
68
  teamId: matches[0].teamId,
67
69
  projectId: matches[0].id,
@@ -71,18 +73,20 @@ async function autoDetectProject(cwd, gitInfo, token) {
71
73
  });
72
74
  }
73
75
  if (matches.length > 1) {
74
- spinner.stop(`Found ${matches.length} Vercel projects for this repo.`);
76
+ spinner?.stop(`Found ${matches.length} Vercel projects for this repo.`);
75
77
  const picked = await pickFromList(allProjects);
76
78
  return picked ? cacheResult(picked) : null;
77
79
  }
78
80
  }
79
81
  // --- No match ---
80
- spinner.stop("Could not auto-detect Vercel project.");
82
+ spinner?.stop("Could not auto-detect Vercel project.");
81
83
  const canCreate = gitRepo && gitRepo.provider !== "unknown";
82
84
  const picked = await pickFromList(allProjects, canCreate ? { token, gitRepo } : undefined);
83
85
  return picked ? cacheResult(picked) : null;
84
86
  }
85
87
  async function pickFromList(projects, createCtx) {
88
+ if (isAgent)
89
+ return null;
86
90
  const maxName = Math.max(...projects.map((proj) => proj.name.length));
87
91
  const selected = await p.select({
88
92
  message: "Select a Vercel project",
@@ -113,6 +117,8 @@ async function pickFromList(projects, createCtx) {
113
117
  };
114
118
  }
115
119
  async function createProjectFlow(projects, ctx) {
120
+ if (isAgent)
121
+ return null;
116
122
  const { token, gitRepo } = ctx;
117
123
  // Pick team from unique teams
118
124
  const teams = [...new Map(projects.map((proj) => [proj.teamId, proj.teamName])).entries()];
@@ -184,18 +190,23 @@ async function createProjectFlow(projects, ctx) {
184
190
  }
185
191
  // --- Main resolve function ---
186
192
  export async function resolveVercelUrl(cwd, gitInfo, opts) {
187
- const cli = await ensureVercelCli((msg) => p.log.step(msg));
193
+ const cli = await ensureVercelCli((msg) => { if (!isAgent)
194
+ p.log.step(msg); });
188
195
  if (!cli.ok) {
189
- p.log.error("Could not install the Vercel CLI automatically.");
196
+ if (!isAgent)
197
+ p.log.error("Could not install the Vercel CLI automatically.");
190
198
  if (cli.error) {
191
- p.log.message(pc.dim(cli.error.trim()));
199
+ if (!isAgent)
200
+ p.log.message(pc.dim(cli.error.trim()));
192
201
  }
193
- p.log.info(`Paste the error above into your AI agent — it can fix this for you.\n\nThen re-run ${pc.cyan("inflight share")}.`);
202
+ if (!isAgent)
203
+ p.log.info(`Paste the error above into your AI agent — it can fix this for you.\n\nThen re-run ${pc.cyan("inflight share")}.`);
194
204
  process.exit(0);
195
205
  }
196
206
  const token = await ensureVercelAuth();
197
207
  if (!token) {
198
- p.log.error("Vercel login failed.");
208
+ if (!isAgent)
209
+ p.log.error("Vercel login failed.");
199
210
  return null;
200
211
  }
201
212
  const project = await autoDetectProject(cwd, gitInfo, token);
@@ -215,8 +226,8 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
215
226
  let commitDeploy = repoMatches ? deployments.find((d) => d.commitSha === commitSha) : undefined;
216
227
  // If just pushed, poll until the commit deployment appears
217
228
  if (!commitDeploy && repoMatches && opts?.justPushed) {
218
- const pollSpinner = p.spinner({ indicator: "timer" });
219
- pollSpinner.start("Waiting for Vercel to pick up your push");
229
+ const pollSpinner = !isAgent ? p.spinner({ indicator: "timer" }) : null;
230
+ pollSpinner?.start("Waiting for deploy");
220
231
  for (let i = 0; i < 30; i++) {
221
232
  await new Promise((r) => setTimeout(r, 2000));
222
233
  deployments = await getVercelDeployments(token, project.teamId, project.projectId, {
@@ -227,10 +238,10 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
227
238
  break;
228
239
  }
229
240
  if (commitDeploy) {
230
- pollSpinner.clear();
241
+ pollSpinner?.clear();
231
242
  }
232
243
  else {
233
- pollSpinner.stop("No deployment detected yet — Vercel may still be processing.");
244
+ pollSpinner?.stop("No deployment detected yet — Vercel may still be processing.");
234
245
  }
235
246
  }
236
247
  // If we found a commit-specific deployment, handle based on state
@@ -240,7 +251,8 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
240
251
  ? pc.dim(` — ${truncate(commitDeploy.commitMessage.split("\n")[0], 50)}`)
241
252
  : "";
242
253
  if (commitDeploy.state === "ERROR" || commitDeploy.state === "CANCELED") {
243
- p.log.error(`Vercel deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
254
+ if (!isAgent)
255
+ p.log.error(`Vercel deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
244
256
  commitDeploy = undefined;
245
257
  // Fall through to the picker
246
258
  }
@@ -248,15 +260,16 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
248
260
  commitDeploy.state === "QUEUED" ||
249
261
  commitDeploy.state === "INITIALIZING") {
250
262
  // Show what we're waiting on
251
- p.log.info(`Vercel deployment for ${commitLabel}${message} — ${pc.yellow(commitDeploy.state.toLowerCase())}`);
263
+ if (!isAgent)
264
+ p.log.info(`Vercel deployment for ${commitLabel}${message} — ${pc.yellow(commitDeploy.state.toLowerCase())}`);
252
265
  // Poll with a ticking timer for up to ~2 minutes
253
266
  const buildingUrl = commitDeploy.url;
254
267
  const startTime = Date.now();
255
268
  const maxWaitMs = 120_000;
256
269
  const pollIntervalMs = 3_000;
257
270
  let resolved = false;
258
- const spinner = p.spinner({ indicator: "timer" });
259
- spinner.start("Waiting for Vercel to finish building");
271
+ const spinner = !isAgent ? p.spinner({ indicator: "timer" }) : null;
272
+ spinner?.start("Waiting for build");
260
273
  try {
261
274
  while (Date.now() - startTime < maxWaitMs) {
262
275
  await new Promise((r) => setTimeout(r, pollIntervalMs));
@@ -267,19 +280,22 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
267
280
  if (!fresh)
268
281
  break;
269
282
  if (fresh.state === "READY") {
270
- spinner.stop("Vercel deployment ready!");
283
+ spinner?.stop("Vercel deployment ready!");
271
284
  return fresh.url;
272
285
  }
273
286
  if (fresh.state === "ERROR" || fresh.state === "CANCELED") {
274
- spinner.stop(pc.red("Vercel deployment failed."));
275
- p.log.error(`Vercel deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
287
+ spinner?.stop(pc.red("Vercel deployment failed."));
288
+ if (!isAgent)
289
+ p.log.error(`Vercel deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
276
290
  commitDeploy = undefined;
277
291
  resolved = true;
278
292
  break;
279
293
  }
280
294
  }
281
295
  if (!resolved) {
282
- spinner.stop("Still building...");
296
+ if (isAgent)
297
+ return buildingUrl;
298
+ spinner?.stop("Still building...");
283
299
  const action = await p.select({
284
300
  message: "Vercel deployment is still building.",
285
301
  options: [
@@ -292,7 +308,8 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
292
308
  process.exit(0);
293
309
  }
294
310
  if (action === "continue") {
295
- p.log.info(`Vercel deployment for ${commitLabel}${message}:\n → ${pc.cyan(buildingUrl)} ${pc.yellow("(building)")}`);
311
+ if (!isAgent)
312
+ p.log.info(`Vercel deployment for ${commitLabel}${message}:\n → ${pc.cyan(buildingUrl)} ${pc.yellow("(building)")}`);
296
313
  return buildingUrl;
297
314
  }
298
315
  commitDeploy = undefined;
@@ -300,14 +317,16 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
300
317
  }
301
318
  }
302
319
  catch (e) {
303
- spinner.stop("Error checking deployment status.");
320
+ spinner?.stop("Error checking deployment status.");
304
321
  if (e instanceof Error)
305
- p.log.message(pc.dim(e.message));
322
+ if (!isAgent)
323
+ p.log.message(pc.dim(e.message));
306
324
  }
307
325
  }
308
326
  else {
309
327
  // READY or any other terminal state — use it
310
- p.log.info(`Vercel deployment for ${commitLabel}${message}`);
328
+ if (!isAgent)
329
+ p.log.info(`Vercel deployment for ${commitLabel}${message}`);
311
330
  return commitDeploy.url;
312
331
  }
313
332
  }
@@ -317,14 +336,20 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
317
336
  });
318
337
  // Fallback: no commit deployment found — let user pick from recent or paste manually
319
338
  if (deployments.length === 0) {
320
- p.log.warn("No deployments found. Paste a URL instead.");
339
+ if (!isAgent)
340
+ p.log.warn("No deployments found. Paste a URL instead.");
321
341
  return null;
322
342
  }
323
343
  const hasWorkingDeployment = deployments.some((d) => d.state !== "ERROR" && d.state !== "CANCELED");
324
344
  if (!hasWorkingDeployment) {
325
- p.log.warn("All recent deployments failed. Fix the build and push again, or paste a URL.");
345
+ if (!isAgent)
346
+ p.log.warn("All recent deployments failed. Fix the build and push again, or paste a URL.");
326
347
  return null;
327
348
  }
349
+ if (isAgent) {
350
+ const ready = deployments.find((d) => d.state === "READY");
351
+ return ready?.url ?? deployments[0]?.url ?? null;
352
+ }
328
353
  const maxBranchPick = Math.max(...deployments.map((d) => (d.branch ?? "unknown").length));
329
354
  const selected = await p.select({
330
355
  message: "No deployment found for current commit. Select one:",
@@ -356,6 +381,8 @@ export async function resolveVercelUrl(cwd, gitInfo, opts) {
356
381
  // Warn if the user picked a failed deployment
357
382
  const pickedDeploy = deployments.find((d) => d.url === selected);
358
383
  if (pickedDeploy && (pickedDeploy.state === "ERROR" || pickedDeploy.state === "CANCELED")) {
384
+ if (isAgent)
385
+ return null;
359
386
  p.log.warn("This deployment failed — the URL may not load.");
360
387
  const confirm = await p.confirm({ message: "Use it anyway?" });
361
388
  if (p.isCancel(confirm) || !confirm)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inflight-cli",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Get feedback directly on your staging URL",
5
5
  "bin": {
6
6
  "inflight": "dist/index.js",
@@ -19,8 +19,8 @@
19
19
  "prepublishOnly": "npm run build"
20
20
  },
21
21
  "dependencies": {
22
- "@clack/core": "1.2.0",
23
- "@clack/prompts": "1.2.0",
22
+ "@clack/core": "^1.3.0",
23
+ "@clack/prompts": "^1.3.0",
24
24
  "commander": "12.1.0",
25
25
  "open": "11.0.0",
26
26
  "picocolors": "1.1.1",