inflight-cli 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/dist/commands/create.d.ts +1 -3
- package/dist/commands/create.js +30 -27
- package/dist/commands/login.js +2 -2
- package/dist/commands/share.d.ts +1 -0
- package/dist/commands/share.js +110 -0
- package/dist/index.js +4 -5
- package/dist/lib/api.d.ts +0 -1
- package/dist/lib/api.js +0 -1
- package/dist/lib/config.d.ts +8 -4
- package/dist/lib/config.js +51 -12
- package/dist/lib/vercel.d.ts +24 -3
- package/dist/lib/vercel.js +170 -11
- package/dist/providers/index.d.ts +9 -0
- package/dist/providers/index.js +4 -0
- package/dist/providers/vercel.d.ts +3 -0
- package/dist/providers/vercel.js +78 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -18,17 +18,24 @@ Authenticate with your Inflight account:
|
|
|
18
18
|
inflight login
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
This opens a browser window to complete authentication. Your credentials are stored in `~/.inflight/
|
|
21
|
+
This opens a browser window to complete authentication. Your credentials are stored in `~/Library/Application Support/co.inflight.cli/auth.json` (macOS) or `~/.local/share/co.inflight.cli/auth.json` (Linux).
|
|
22
22
|
|
|
23
|
-
###
|
|
23
|
+
### Share a version
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Share a new design version for the current project:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
inflight
|
|
28
|
+
inflight share
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
This will:
|
|
32
|
+
1. Detect your git branch and commit info automatically
|
|
33
|
+
2. Ask where your staging URL is hosted (Vercel or paste a URL)
|
|
34
|
+
3. Create a version on Inflight and open it in your browser
|
|
35
|
+
|
|
36
|
+
On first run, you'll be prompted to link your Vercel project and select a workspace. These are saved locally so subsequent runs are fast.
|
|
37
|
+
|
|
38
|
+
Git context (branch, commit, diff) is captured automatically and used to generate a version title and feedback questions.
|
|
32
39
|
|
|
33
40
|
## Requirements
|
|
34
41
|
|
package/dist/commands/create.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
-
import {
|
|
3
|
+
import { readGlobalAuth, readWorkspaceConfig, writeWorkspaceConfig } from "../lib/config.js";
|
|
4
4
|
import { getGitInfo, isGitRepo } from "../lib/git.js";
|
|
5
|
-
import {
|
|
5
|
+
import { providers } from "../providers/index.js";
|
|
6
6
|
import { apiGetMe, apiCreateVersion } from "../lib/api.js";
|
|
7
|
-
export async function createCommand(
|
|
7
|
+
export async function createCommand() {
|
|
8
8
|
const cwd = process.cwd();
|
|
9
|
-
const
|
|
10
|
-
if (!
|
|
9
|
+
const auth = readGlobalAuth();
|
|
10
|
+
if (!auth) {
|
|
11
11
|
p.log.error("Not logged in. Run " + pc.cyan("inflight login") + " first.");
|
|
12
12
|
process.exit(1);
|
|
13
13
|
}
|
|
14
|
-
p.intro(pc.bgBlue(pc.white(" inflight
|
|
15
|
-
// Resolve workspace —
|
|
16
|
-
let workspaceId =
|
|
14
|
+
p.intro(pc.bgBlue(pc.white(" inflight share ")));
|
|
15
|
+
// Resolve workspace — read from .inflight/workspace.json, prompt if not linked
|
|
16
|
+
let workspaceId = readWorkspaceConfig(cwd)?.workspaceId;
|
|
17
17
|
if (!workspaceId) {
|
|
18
18
|
const spinner = p.spinner();
|
|
19
19
|
spinner.start("Loading workspaces...");
|
|
20
|
-
const me = await apiGetMe(
|
|
20
|
+
const me = await apiGetMe(auth.apiKey, auth.apiUrl).catch((e) => {
|
|
21
21
|
spinner.stop("Failed.");
|
|
22
22
|
p.log.error(e.message);
|
|
23
23
|
process.exit(1);
|
|
@@ -42,31 +42,34 @@ export async function createCommand(opts) {
|
|
|
42
42
|
}
|
|
43
43
|
workspaceId = selected;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
writeWorkspaceConfig(cwd, { workspaceId });
|
|
46
46
|
}
|
|
47
47
|
// Git info
|
|
48
48
|
const gitInfo = isGitRepo(cwd)
|
|
49
49
|
? getGitInfo(cwd)
|
|
50
50
|
: { branch: null, commitShort: null, commitFull: null, commitMessage: null, remoteUrl: null, isDirty: false, diff: null };
|
|
51
|
-
// Title
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
// Title is auto-generated server-side from the diff/branch/commit
|
|
52
|
+
const title = gitInfo.branch ?? "Untitled";
|
|
53
|
+
// Staging URL — user picks provider
|
|
54
|
+
const providerChoice = await p.select({
|
|
55
|
+
message: "Where is your staging URL hosted?",
|
|
56
|
+
options: [
|
|
57
|
+
...providers.map((prov) => ({ value: prov.id, label: prov.label })),
|
|
58
|
+
{ value: "manual", label: "Paste a URL" },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
if (p.isCancel(providerChoice)) {
|
|
60
62
|
p.cancel("Cancelled.");
|
|
61
63
|
process.exit(0);
|
|
62
64
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
let stagingUrl
|
|
66
|
-
if (
|
|
67
|
-
|
|
65
|
+
const providerSpinner = p.spinner();
|
|
66
|
+
const provider = providers.find((prov) => prov.id === providerChoice);
|
|
67
|
+
let stagingUrl;
|
|
68
|
+
if (provider) {
|
|
69
|
+
stagingUrl = (await provider.resolve(cwd, gitInfo, providerSpinner)) ?? undefined;
|
|
68
70
|
}
|
|
69
|
-
|
|
71
|
+
// Manual input (or fallback if provider returned no URL)
|
|
72
|
+
if (!stagingUrl) {
|
|
70
73
|
const input = await p.text({
|
|
71
74
|
message: "Staging URL",
|
|
72
75
|
placeholder: "https://my-branch.vercel.app",
|
|
@@ -90,8 +93,8 @@ export async function createCommand(opts) {
|
|
|
90
93
|
const spinner = p.spinner();
|
|
91
94
|
spinner.start("Creating version...");
|
|
92
95
|
const result = await apiCreateVersion({
|
|
93
|
-
apiKey:
|
|
94
|
-
apiUrl:
|
|
96
|
+
apiKey: auth.apiKey,
|
|
97
|
+
apiUrl: auth.apiUrl,
|
|
95
98
|
workspaceId,
|
|
96
99
|
title,
|
|
97
100
|
stagingUrl,
|
package/dist/commands/login.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import pc from "picocolors";
|
|
3
3
|
import { createServer } from "http";
|
|
4
|
-
import {
|
|
4
|
+
import { writeGlobalAuth } from "../lib/config.js";
|
|
5
5
|
import { apiGetMe } from "../lib/api.js";
|
|
6
6
|
const DEFAULT_API_URL = process.env.INFLIGHT_API_URL ?? "https://api.inflight.co";
|
|
7
7
|
const WEB_URL = process.env.INFLIGHT_WEB_URL ?? "https://inflight.co";
|
|
@@ -18,7 +18,7 @@ export async function loginCommand() {
|
|
|
18
18
|
process.exit(1);
|
|
19
19
|
});
|
|
20
20
|
spinner.stop(`Authenticated as ${pc.bold(me.name)}`);
|
|
21
|
-
|
|
21
|
+
writeGlobalAuth({ apiKey, apiUrl });
|
|
22
22
|
p.outro(pc.green("✓ Logged in successfully"));
|
|
23
23
|
process.exit(0);
|
|
24
24
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function shareCommand(): Promise<void>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { readGlobalAuth, readWorkspaceConfig, writeWorkspaceConfig } from "../lib/config.js";
|
|
4
|
+
import { getGitInfo, isGitRepo } from "../lib/git.js";
|
|
5
|
+
import { providers } from "../providers/index.js";
|
|
6
|
+
import { apiGetMe, apiCreateVersion } from "../lib/api.js";
|
|
7
|
+
export async function shareCommand() {
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const auth = readGlobalAuth();
|
|
10
|
+
if (!auth) {
|
|
11
|
+
p.log.error("Not logged in. Run " + pc.cyan("inflight login") + " first.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
p.intro(pc.bgBlue(pc.white(" inflight share ")));
|
|
15
|
+
// Resolve workspace — read from .inflight/workspace.json, prompt if not linked
|
|
16
|
+
let workspaceId = readWorkspaceConfig(cwd)?.workspaceId;
|
|
17
|
+
if (!workspaceId) {
|
|
18
|
+
const spinner = p.spinner();
|
|
19
|
+
spinner.start("Loading workspaces...");
|
|
20
|
+
const me = await apiGetMe(auth.apiKey, auth.apiUrl).catch((e) => {
|
|
21
|
+
spinner.stop("Failed.");
|
|
22
|
+
p.log.error(e.message);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
|
25
|
+
spinner.stop("");
|
|
26
|
+
if (me.workspaces.length === 0) {
|
|
27
|
+
p.log.error("No workspaces found. Create one at inflight.co first.");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
else if (me.workspaces.length === 1) {
|
|
31
|
+
workspaceId = me.workspaces[0].id;
|
|
32
|
+
p.log.info(`Workspace: ${pc.bold(me.workspaces[0].name)}`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const selected = await p.select({
|
|
36
|
+
message: "Select a workspace",
|
|
37
|
+
options: me.workspaces.map((w) => ({ value: w.id, label: w.name })),
|
|
38
|
+
});
|
|
39
|
+
if (p.isCancel(selected)) {
|
|
40
|
+
p.cancel("Cancelled.");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
workspaceId = selected;
|
|
44
|
+
}
|
|
45
|
+
writeWorkspaceConfig(cwd, { workspaceId });
|
|
46
|
+
}
|
|
47
|
+
// Git info
|
|
48
|
+
const gitInfo = isGitRepo(cwd)
|
|
49
|
+
? getGitInfo(cwd)
|
|
50
|
+
: { branch: null, commitShort: null, commitFull: null, commitMessage: null, remoteUrl: null, isDirty: false, diff: null };
|
|
51
|
+
// Staging URL — user picks provider
|
|
52
|
+
const providerChoice = await p.select({
|
|
53
|
+
message: "Where is your staging URL hosted?",
|
|
54
|
+
options: [
|
|
55
|
+
...providers.map((prov) => ({ value: prov.id, label: prov.label })),
|
|
56
|
+
{ value: "manual", label: "Paste a URL" },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
if (p.isCancel(providerChoice)) {
|
|
60
|
+
p.cancel("Cancelled.");
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
const providerSpinner = p.spinner();
|
|
64
|
+
const provider = providers.find((prov) => prov.id === providerChoice);
|
|
65
|
+
let stagingUrl;
|
|
66
|
+
if (provider) {
|
|
67
|
+
stagingUrl = (await provider.resolve(cwd, gitInfo, providerSpinner)) ?? undefined;
|
|
68
|
+
}
|
|
69
|
+
// Manual input (or fallback if provider returned no URL)
|
|
70
|
+
if (!stagingUrl) {
|
|
71
|
+
const input = await p.text({
|
|
72
|
+
message: "Staging URL",
|
|
73
|
+
placeholder: "https://my-branch.vercel.app",
|
|
74
|
+
validate: (v) => {
|
|
75
|
+
if (!v)
|
|
76
|
+
return "Staging URL is required";
|
|
77
|
+
try {
|
|
78
|
+
new URL(v);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return "Must be a valid URL (include https://)";
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
if (p.isCancel(input)) {
|
|
86
|
+
p.cancel("Cancelled.");
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
stagingUrl = input;
|
|
90
|
+
}
|
|
91
|
+
const spinner = p.spinner();
|
|
92
|
+
// spinner.start("Creating version...");
|
|
93
|
+
const result = await apiCreateVersion({
|
|
94
|
+
apiKey: auth.apiKey,
|
|
95
|
+
apiUrl: auth.apiUrl,
|
|
96
|
+
workspaceId,
|
|
97
|
+
stagingUrl,
|
|
98
|
+
gitInfo,
|
|
99
|
+
}).catch((e) => {
|
|
100
|
+
spinner.stop("Failed.");
|
|
101
|
+
p.log.error(e.message);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
});
|
|
104
|
+
// spinner.stop("Version created");
|
|
105
|
+
p.note(result.inflightUrl, "Your Inflight version");
|
|
106
|
+
p.outro(pc.green("✓ Done") + " — opening Inflight...");
|
|
107
|
+
const { default: open } = await import("open");
|
|
108
|
+
await open(result.inflightUrl);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { loginCommand } from "./commands/login.js";
|
|
4
|
-
import {
|
|
4
|
+
import { shareCommand } from "./commands/share.js";
|
|
5
5
|
const program = new Command();
|
|
6
6
|
program
|
|
7
7
|
.name("inflight")
|
|
@@ -12,8 +12,7 @@ program
|
|
|
12
12
|
.description("Authenticate with your Inflight account")
|
|
13
13
|
.action(loginCommand);
|
|
14
14
|
program
|
|
15
|
-
.command("
|
|
16
|
-
.description("
|
|
17
|
-
.
|
|
18
|
-
.action((opts) => createCommand({ title: opts.title }));
|
|
15
|
+
.command("share")
|
|
16
|
+
.description("Share a new version for this project")
|
|
17
|
+
.action(() => shareCommand());
|
|
19
18
|
program.parse();
|
package/dist/lib/api.d.ts
CHANGED
package/dist/lib/api.js
CHANGED
package/dist/lib/config.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface GlobalAuth {
|
|
2
2
|
apiKey: string;
|
|
3
3
|
apiUrl: string;
|
|
4
|
-
workspaceId?: string;
|
|
5
4
|
}
|
|
6
|
-
export declare function
|
|
7
|
-
export declare function
|
|
5
|
+
export declare function readGlobalAuth(): GlobalAuth | null;
|
|
6
|
+
export declare function writeGlobalAuth(auth: GlobalAuth): void;
|
|
7
|
+
export interface WorkspaceConfig {
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function readWorkspaceConfig(cwd: string): WorkspaceConfig | null;
|
|
11
|
+
export declare function writeWorkspaceConfig(cwd: string, config: WorkspaceConfig): void;
|
package/dist/lib/config.js
CHANGED
|
@@ -1,22 +1,61 @@
|
|
|
1
|
-
import { homedir } from "os";
|
|
1
|
+
import { homedir, platform } from "os";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (
|
|
4
|
+
function getGlobalConfigDir() {
|
|
5
|
+
if (platform() === "darwin") {
|
|
6
|
+
return join(homedir(), "Library", "Application Support", "co.inflight.cli");
|
|
7
|
+
}
|
|
8
|
+
if (platform() === "win32") {
|
|
9
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "co.inflight.cli");
|
|
10
|
+
}
|
|
11
|
+
return join(homedir(), ".local", "share", "co.inflight.cli");
|
|
12
|
+
}
|
|
13
|
+
const AUTH_FILE = join(getGlobalConfigDir(), "auth.json");
|
|
14
|
+
const DEFAULT_API_URL = process.env.INFLIGHT_API_URL ?? "https://api.inflight.co";
|
|
15
|
+
export function readGlobalAuth() {
|
|
16
|
+
if (!existsSync(AUTH_FILE))
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const auth = JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
|
|
20
|
+
return { ...auth, apiUrl: DEFAULT_API_URL };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function writeGlobalAuth(auth) {
|
|
27
|
+
mkdirSync(getGlobalConfigDir(), { recursive: true });
|
|
28
|
+
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
// --- Project-level config (per-directory, like .vercel/project.json) ---
|
|
31
|
+
const WORKSPACE_FILE = ".inflight/workspace.json";
|
|
32
|
+
export function readWorkspaceConfig(cwd) {
|
|
33
|
+
const file = join(cwd, WORKSPACE_FILE);
|
|
34
|
+
if (!existsSync(file))
|
|
9
35
|
return null;
|
|
10
36
|
try {
|
|
11
|
-
|
|
12
|
-
// Allow env var to override stored API URL (useful for local dev)
|
|
13
|
-
return { ...config, apiUrl: DEFAULT_API_URL };
|
|
37
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
14
38
|
}
|
|
15
39
|
catch {
|
|
16
40
|
return null;
|
|
17
41
|
}
|
|
18
42
|
}
|
|
19
|
-
export function
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
export function writeWorkspaceConfig(cwd, config) {
|
|
44
|
+
const dir = join(cwd, ".inflight");
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
writeFileSync(join(cwd, WORKSPACE_FILE), JSON.stringify(config, null, 2));
|
|
47
|
+
addToGitignore(cwd);
|
|
48
|
+
}
|
|
49
|
+
function addToGitignore(cwd) {
|
|
50
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
51
|
+
const entry = ".inflight";
|
|
52
|
+
try {
|
|
53
|
+
const contents = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
|
|
54
|
+
if (!contents.split("\n").some((line) => line.trim() === entry)) {
|
|
55
|
+
writeFileSync(gitignorePath, contents + (contents.endsWith("\n") ? "" : "\n") + entry + "\n");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// non-fatal
|
|
60
|
+
}
|
|
22
61
|
}
|
package/dist/lib/vercel.d.ts
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
|
+
export declare function isVercelLoggedIn(): Promise<boolean>;
|
|
2
|
+
export declare function isVercelLinked(cwd: string): boolean;
|
|
3
|
+
/** Opens browser-based Vercel login — suppresses CLI output, browser opens automatically. */
|
|
4
|
+
export declare function vercelLogin(): boolean;
|
|
5
|
+
export interface VercelTeam {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
}
|
|
10
|
+
export interface VercelProject {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function getVercelTeams(): Promise<VercelTeam[]>;
|
|
15
|
+
export declare function getVercelProjects(teamId: string): Promise<VercelProject[]>;
|
|
16
|
+
/** Links the directory to a specific Vercel project silently. */
|
|
17
|
+
export declare function vercelLink(cwd: string, projectId: string): Promise<boolean>;
|
|
18
|
+
export interface VercelDeployment {
|
|
19
|
+
url: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
1
22
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
23
|
+
* Fetches Vercel deployments for the current commit SHA.
|
|
24
|
+
* Falls back to recent deployments if no SHA match is found.
|
|
4
25
|
*/
|
|
5
|
-
export declare function
|
|
26
|
+
export declare function getVercelDeployments(cwd: string, commitSha: string | null, branch: string | null): Promise<VercelDeployment[]>;
|
package/dist/lib/vercel.js
CHANGED
|
@@ -1,18 +1,177 @@
|
|
|
1
|
+
import { execSync, spawnSync, spawn } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
1
5
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
6
|
+
* Returns the vercel command to use — the installed CLI if available,
|
|
7
|
+
* otherwise falls back to npx so users don't need a global install.
|
|
4
8
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return
|
|
9
|
+
function getVercelCmd() {
|
|
10
|
+
try {
|
|
11
|
+
execSync("vercel --version", { stdio: "pipe" });
|
|
12
|
+
return { cmd: "vercel", args: [] };
|
|
9
13
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return process.env.DEPLOY_PRIME_URL;
|
|
14
|
+
catch {
|
|
15
|
+
return { cmd: "npx", args: ["--yes", "vercel"] };
|
|
13
16
|
}
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
}
|
|
18
|
+
export function isVercelLoggedIn() {
|
|
19
|
+
const { cmd, args } = getVercelCmd();
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const child = spawn(cmd, [...args, "whoami"], { stdio: "pipe" });
|
|
22
|
+
child.on("close", (code) => resolve(code === 0));
|
|
23
|
+
child.on("error", () => resolve(false));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function isVercelLinked(cwd) {
|
|
27
|
+
return existsSync(join(cwd, ".vercel", "project.json"));
|
|
28
|
+
}
|
|
29
|
+
/** Opens browser-based Vercel login — suppresses CLI output, browser opens automatically. */
|
|
30
|
+
export function vercelLogin() {
|
|
31
|
+
const { cmd, args } = getVercelCmd();
|
|
32
|
+
const result = spawnSync(cmd, [...args, "login"], { stdio: "pipe" });
|
|
33
|
+
return result.status === 0;
|
|
34
|
+
}
|
|
35
|
+
export async function getVercelTeams() {
|
|
36
|
+
const token = getVercelAuthToken();
|
|
37
|
+
if (!token)
|
|
38
|
+
return [];
|
|
39
|
+
const res = await fetch("https://api.vercel.com/v2/teams", {
|
|
40
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
return [];
|
|
44
|
+
const data = (await res.json());
|
|
45
|
+
return data.teams;
|
|
46
|
+
}
|
|
47
|
+
export async function getVercelProjects(teamId) {
|
|
48
|
+
const token = getVercelAuthToken();
|
|
49
|
+
if (!token)
|
|
50
|
+
return [];
|
|
51
|
+
const params = new URLSearchParams({ teamId, limit: "100" });
|
|
52
|
+
const res = await fetch(`https://api.vercel.com/v10/projects?${params}`, {
|
|
53
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
return [];
|
|
57
|
+
const data = (await res.json());
|
|
58
|
+
return data.projects;
|
|
59
|
+
}
|
|
60
|
+
/** Links the directory to a specific Vercel project silently. */
|
|
61
|
+
export function vercelLink(cwd, projectId) {
|
|
62
|
+
const { cmd, args } = getVercelCmd();
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const child = spawn(cmd, [...args, "link", "--yes", "--project", projectId], {
|
|
65
|
+
stdio: "pipe",
|
|
66
|
+
cwd,
|
|
67
|
+
});
|
|
68
|
+
child.on("close", (code) => resolve(code === 0));
|
|
69
|
+
child.on("error", () => resolve(false));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Reads the auth token saved by the Vercel CLI after login. */
|
|
73
|
+
function getVercelAuthToken() {
|
|
74
|
+
const candidates = [
|
|
75
|
+
join(homedir(), "Library", "Application Support", "com.vercel.cli", "auth.json"), // macOS
|
|
76
|
+
join(homedir(), ".local", "share", "com.vercel.cli", "auth.json"), // Linux
|
|
77
|
+
join(homedir(), ".config", "vercel", "auth.json"), // older versions
|
|
78
|
+
];
|
|
79
|
+
for (const p of candidates) {
|
|
80
|
+
if (existsSync(p)) {
|
|
81
|
+
try {
|
|
82
|
+
const data = JSON.parse(readFileSync(p, "utf-8"));
|
|
83
|
+
return data.token ?? null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
16
89
|
}
|
|
17
90
|
return null;
|
|
18
91
|
}
|
|
92
|
+
/** Reads the project + team IDs from `.vercel/project.json`. */
|
|
93
|
+
function getVercelProjectConfig(cwd) {
|
|
94
|
+
const configPath = join(cwd, ".vercel", "project.json");
|
|
95
|
+
if (!existsSync(configPath))
|
|
96
|
+
return null;
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fetches Vercel deployments for the current commit SHA.
|
|
106
|
+
* Falls back to recent deployments if no SHA match is found.
|
|
107
|
+
*/
|
|
108
|
+
export async function getVercelDeployments(cwd, commitSha, branch) {
|
|
109
|
+
const token = getVercelAuthToken();
|
|
110
|
+
const project = getVercelProjectConfig(cwd);
|
|
111
|
+
if (!token || !project)
|
|
112
|
+
return [];
|
|
113
|
+
async function fetchDeployments(extraParams) {
|
|
114
|
+
const params = new URLSearchParams({
|
|
115
|
+
projectId: project.projectId,
|
|
116
|
+
teamId: project.orgId,
|
|
117
|
+
limit: "1",
|
|
118
|
+
state: "READY",
|
|
119
|
+
...extraParams,
|
|
120
|
+
});
|
|
121
|
+
const res = await fetch(`https://api.vercel.com/v6/deployments?${params}`, {
|
|
122
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
return [];
|
|
126
|
+
const data = (await res.json());
|
|
127
|
+
return data.deployments;
|
|
128
|
+
}
|
|
129
|
+
async function fetchAutomaticAliases(uid) {
|
|
130
|
+
const params = new URLSearchParams({ teamId: project.orgId });
|
|
131
|
+
const res = await fetch(`https://api.vercel.com/v13/deployments/${uid}?${params}`, {
|
|
132
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok)
|
|
135
|
+
return [];
|
|
136
|
+
const data = (await res.json());
|
|
137
|
+
return data.automaticAliases ?? [];
|
|
138
|
+
}
|
|
139
|
+
// Fetch commit deployment and latest branch deployment in parallel
|
|
140
|
+
const [commitDeployments, branchDeployments] = await Promise.all([
|
|
141
|
+
commitSha ? fetchDeployments({ sha: commitSha }) : Promise.resolve([]),
|
|
142
|
+
branch ? fetchDeployments({ branch }) : Promise.resolve([]),
|
|
143
|
+
]);
|
|
144
|
+
const results = [];
|
|
145
|
+
const seenUrls = new Set();
|
|
146
|
+
// Branch alias URL from automaticAliases (auto-updates with each push)
|
|
147
|
+
const branchDeploy = branchDeployments[0];
|
|
148
|
+
if (branchDeploy) {
|
|
149
|
+
const aliases = await fetchAutomaticAliases(branchDeploy.uid);
|
|
150
|
+
for (const alias of aliases) {
|
|
151
|
+
const url = `https://${alias}`;
|
|
152
|
+
if (!seenUrls.has(url)) {
|
|
153
|
+
results.push({ url, label: `branch: ${branch} (${alias})` });
|
|
154
|
+
seenUrls.add(url);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Commit-specific URL
|
|
159
|
+
const commitDeploy = commitDeployments[0];
|
|
160
|
+
if (commitDeploy) {
|
|
161
|
+
const url = `https://${commitDeploy.url}`;
|
|
162
|
+
if (!seenUrls.has(url)) {
|
|
163
|
+
results.push({ url, label: `commit: ${commitSha?.slice(0, 7)} (${commitDeploy.url})` });
|
|
164
|
+
seenUrls.add(url);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Fallback: most recent deployment
|
|
168
|
+
if (results.length === 0) {
|
|
169
|
+
const recent = await fetchDeployments({});
|
|
170
|
+
const d = recent[0];
|
|
171
|
+
if (d) {
|
|
172
|
+
const b = d.meta?.githubCommitRef ?? d.meta?.gitlabCommitRef ?? d.meta?.bitbucketCommitRef;
|
|
173
|
+
results.push({ url: `https://${d.url}`, label: b ? `${b} (${d.url})` : d.url });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import type { GitInfo } from "../lib/git.js";
|
|
3
|
+
export type ProviderSpinner = ReturnType<typeof p.spinner>;
|
|
4
|
+
export interface Provider {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
resolve: (cwd: string, gitInfo: GitInfo, spinner: ProviderSpinner) => Promise<string | null>;
|
|
8
|
+
}
|
|
9
|
+
export declare const providers: Provider[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { isVercelLoggedIn, isVercelLinked, vercelLogin, vercelLink, getVercelTeams, getVercelProjects, getVercelDeployments, } from "../lib/vercel.js";
|
|
3
|
+
export async function resolveVercelUrl(cwd, gitInfo, spinner) {
|
|
4
|
+
// Ensure logged in
|
|
5
|
+
spinner.start("Checking Vercel...");
|
|
6
|
+
const loggedIn = await isVercelLoggedIn();
|
|
7
|
+
if (!loggedIn) {
|
|
8
|
+
spinner.stop("Opening browser to log in to Vercel...");
|
|
9
|
+
const ok = vercelLogin();
|
|
10
|
+
if (!ok) {
|
|
11
|
+
p.log.error("Vercel login failed.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
spinner.stop("Vercel ready");
|
|
17
|
+
}
|
|
18
|
+
// Ensure project is linked
|
|
19
|
+
if (!isVercelLinked(cwd)) {
|
|
20
|
+
const teams = await getVercelTeams();
|
|
21
|
+
if (teams.length === 0) {
|
|
22
|
+
p.log.error("No Vercel teams found.");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
let teamId;
|
|
26
|
+
if (teams.length === 1) {
|
|
27
|
+
teamId = teams[0].id;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const selectedTeam = await p.select({
|
|
31
|
+
message: "Select a Vercel team",
|
|
32
|
+
options: teams.map((t) => ({ value: t.id, label: t.name })),
|
|
33
|
+
});
|
|
34
|
+
if (p.isCancel(selectedTeam)) {
|
|
35
|
+
p.cancel("Cancelled.");
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
teamId = selectedTeam;
|
|
39
|
+
}
|
|
40
|
+
const projects = await getVercelProjects(teamId);
|
|
41
|
+
if (projects.length === 0) {
|
|
42
|
+
p.log.error("No Vercel projects found.");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const selectedProject = await p.select({
|
|
46
|
+
message: "Select a Vercel project",
|
|
47
|
+
options: projects.map((proj) => ({ value: proj.id, label: proj.name })),
|
|
48
|
+
});
|
|
49
|
+
if (p.isCancel(selectedProject)) {
|
|
50
|
+
p.cancel("Cancelled.");
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
spinner.start("Linking to Vercel project...");
|
|
54
|
+
const ok = await vercelLink(cwd, selectedProject);
|
|
55
|
+
spinner.stop(ok ? "Linked to Vercel project" : "");
|
|
56
|
+
if (!ok) {
|
|
57
|
+
p.log.error("Vercel link failed.");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Fetch deployments
|
|
62
|
+
spinner.start("Fetching Vercel deployments...");
|
|
63
|
+
const deployments = await getVercelDeployments(cwd, gitInfo.commitFull, gitInfo.branch);
|
|
64
|
+
spinner.stop("Fetched Vercel deployments");
|
|
65
|
+
if (deployments.length === 0) {
|
|
66
|
+
p.log.warn("No Vercel deployments found. Paste a URL instead.");
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const selected = await p.select({
|
|
70
|
+
message: "Select a deployment",
|
|
71
|
+
options: deployments.map((d) => ({ value: d.url, label: d.label })),
|
|
72
|
+
});
|
|
73
|
+
if (p.isCancel(selected)) {
|
|
74
|
+
p.cancel("Cancelled.");
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
return selected;
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inflight-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Get feedback directly on your staging URL",
|
|
5
5
|
"bin": {
|
|
6
6
|
"inflight": "dist/index.js"
|
|
7
7
|
},
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist"
|
|
12
12
|
],
|
|
13
|
-
"license": "
|
|
13
|
+
"license": "UNLICENSED",
|
|
14
14
|
"homepage": "https://inflight.co",
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc && chmod +x dist/index.js",
|