inflight-cli 0.1.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.
@@ -0,0 +1,3 @@
1
+ export declare function createCommand(opts: {
2
+ title?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,108 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { readGlobalConfig, writeGlobalConfig } from "../lib/config.js";
4
+ import { getGitInfo, isGitRepo } from "../lib/git.js";
5
+ import { detectStagingUrl } from "../lib/vercel.js";
6
+ import { apiGetMe, apiCreateVersion } from "../lib/api.js";
7
+ export async function createCommand(opts) {
8
+ const cwd = process.cwd();
9
+ const config = readGlobalConfig();
10
+ if (!config) {
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 create ")));
15
+ // Resolve workspace — lazy, cached after first use
16
+ let workspaceId = config.workspaceId;
17
+ if (!workspaceId) {
18
+ const spinner = p.spinner();
19
+ spinner.start("Loading workspaces...");
20
+ const me = await apiGetMe(config.apiKey, config.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
+ writeGlobalConfig({ ...config, 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
+ // Title defaults to branch name
52
+ const defaultTitle = gitInfo.branch ?? "Untitled";
53
+ const titleInput = opts.title ??
54
+ (await p.text({
55
+ message: "Version title",
56
+ placeholder: defaultTitle,
57
+ defaultValue: defaultTitle,
58
+ }));
59
+ if (p.isCancel(titleInput)) {
60
+ p.cancel("Cancelled.");
61
+ process.exit(0);
62
+ }
63
+ const title = titleInput;
64
+ // Staging URL — auto-detected from env or prompted
65
+ let stagingUrl = detectStagingUrl();
66
+ if (stagingUrl) {
67
+ p.log.info(`Staging URL: ${pc.cyan(stagingUrl)}`);
68
+ }
69
+ else {
70
+ const input = await p.text({
71
+ message: "Staging URL",
72
+ placeholder: "https://my-branch.vercel.app",
73
+ validate: (v) => {
74
+ if (!v)
75
+ return "Staging URL is required";
76
+ try {
77
+ new URL(v);
78
+ }
79
+ catch {
80
+ return "Must be a valid URL (include https://)";
81
+ }
82
+ },
83
+ });
84
+ if (p.isCancel(input)) {
85
+ p.cancel("Cancelled.");
86
+ process.exit(0);
87
+ }
88
+ stagingUrl = input;
89
+ }
90
+ const spinner = p.spinner();
91
+ spinner.start("Creating version...");
92
+ const result = await apiCreateVersion({
93
+ apiKey: config.apiKey,
94
+ apiUrl: config.apiUrl,
95
+ workspaceId,
96
+ title,
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") + " — open Inflight to add feedback and publish when ready");
107
+ process.exit(0);
108
+ }
@@ -0,0 +1 @@
1
+ export declare function loginCommand(): Promise<void>;
@@ -0,0 +1,60 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { createServer } from "http";
4
+ import { writeGlobalConfig } from "../lib/config.js";
5
+ import { apiGetMe } from "../lib/api.js";
6
+ const DEFAULT_API_URL = process.env.INFLIGHT_API_URL ?? "https://api.inflight.co";
7
+ const WEB_URL = process.env.INFLIGHT_WEB_URL ?? "https://inflight.co";
8
+ export async function loginCommand() {
9
+ p.intro(pc.bgBlue(pc.white(" inflight login ")));
10
+ p.log.info("Opening browser to authenticate with Inflight...");
11
+ const apiKey = await browserAuth();
12
+ const apiUrl = DEFAULT_API_URL;
13
+ const spinner = p.spinner();
14
+ spinner.start("Validating...");
15
+ const me = await apiGetMe(apiKey, apiUrl).catch((e) => {
16
+ spinner.stop("Validation failed.");
17
+ p.log.error(e.message);
18
+ process.exit(1);
19
+ });
20
+ spinner.stop(`Authenticated as ${pc.bold(me.name)}`);
21
+ writeGlobalConfig({ apiKey, apiUrl });
22
+ p.outro(pc.green("✓ Logged in successfully"));
23
+ process.exit(0);
24
+ }
25
+ function browserAuth() {
26
+ return new Promise((resolve, reject) => {
27
+ const server = createServer((req, res) => {
28
+ const url = new URL(req.url ?? "/", "http://localhost");
29
+ const key = url.searchParams.get("api_key");
30
+ res.writeHead(200, { "Content-Type": "text/html" });
31
+ res.end(`
32
+ <html><body style="font-family:sans-serif;text-align:center;padding:60px">
33
+ <h2>Authenticated!</h2>
34
+ <p>You can close this tab and return to the terminal.</p>
35
+ <script>window.close()</script>
36
+ </body></html>
37
+ `);
38
+ server.close();
39
+ if (key) {
40
+ resolve(key);
41
+ }
42
+ else {
43
+ reject(new Error("No API key received from browser"));
44
+ }
45
+ });
46
+ server.listen(0, "127.0.0.1").unref();
47
+ server.once("listening", async () => {
48
+ const port = server.address().port;
49
+ const callbackUrl = `http://127.0.0.1:${port}`;
50
+ const authUrl = `${WEB_URL}/cli/connect?callback=${encodeURIComponent(callbackUrl)}`;
51
+ p.log.info(`Opening ${pc.cyan(authUrl)}`);
52
+ const { default: open } = await import("open");
53
+ await open(authUrl);
54
+ });
55
+ setTimeout(() => {
56
+ server.close();
57
+ reject(new Error("Authentication timed out after 5 minutes"));
58
+ }, 5 * 60 * 1000);
59
+ });
60
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { loginCommand } from "./commands/login.js";
4
+ import { createCommand } from "./commands/create.js";
5
+ const program = new Command();
6
+ program
7
+ .name("inflight")
8
+ .description("Create and share design versions from the terminal")
9
+ .version("0.1.0");
10
+ program
11
+ .command("login")
12
+ .description("Authenticate with your Inflight account")
13
+ .action(loginCommand);
14
+ program
15
+ .command("create")
16
+ .description("Create a new draft version for this project")
17
+ .option("-t, --title <title>", "Version title")
18
+ .action((opts) => createCommand({ title: opts.title }));
19
+ program.parse();
@@ -0,0 +1,22 @@
1
+ import type { GitInfo } from "./git.js";
2
+ export interface Workspace {
3
+ id: string;
4
+ name: string;
5
+ }
6
+ export interface CreateVersionResult {
7
+ projectId: string;
8
+ versionId: string;
9
+ inflightUrl: string;
10
+ }
11
+ export declare function apiGetMe(apiKey: string, apiUrl: string): Promise<{
12
+ name: string;
13
+ workspaces: Workspace[];
14
+ }>;
15
+ export declare function apiCreateVersion(opts: {
16
+ apiKey: string;
17
+ apiUrl: string;
18
+ workspaceId: string;
19
+ title: string;
20
+ stagingUrl: string;
21
+ gitInfo: GitInfo;
22
+ }): Promise<CreateVersionResult>;
@@ -0,0 +1,28 @@
1
+ export async function apiGetMe(apiKey, apiUrl) {
2
+ const res = await fetch(`${apiUrl}/api/cli/me`, {
3
+ headers: { Authorization: `Bearer ${apiKey}` },
4
+ });
5
+ if (!res.ok)
6
+ throw new Error("Invalid API key");
7
+ return res.json();
8
+ }
9
+ export async function apiCreateVersion(opts) {
10
+ const res = await fetch(`${opts.apiUrl}/api/cli/version/create`, {
11
+ method: "POST",
12
+ headers: {
13
+ "Content-Type": "application/json",
14
+ Authorization: `Bearer ${opts.apiKey}`,
15
+ },
16
+ body: JSON.stringify({
17
+ workspaceId: opts.workspaceId,
18
+ title: opts.title,
19
+ stagingUrl: opts.stagingUrl,
20
+ gitInfo: opts.gitInfo,
21
+ }),
22
+ });
23
+ if (!res.ok) {
24
+ const body = await res.json().catch(() => ({ error: res.statusText }));
25
+ throw new Error(body.error ?? `API error ${res.status}`);
26
+ }
27
+ return res.json();
28
+ }
@@ -0,0 +1,7 @@
1
+ export interface GlobalConfig {
2
+ apiKey: string;
3
+ apiUrl: string;
4
+ workspaceId?: string;
5
+ }
6
+ export declare function readGlobalConfig(): GlobalConfig | null;
7
+ export declare function writeGlobalConfig(config: GlobalConfig): void;
@@ -0,0 +1,22 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
4
+ const CONFIG_DIR = join(homedir(), ".inflight");
5
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
6
+ const DEFAULT_API_URL = process.env.INFLIGHT_API_URL ?? "https://api.inflight.co";
7
+ export function readGlobalConfig() {
8
+ if (!existsSync(CONFIG_FILE))
9
+ return null;
10
+ try {
11
+ const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
12
+ // Allow env var to override stored API URL (useful for local dev)
13
+ return { ...config, apiUrl: DEFAULT_API_URL };
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function writeGlobalConfig(config) {
20
+ mkdirSync(CONFIG_DIR, { recursive: true });
21
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
22
+ }
@@ -0,0 +1,11 @@
1
+ export interface GitInfo {
2
+ branch: string | null;
3
+ commitShort: string | null;
4
+ commitFull: string | null;
5
+ commitMessage: string | null;
6
+ remoteUrl: string | null;
7
+ isDirty: boolean;
8
+ diff: string | null;
9
+ }
10
+ export declare function getGitInfo(cwd: string): GitInfo;
11
+ export declare function isGitRepo(cwd: string): boolean;
@@ -0,0 +1,72 @@
1
+ import { execSync } from "child_process";
2
+ function run(cmd, cwd) {
3
+ try {
4
+ return execSync(cmd, { cwd, stdio: ["pipe", "pipe", "pipe"] })
5
+ .toString()
6
+ .trim();
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ // Files matching these patterns are stripped from the diff before sending to GPT.
13
+ // They consume token budget without providing useful feedback signal.
14
+ const EXCLUDED_FILE_PATTERNS = [
15
+ /^(dist|build|\.next|out)\//,
16
+ /\/dist\//,
17
+ /\/build\//,
18
+ /\.min\.(js|css)$/,
19
+ /public\/.*\.js$/, // minified embed bundles, compiled assets
20
+ /^(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|bun\.lock)$/,
21
+ /\.snap$/,
22
+ // Generated type files — large, no feedback signal
23
+ /supabase\.ts$/,
24
+ ];
25
+ function isExcludedFile(filePath) {
26
+ return EXCLUDED_FILE_PATTERNS.some((re) => re.test(filePath));
27
+ }
28
+ /**
29
+ * Strip hunks for excluded files from a unified diff string.
30
+ * Each file section starts with "diff --git a/<path> b/<path>".
31
+ */
32
+ function filterDiff(raw) {
33
+ // Split on file boundaries, keeping the delimiter
34
+ const sections = raw.split(/(?=^diff --git )/m);
35
+ return sections
36
+ .filter((section) => {
37
+ const match = section.match(/^diff --git a\/.+ b\/(.+)/);
38
+ if (!match)
39
+ return true; // keep preamble / unknown sections
40
+ return !isExcludedFile(match[1]);
41
+ })
42
+ .join("");
43
+ }
44
+ function getDiff(cwd) {
45
+ // Try to find merge base with origin/main or origin/master
46
+ const mergeBase = run("git merge-base origin/main HEAD", cwd) ?? run("git merge-base origin/master HEAD", cwd);
47
+ const raw = mergeBase
48
+ ? run(`git diff ${mergeBase}..HEAD`, cwd)
49
+ : run("git diff HEAD~1..HEAD", cwd);
50
+ if (!raw)
51
+ return null;
52
+ const filtered = filterDiff(raw);
53
+ if (!filtered.trim())
54
+ return null;
55
+ // Cap at 100k chars for truly enormous diffs
56
+ const MAX_DIFF_CHARS = 100_000;
57
+ return filtered.length > MAX_DIFF_CHARS ? filtered.slice(0, MAX_DIFF_CHARS) : filtered;
58
+ }
59
+ export function getGitInfo(cwd) {
60
+ return {
61
+ branch: run("git rev-parse --abbrev-ref HEAD", cwd),
62
+ commitShort: run("git rev-parse --short HEAD", cwd),
63
+ commitFull: run("git rev-parse HEAD", cwd),
64
+ commitMessage: run("git log -1 --pretty=%s", cwd),
65
+ remoteUrl: run("git remote get-url origin", cwd),
66
+ isDirty: run("git status --porcelain", cwd) !== "",
67
+ diff: getDiff(cwd),
68
+ };
69
+ }
70
+ export function isGitRepo(cwd) {
71
+ return run("git rev-parse --git-dir", cwd) !== null;
72
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Detect staging URL from hosting provider environment variables.
3
+ * Only works when running inside a CI/CD build environment.
4
+ */
5
+ export declare function detectStagingUrl(): string | null;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Detect staging URL from hosting provider environment variables.
3
+ * Only works when running inside a CI/CD build environment.
4
+ */
5
+ export function detectStagingUrl() {
6
+ // Vercel preview deployments
7
+ if (process.env.VERCEL_URL) {
8
+ return `https://${process.env.VERCEL_URL}`;
9
+ }
10
+ // Netlify deploy previews
11
+ if (process.env.DEPLOY_PRIME_URL) {
12
+ return process.env.DEPLOY_PRIME_URL;
13
+ }
14
+ if (process.env.DEPLOY_URL) {
15
+ return process.env.DEPLOY_URL;
16
+ }
17
+ return null;
18
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "inflight-cli",
3
+ "version": "0.1.0",
4
+ "description": "Inflight CLI — create and share design versions from the terminal",
5
+ "bin": {
6
+ "inflight": "./dist/index.js"
7
+ },
8
+ "main": "./dist/index.js",
9
+ "type": "module",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "license": "MIT",
14
+ "homepage": "https://inflight.co",
15
+ "scripts": {
16
+ "build": "tsc && chmod +x dist/index.js",
17
+ "dev": "tsx src/index.ts"
18
+ },
19
+ "dependencies": {
20
+ "@clack/prompts": "^0.8.2",
21
+ "commander": "^12.1.0",
22
+ "open": "^11.0.0",
23
+ "picocolors": "^1.1.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "tsx": "^4.19.0",
28
+ "typescript": "^5.6.0"
29
+ }
30
+ }