inflight-cli 2.12.0 → 2.14.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.
- package/dist/commands/share.d.ts +0 -1
- package/dist/commands/share.js +386 -466
- package/dist/commands/workspace.d.ts +1 -2
- package/dist/commands/workspace.js +47 -47
- package/dist/index.js +22 -8
- package/dist/lib/git.d.ts +20 -14
- package/dist/lib/git.js +14 -28
- package/dist/lib/netlify.d.ts +4 -5
- package/dist/lib/netlify.js +22 -10
- package/dist/lib/vercel.d.ts +6 -9
- package/dist/lib/vercel.js +26 -33
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.js +11 -4
- package/dist/providers/netlify.d.ts +1 -3
- package/dist/providers/netlify.js +75 -272
- package/dist/providers/shared.d.ts +39 -0
- package/dist/providers/shared.js +249 -0
- package/dist/providers/vercel.d.ts +1 -1
- package/dist/providers/vercel.js +88 -259
- package/package.json +1 -1
|
@@ -1,40 +1,48 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
-
import { parseGitRepo
|
|
3
|
+
import { parseGitRepo } from "../lib/git.js";
|
|
4
4
|
import { isAgent } from "../lib/agent.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { ensureNetlifyCli, ensureNetlifyAuth, readLocalNetlifySite, writeLocalNetlifySite, getNetlifySiteById, getNetlifySites, getNetlifyDeploys, getNetlifyDeployById, matchSitesByRepo, } from "../lib/netlify.js";
|
|
6
|
+
import { resolveDeploymentUrl } from "./shared.js";
|
|
7
|
+
// --- Netlify state mapping ---
|
|
8
|
+
function normalizeNetlifyState(state) {
|
|
9
|
+
switch (state) {
|
|
10
|
+
case "ready":
|
|
11
|
+
return "ready";
|
|
12
|
+
case "error":
|
|
13
|
+
return "error";
|
|
14
|
+
case "enqueued":
|
|
15
|
+
return "queued";
|
|
16
|
+
default:
|
|
17
|
+
// building, uploading, preparing, processing, etc.
|
|
18
|
+
return "building";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
// --- Auto-detection ---
|
|
8
22
|
/**
|
|
9
23
|
* Auto-detect the Netlify site for the current git repo.
|
|
10
24
|
*
|
|
11
25
|
* 1. `.netlify/state.json` at git root (instant, no API)
|
|
12
26
|
* 2. Fetch all sites, exact match on git remote
|
|
13
|
-
* 3. Multiple matches
|
|
14
|
-
* 4. Zero matches
|
|
27
|
+
* 3. Multiple matches -> pick from matches
|
|
28
|
+
* 4. Zero matches -> pick from all sites
|
|
15
29
|
*/
|
|
16
|
-
async function autoDetectSite(
|
|
17
|
-
const gitRoot = getGitRoot(cwd);
|
|
30
|
+
async function autoDetectSite(gitRoot, gitInfo, token) {
|
|
18
31
|
// Cache result to .netlify/state.json so subsequent runs skip API calls
|
|
19
|
-
const cacheResult = (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
return site;
|
|
32
|
+
const cacheResult = (project) => {
|
|
33
|
+
writeLocalNetlifySite(gitRoot, project.id);
|
|
34
|
+
return project;
|
|
24
35
|
};
|
|
25
36
|
// --- Fast path: .netlify/state.json ---
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
repoPath: detail.build_settings?.repo_path ?? null,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
37
|
+
const localSite = readLocalNetlifySite(gitRoot);
|
|
38
|
+
if (localSite) {
|
|
39
|
+
const detail = await getNetlifySiteById(token, localSite.siteId);
|
|
40
|
+
if (detail) {
|
|
41
|
+
return {
|
|
42
|
+
id: detail.id,
|
|
43
|
+
name: detail.name,
|
|
44
|
+
teamId: detail.account_slug,
|
|
45
|
+
};
|
|
38
46
|
}
|
|
39
47
|
}
|
|
40
48
|
// --- Fetch all sites ---
|
|
@@ -61,10 +69,9 @@ async function autoDetectSite(cwd, gitInfo, token) {
|
|
|
61
69
|
if (matches.length === 1) {
|
|
62
70
|
spinner?.stop(`Detected Netlify site: ${pc.bold(matches[0].name)}`);
|
|
63
71
|
return cacheResult({
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
repoPath: matches[0].build_settings?.repo_path ?? null,
|
|
72
|
+
id: matches[0].id,
|
|
73
|
+
name: matches[0].name,
|
|
74
|
+
teamId: matches[0].account_slug,
|
|
68
75
|
});
|
|
69
76
|
}
|
|
70
77
|
if (matches.length > 1) {
|
|
@@ -95,251 +102,47 @@ async function pickFromList(sites) {
|
|
|
95
102
|
}
|
|
96
103
|
const match = selected;
|
|
97
104
|
return {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
repoPath: match.build_settings?.repo_path ?? null,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
// --- Manual picker (used by `inflight netlify` command) ---
|
|
105
|
-
export async function pickNetlifySite(token) {
|
|
106
|
-
const allSites = await getNetlifySites(token);
|
|
107
|
-
if (allSites.length === 0) {
|
|
108
|
-
if (!isAgent)
|
|
109
|
-
p.log.error("No Netlify sites found.");
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
if (isAgent)
|
|
113
|
-
return null;
|
|
114
|
-
const selected = await p.select({
|
|
115
|
-
message: "Select a Netlify site",
|
|
116
|
-
options: allSites.map((s) => ({
|
|
117
|
-
value: s,
|
|
118
|
-
label: s.account_name ? `${s.name} ${pc.dim(`(${s.account_name})`)}` : s.name,
|
|
119
|
-
})),
|
|
120
|
-
});
|
|
121
|
-
if (p.isCancel(selected)) {
|
|
122
|
-
p.cancel("Cancelled.");
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
const match = selected;
|
|
126
|
-
const config = {
|
|
127
|
-
siteId: match.id,
|
|
128
|
-
siteName: match.name,
|
|
129
|
-
teamSlug: match.account_slug,
|
|
105
|
+
id: match.id,
|
|
106
|
+
name: match.name,
|
|
107
|
+
teamId: match.account_slug,
|
|
130
108
|
};
|
|
131
|
-
writeNetlifyConfig(config);
|
|
132
|
-
return config;
|
|
133
109
|
}
|
|
134
|
-
// ---
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const commitSha = gitInfo.commitShort?.slice(0, 7) ?? null;
|
|
158
|
-
// Check if the current git repo matches the Netlify site's linked repo
|
|
159
|
-
const gitRepo = gitInfo.remoteUrl ? parseGitRepo(gitInfo.remoteUrl) : null;
|
|
160
|
-
const localRepoPath = gitRepo ? `${gitRepo.owner}/${gitRepo.name}`.toLowerCase() : null;
|
|
161
|
-
const repoMatches = site.repoPath && localRepoPath ? site.repoPath.toLowerCase() === localRepoPath : false;
|
|
162
|
-
// Only filter by branch if we're in the matching repo — otherwise show all deploys
|
|
163
|
-
let deploys = await getNetlifyDeploys(token, site.siteId, site.siteName, {
|
|
164
|
-
branch: repoMatches ? gitInfo.branch ?? undefined : undefined,
|
|
165
|
-
});
|
|
166
|
-
let commitDeploy = repoMatches ? deploys.find((d) => d.commitRef === commitSha) : undefined;
|
|
167
|
-
// If just pushed, poll until the commit deployment appears
|
|
168
|
-
if (!commitDeploy && repoMatches && opts?.justPushed) {
|
|
169
|
-
const pollSpinner = !isAgent ? p.spinner({ indicator: "timer" }) : null;
|
|
170
|
-
pollSpinner?.start("Waiting for deploy");
|
|
171
|
-
for (let i = 0; i < 30; i++) {
|
|
172
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
173
|
-
deploys = await getNetlifyDeploys(token, site.siteId, site.siteName, {
|
|
174
|
-
branch: repoMatches ? gitInfo.branch ?? undefined : undefined,
|
|
175
|
-
});
|
|
176
|
-
commitDeploy = deploys.find((d) => d.commitRef === commitSha);
|
|
177
|
-
if (commitDeploy)
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
if (commitDeploy) {
|
|
181
|
-
pollSpinner?.clear();
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
pollSpinner?.stop("No deployment detected yet — Netlify may still be processing.");
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
// If we found a commit-specific deployment, handle based on state
|
|
188
|
-
if (commitDeploy) {
|
|
189
|
-
const commitLabel = pc.bold(commitSha);
|
|
190
|
-
const message = commitDeploy.commitMessage
|
|
191
|
-
? pc.dim(` — ${truncate(commitDeploy.commitMessage.split("\n")[0], 50)}`)
|
|
192
|
-
: "";
|
|
193
|
-
if (commitDeploy.state === "error") {
|
|
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.`);
|
|
196
|
-
commitDeploy = undefined;
|
|
197
|
-
// Fall through to the picker
|
|
198
|
-
}
|
|
199
|
-
else if (commitDeploy.state !== "ready") {
|
|
200
|
-
// Any non-ready, non-error state (building, enqueued, uploading, preparing, processing, etc.)
|
|
201
|
-
if (!isAgent)
|
|
202
|
-
p.log.info(`Netlify deployment for ${commitLabel}${message} — ${pc.yellow(commitDeploy.state)}`);
|
|
203
|
-
const buildingUrl = commitDeploy.deploySslUrl;
|
|
204
|
-
const startTime = Date.now();
|
|
205
|
-
const maxWaitMs = 120_000;
|
|
206
|
-
const pollIntervalMs = 5_000;
|
|
207
|
-
let resolved = false;
|
|
208
|
-
const spinner = !isAgent ? p.spinner({ indicator: "timer" }) : null;
|
|
209
|
-
spinner?.start("Waiting for build");
|
|
210
|
-
try {
|
|
211
|
-
while (Date.now() - startTime < maxWaitMs) {
|
|
212
|
-
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
213
|
-
const freshDeps = await getNetlifyDeploys(token, site.siteId, site.siteName, {
|
|
214
|
-
branch: repoMatches ? gitInfo.branch ?? undefined : undefined,
|
|
215
|
-
});
|
|
216
|
-
const fresh = freshDeps.find((d) => d.commitRef === commitSha);
|
|
217
|
-
if (!fresh)
|
|
218
|
-
break;
|
|
219
|
-
if (fresh.state === "ready") {
|
|
220
|
-
spinner?.stop("Netlify deployment ready!");
|
|
221
|
-
if (!isAgent)
|
|
222
|
-
p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(fresh.deploySslUrl)}`);
|
|
223
|
-
return fresh.deploySslUrl;
|
|
224
|
-
}
|
|
225
|
-
if (fresh.state === "error") {
|
|
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.`);
|
|
229
|
-
commitDeploy = undefined;
|
|
230
|
-
resolved = true;
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (!resolved) {
|
|
235
|
-
spinner?.stop("Still building...");
|
|
236
|
-
if (isAgent)
|
|
237
|
-
return buildingUrl;
|
|
238
|
-
const action = await p.select({
|
|
239
|
-
message: "Netlify deployment is still building.",
|
|
240
|
-
options: [
|
|
241
|
-
{ value: "continue", label: "Use it anyway", hint: "URL may not be ready yet" },
|
|
242
|
-
{ value: "pick", label: "Select a different deployment" },
|
|
243
|
-
],
|
|
244
|
-
});
|
|
245
|
-
if (p.isCancel(action)) {
|
|
246
|
-
p.cancel("Cancelled.");
|
|
247
|
-
process.exit(0);
|
|
248
|
-
}
|
|
249
|
-
if (action === "continue") {
|
|
250
|
-
p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(buildingUrl)} ${pc.yellow("(building)")}`);
|
|
251
|
-
return buildingUrl;
|
|
252
|
-
}
|
|
253
|
-
commitDeploy = undefined;
|
|
254
|
-
// Fall through to picker
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
catch (e) {
|
|
258
|
-
spinner?.stop("Error checking deployment status.");
|
|
259
|
-
if (!isAgent && e instanceof Error)
|
|
260
|
-
p.log.message(pc.dim(e.message));
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
// ready — use it
|
|
265
|
-
if (!isAgent)
|
|
266
|
-
p.log.info(`Netlify deployment for ${commitLabel}${message}:\n → ${pc.cyan(commitDeploy.deploySslUrl)}`);
|
|
267
|
-
return commitDeploy.deploySslUrl;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
// Refresh deployments so the picker shows current states
|
|
271
|
-
deploys = await getNetlifyDeploys(token, site.siteId, site.siteName, {
|
|
272
|
-
branch: repoMatches ? gitInfo.branch ?? undefined : undefined,
|
|
273
|
-
});
|
|
274
|
-
// Fallback: no commit deployment — let user pick from recent or paste manually
|
|
275
|
-
if (deploys.length === 0) {
|
|
276
|
-
if (!isAgent)
|
|
277
|
-
p.log.warn("No deployments found. Paste a URL instead.");
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
const hasWorkingDeploy = deploys.some((d) => d.state !== "error");
|
|
281
|
-
if (!hasWorkingDeploy) {
|
|
282
|
-
if (!isAgent)
|
|
283
|
-
p.log.warn("All recent deployments failed. Fix the build and push again, or paste a URL.");
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
if (isAgent) {
|
|
287
|
-
const ready = deploys.find((d) => d.state === "ready");
|
|
288
|
-
return ready?.deploySslUrl ?? deploys[0]?.deploySslUrl ?? null;
|
|
289
|
-
}
|
|
290
|
-
const maxBranch = Math.max(...deploys.map((d) => (d.branch ?? "unknown").length));
|
|
291
|
-
const selected = await p.select({
|
|
292
|
-
message: "No deployment found for current commit. Select one:",
|
|
293
|
-
options: [
|
|
294
|
-
...deploys.map((d) => {
|
|
295
|
-
const isFailed = d.state === "error";
|
|
296
|
-
const branch = (d.branch ?? "unknown").padEnd(maxBranch);
|
|
297
|
-
const ago = timeAgo(d.createdAt).padEnd(8);
|
|
298
|
-
const firstLine = (d.commitMessage ?? "No commit message").split("\n")[0];
|
|
299
|
-
const msg = truncate(firstLine, 55);
|
|
300
|
-
if (isFailed) {
|
|
301
|
-
return {
|
|
302
|
-
value: d.deploySslUrl,
|
|
303
|
-
label: pc.dim(`${branch} ${ago} ${pc.red("(failed)")} ${msg}`),
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
const state = d.state !== "ready" ? ` ${pc.yellow(`(${d.state})`)}` : "";
|
|
307
|
-
return { value: d.deploySslUrl, label: `${branch} ${ago}${state} ${pc.dim(msg)}` };
|
|
308
|
-
}),
|
|
309
|
-
{ value: "manual", label: "Paste a URL manually" },
|
|
310
|
-
],
|
|
311
|
-
});
|
|
312
|
-
if (p.isCancel(selected)) {
|
|
313
|
-
p.cancel("Cancelled.");
|
|
314
|
-
process.exit(0);
|
|
315
|
-
}
|
|
316
|
-
if (selected === "manual")
|
|
317
|
-
return null;
|
|
318
|
-
// Warn if the user picked a failed deployment
|
|
319
|
-
const pickedDeploy = deploys.find((d) => d.deploySslUrl === selected);
|
|
320
|
-
if (pickedDeploy && pickedDeploy.state === "error") {
|
|
321
|
-
if (isAgent)
|
|
110
|
+
// --- Adapter ---
|
|
111
|
+
const netlifyAdapter = {
|
|
112
|
+
name: "Netlify",
|
|
113
|
+
ensureCli: (log) => ensureNetlifyCli(log),
|
|
114
|
+
ensureAuth: () => ensureNetlifyAuth(),
|
|
115
|
+
detectProject: (gitRoot, gitInfo, token) => autoDetectSite(gitRoot, gitInfo, token),
|
|
116
|
+
getDeployments: async (token, project, opts) => {
|
|
117
|
+
const raw = await getNetlifyDeploys(token, project.id, project.name, {
|
|
118
|
+
branch: opts?.branch,
|
|
119
|
+
});
|
|
120
|
+
return raw.map((d) => ({
|
|
121
|
+
id: d.id,
|
|
122
|
+
url: d.deploySslUrl,
|
|
123
|
+
state: normalizeNetlifyState(d.state),
|
|
124
|
+
branch: d.branch,
|
|
125
|
+
commitSha: d.commitRef,
|
|
126
|
+
commitMessage: d.commitMessage,
|
|
127
|
+
createdAt: d.createdAt,
|
|
128
|
+
}));
|
|
129
|
+
},
|
|
130
|
+
getDeploymentById: async (token, project, deployId) => {
|
|
131
|
+
const raw = await getNetlifyDeployById(token, deployId, project.name);
|
|
132
|
+
if (!raw)
|
|
322
133
|
return null;
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const minutes = Math.floor(seconds / 60);
|
|
338
|
-
if (minutes < 60)
|
|
339
|
-
return `${minutes}m ago`;
|
|
340
|
-
const hours = Math.floor(minutes / 60);
|
|
341
|
-
if (hours < 24)
|
|
342
|
-
return `${hours}h ago`;
|
|
343
|
-
const days = Math.floor(hours / 24);
|
|
344
|
-
return `${days}d ago`;
|
|
134
|
+
return {
|
|
135
|
+
id: raw.id,
|
|
136
|
+
url: raw.deploySslUrl,
|
|
137
|
+
state: normalizeNetlifyState(raw.state),
|
|
138
|
+
branch: raw.branch,
|
|
139
|
+
commitSha: raw.commitRef,
|
|
140
|
+
commitMessage: raw.commitMessage,
|
|
141
|
+
createdAt: raw.createdAt,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
// --- Main resolve function ---
|
|
146
|
+
export async function resolveNetlifyUrl(gitRoot, gitInfo, opts) {
|
|
147
|
+
return resolveDeploymentUrl(netlifyAdapter, gitRoot, gitInfo, opts);
|
|
345
148
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GitInfo } from "../lib/git.js";
|
|
2
|
+
import type { ProviderResolveOptions } from "./index.js";
|
|
3
|
+
export interface Deployment {
|
|
4
|
+
id: string;
|
|
5
|
+
url: string;
|
|
6
|
+
/** Normalized: "ready" | "building" | "error" | "queued" */
|
|
7
|
+
state: "ready" | "building" | "error" | "queued";
|
|
8
|
+
branch: string | null;
|
|
9
|
+
commitSha: string | null;
|
|
10
|
+
commitMessage: string | null;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
}
|
|
13
|
+
export interface DetectedProject {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
/** teamId for Vercel, account_slug for Netlify — passed through to API calls */
|
|
17
|
+
teamId: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ProviderAdapter {
|
|
20
|
+
/** Display name: "Vercel" | "Netlify" */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Ensure CLI is installed, install if missing */
|
|
23
|
+
ensureCli(log?: (msg: string) => void): Promise<{
|
|
24
|
+
ok: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** Ensure authenticated, return token or null */
|
|
28
|
+
ensureAuth(): Promise<string | null>;
|
|
29
|
+
/** Auto-detect or prompt for project. All provider-specific detection/picker logic lives here. */
|
|
30
|
+
detectProject(gitRoot: string, gitInfo: GitInfo, token: string): Promise<DetectedProject | null>;
|
|
31
|
+
/** Fetch deployments. Adapter normalizes states to Deployment format. */
|
|
32
|
+
getDeployments(token: string, project: DetectedProject, opts?: {
|
|
33
|
+
branch?: string;
|
|
34
|
+
sha?: string;
|
|
35
|
+
}): Promise<Deployment[]>;
|
|
36
|
+
/** Fetch a single deployment by ID. Used for polling build state. */
|
|
37
|
+
getDeploymentById(token: string, project: DetectedProject, deploymentId: string): Promise<Deployment | null>;
|
|
38
|
+
}
|
|
39
|
+
export declare function resolveDeploymentUrl(adapter: ProviderAdapter, gitRoot: string, gitInfo: GitInfo, opts?: ProviderResolveOptions): Promise<string | null>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { isAgent, agentError, agentActionRequired } from "../lib/agent.js";
|
|
4
|
+
// --- Shared resolution logic ---
|
|
5
|
+
export async function resolveDeploymentUrl(adapter, gitRoot, gitInfo, opts) {
|
|
6
|
+
// 1. Ensure CLI
|
|
7
|
+
const cli = await adapter.ensureCli((msg) => {
|
|
8
|
+
if (!isAgent)
|
|
9
|
+
p.log.step(msg);
|
|
10
|
+
});
|
|
11
|
+
if (!cli.ok) {
|
|
12
|
+
if (isAgent) {
|
|
13
|
+
agentError({
|
|
14
|
+
type: "cli_install_failed",
|
|
15
|
+
message: `Could not install the ${adapter.name} CLI.`,
|
|
16
|
+
...(cli.error && { detail: cli.error.trim() }),
|
|
17
|
+
suggestion: `Install the ${adapter.name} CLI globally, fix any errors, then re-run \`inflight share\`.`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
p.log.error(`Could not install the ${adapter.name} CLI automatically.`);
|
|
21
|
+
if (cli.error)
|
|
22
|
+
p.log.message(pc.dim(cli.error.trim()));
|
|
23
|
+
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")}.`);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
// 2. Ensure auth
|
|
27
|
+
const token = await adapter.ensureAuth();
|
|
28
|
+
if (!token) {
|
|
29
|
+
if (isAgent) {
|
|
30
|
+
agentError({
|
|
31
|
+
type: "auth_failed",
|
|
32
|
+
message: `${adapter.name} login failed.`,
|
|
33
|
+
suggestion: `Run \`${adapter.name.toLowerCase()} login\` to authenticate, then re-run \`inflight share\`.`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
p.log.error(`${adapter.name} login failed.`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// 3. Detect project
|
|
40
|
+
const project = await adapter.detectProject(gitRoot, gitInfo, token);
|
|
41
|
+
if (!project) {
|
|
42
|
+
if (isAgent) {
|
|
43
|
+
agentError({
|
|
44
|
+
type: "no_project",
|
|
45
|
+
message: `Could not detect a ${adapter.name} project for this repo.`,
|
|
46
|
+
suggestion: `Make sure you're in the right directory. Link your repo to a ${adapter.name} project, or re-run with: \`inflight share --url <staging-url>\` to skip provider detection.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const branch = gitInfo.branch ?? undefined;
|
|
52
|
+
// 4. Fetch deployments for current branch, filtered by SHA if supported
|
|
53
|
+
let deployments = await adapter.getDeployments(token, project, { branch, sha: gitInfo.commitFull });
|
|
54
|
+
// 5. Try SHA match
|
|
55
|
+
let currentDeployment = deployments.find((d) => d.commitSha === gitInfo.commitFull);
|
|
56
|
+
// 6. If just pushed, poll for the new deployment to appear
|
|
57
|
+
if (!currentDeployment && opts?.justPushed) {
|
|
58
|
+
const pollSpinner = !isAgent ? p.spinner({ indicator: "timer" }) : null;
|
|
59
|
+
pollSpinner?.start("Waiting for deploy");
|
|
60
|
+
for (let i = 0; i < 30; i++) {
|
|
61
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
62
|
+
const results = await adapter.getDeployments(token, project, { branch, sha: gitInfo.commitFull });
|
|
63
|
+
currentDeployment = results.find((d) => d.commitSha === gitInfo.commitFull);
|
|
64
|
+
if (currentDeployment)
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (currentDeployment) {
|
|
68
|
+
pollSpinner?.clear();
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
pollSpinner?.stop(`No deployment detected yet — ${adapter.name} may still be processing.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 7. Handle commit-specific deployment states
|
|
75
|
+
if (currentDeployment) {
|
|
76
|
+
// Agent mode: return URL immediately — don't wait for build/error
|
|
77
|
+
if (isAgent)
|
|
78
|
+
return currentDeployment.url;
|
|
79
|
+
const commitLabel = pc.bold(gitInfo.commitShort);
|
|
80
|
+
const message = currentDeployment.commitMessage
|
|
81
|
+
? pc.dim(` — ${truncate(currentDeployment.commitMessage.split("\n")[0], 50)}`)
|
|
82
|
+
: "";
|
|
83
|
+
switch (currentDeployment.state) {
|
|
84
|
+
case "ready":
|
|
85
|
+
p.log.info(`${adapter.name} deployment for ${commitLabel}${message}`);
|
|
86
|
+
return currentDeployment.url;
|
|
87
|
+
case "error":
|
|
88
|
+
p.log.error(`${adapter.name} deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
|
|
89
|
+
currentDeployment = undefined;
|
|
90
|
+
// Fall through to picker
|
|
91
|
+
break;
|
|
92
|
+
case "building":
|
|
93
|
+
case "queued": {
|
|
94
|
+
p.log.info(`${adapter.name} deployment for ${commitLabel}${message} — ${pc.yellow(currentDeployment.state)}`);
|
|
95
|
+
// Poll for build to finish (~2 min)
|
|
96
|
+
const buildingUrl = currentDeployment.url;
|
|
97
|
+
const startTime = Date.now();
|
|
98
|
+
const maxWaitMs = 120_000;
|
|
99
|
+
const pollIntervalMs = 3_000;
|
|
100
|
+
let resolved = false;
|
|
101
|
+
const spinner = p.spinner({ indicator: "timer" });
|
|
102
|
+
spinner.start("Waiting for build");
|
|
103
|
+
try {
|
|
104
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
105
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
106
|
+
const fresh = await adapter.getDeploymentById(token, project, currentDeployment.id);
|
|
107
|
+
if (!fresh)
|
|
108
|
+
break;
|
|
109
|
+
if (fresh.state === "ready") {
|
|
110
|
+
spinner.stop(`${adapter.name} deployment ready!`);
|
|
111
|
+
return fresh.url;
|
|
112
|
+
}
|
|
113
|
+
if (fresh.state === "error") {
|
|
114
|
+
spinner.stop(pc.red(`${adapter.name} deployment failed.`));
|
|
115
|
+
p.log.error(`${adapter.name} deployment for ${commitLabel}${message} ${pc.red("failed")}.\n Fix the build and push again, or select a different deployment below.`);
|
|
116
|
+
currentDeployment = undefined;
|
|
117
|
+
resolved = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!resolved) {
|
|
122
|
+
spinner.stop("Still building...");
|
|
123
|
+
const action = await p.select({
|
|
124
|
+
message: `${adapter.name} deployment is still building.`,
|
|
125
|
+
options: [
|
|
126
|
+
{ value: "continue", label: "Use it anyway", hint: "URL may not be ready yet" },
|
|
127
|
+
{ value: "pick", label: "Select a different deployment" },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
if (p.isCancel(action)) {
|
|
131
|
+
p.cancel("Cancelled.");
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
if (action === "continue") {
|
|
135
|
+
p.log.info(`${adapter.name} deployment for ${commitLabel}${message}:\n → ${pc.cyan(buildingUrl)} ${pc.yellow("(building)")}`);
|
|
136
|
+
return buildingUrl;
|
|
137
|
+
}
|
|
138
|
+
currentDeployment = undefined;
|
|
139
|
+
// Fall through to picker
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
spinner.stop("Error checking deployment status.");
|
|
144
|
+
if (e instanceof Error)
|
|
145
|
+
p.log.message(pc.dim(e.message));
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 8. Picker fallback — fetch all branch deployments (no SHA filter)
|
|
152
|
+
deployments = await adapter.getDeployments(token, project, { branch });
|
|
153
|
+
if (deployments.length === 0) {
|
|
154
|
+
if (isAgent) {
|
|
155
|
+
agentError({
|
|
156
|
+
type: "no_deployments",
|
|
157
|
+
message: `No deployments found for this branch. Ask the user for their staging URL.`,
|
|
158
|
+
suggestion: "Re-run with: `inflight share --url <staging-url>`",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
p.log.warn("No deployments found for this branch. Paste a URL instead.");
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const hasWorkingDeployment = deployments.some((d) => d.state !== "error");
|
|
165
|
+
if (!hasWorkingDeployment) {
|
|
166
|
+
if (isAgent) {
|
|
167
|
+
agentError({
|
|
168
|
+
type: "all_deployments_failed",
|
|
169
|
+
message: "All recent deployments failed. Ask the user to fix the build and push again, or provide a URL.",
|
|
170
|
+
suggestion: "Re-run with: `inflight share --url <staging-url>`",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
p.log.warn("All recent deployments failed. Fix the build and push again, or paste a URL.");
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (isAgent) {
|
|
177
|
+
agentActionRequired({
|
|
178
|
+
type: "choose_deployment",
|
|
179
|
+
message: "No deployment found for the current commit. Ask the user which deployment to use.",
|
|
180
|
+
choices: [
|
|
181
|
+
...deployments.map((d) => ({
|
|
182
|
+
id: d.url,
|
|
183
|
+
label: d.commitMessage ? truncate(d.commitMessage.split("\n")[0], 55) : "No commit message",
|
|
184
|
+
hint: [
|
|
185
|
+
timeAgo(d.createdAt),
|
|
186
|
+
d.branch ?? "unknown",
|
|
187
|
+
d.state === "error" ? "(failed)" : d.state !== "ready" ? `(${d.state})` : undefined,
|
|
188
|
+
].filter(Boolean).join(" · "),
|
|
189
|
+
})),
|
|
190
|
+
{ id: "manual", label: "Paste a URL" },
|
|
191
|
+
],
|
|
192
|
+
nextCommand: `inflight share --skip-git-check --url <URL>`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const maxBranch = Math.max(...deployments.map((d) => (d.branch ?? "unknown").length));
|
|
196
|
+
const selected = await p.select({
|
|
197
|
+
message: "No deployment found for current commit. Select one:",
|
|
198
|
+
options: [
|
|
199
|
+
...deployments.map((d) => {
|
|
200
|
+
const isFailed = d.state === "error";
|
|
201
|
+
const branchLabel = (d.branch ?? "unknown").padEnd(maxBranch);
|
|
202
|
+
const ago = timeAgo(d.createdAt).padEnd(8);
|
|
203
|
+
const firstLine = (d.commitMessage ?? "No commit message").split("\n")[0];
|
|
204
|
+
const msg = truncate(firstLine, 55);
|
|
205
|
+
if (isFailed) {
|
|
206
|
+
return {
|
|
207
|
+
value: d.url,
|
|
208
|
+
label: pc.dim(`${branchLabel} ${ago} ${pc.red("(failed)")} ${msg}`),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const state = d.state !== "ready" ? ` ${pc.yellow(`(${d.state})`)}` : "";
|
|
212
|
+
return { value: d.url, label: `${branchLabel} ${ago}${state} ${pc.dim(msg)}` };
|
|
213
|
+
}),
|
|
214
|
+
{ value: "manual", label: "Paste a URL manually" },
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
if (p.isCancel(selected)) {
|
|
218
|
+
p.cancel("Cancelled.");
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
if (selected === "manual")
|
|
222
|
+
return null;
|
|
223
|
+
// Warn if the user picked a failed deployment
|
|
224
|
+
const pickedDeploy = deployments.find((d) => d.url === selected);
|
|
225
|
+
if (pickedDeploy?.state === "error") {
|
|
226
|
+
p.log.warn("This deployment failed — the URL may not load.");
|
|
227
|
+
const confirm = await p.confirm({ message: "Use it anyway?" });
|
|
228
|
+
if (p.isCancel(confirm) || !confirm)
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return selected;
|
|
232
|
+
}
|
|
233
|
+
// --- Helpers ---
|
|
234
|
+
function truncate(str, max) {
|
|
235
|
+
return str.length > max ? str.slice(0, max - 1) + "…" : str;
|
|
236
|
+
}
|
|
237
|
+
function timeAgo(timestamp) {
|
|
238
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
239
|
+
if (seconds < 60)
|
|
240
|
+
return "now";
|
|
241
|
+
const minutes = Math.floor(seconds / 60);
|
|
242
|
+
if (minutes < 60)
|
|
243
|
+
return `${minutes}m ago`;
|
|
244
|
+
const hours = Math.floor(minutes / 60);
|
|
245
|
+
if (hours < 24)
|
|
246
|
+
return `${hours}h ago`;
|
|
247
|
+
const days = Math.floor(hours / 24);
|
|
248
|
+
return `${days}d ago`;
|
|
249
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { GitInfo } from "../lib/git.js";
|
|
2
2
|
import type { ProviderResolveOptions } from "./index.js";
|
|
3
|
-
export declare function resolveVercelUrl(
|
|
3
|
+
export declare function resolveVercelUrl(gitRoot: string, gitInfo: GitInfo, opts?: ProviderResolveOptions): Promise<string | null>;
|