@solcreek/cli 0.4.20 → 0.4.22

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/commands/dashboard.d.ts +21 -0
  3. package/dist/commands/dashboard.js +72 -0
  4. package/dist/commands/deploy.d.ts +10 -0
  5. package/dist/commands/deploy.js +252 -0
  6. package/dist/commands/dev.d.ts +13 -0
  7. package/dist/commands/dev.js +77 -2
  8. package/dist/commands/init.d.ts +10 -0
  9. package/dist/commands/init.js +158 -2
  10. package/dist/commands/login.js +1 -1
  11. package/dist/commands/logs.d.ts +12 -0
  12. package/dist/commands/logs.js +69 -1
  13. package/dist/commands/restart.d.ts +26 -0
  14. package/dist/commands/restart.js +55 -0
  15. package/dist/commands/rollback.d.ts +13 -0
  16. package/dist/commands/rollback.js +188 -1
  17. package/dist/commands/stop.d.ts +26 -0
  18. package/dist/commands/stop.js +65 -0
  19. package/dist/commands/top.d.ts +28 -0
  20. package/dist/commands/top.js +171 -0
  21. package/dist/dev/creekd-runner.d.ts +22 -0
  22. package/dist/dev/creekd-runner.js +188 -0
  23. package/dist/index.js +8 -0
  24. package/dist/utils/auth-server.d.ts +2 -2
  25. package/dist/utils/auth-server.js +16 -3
  26. package/dist/utils/creekd-client.d.ts +152 -0
  27. package/dist/utils/creekd-client.js +144 -0
  28. package/dist/utils/gitignore.d.ts +2 -0
  29. package/dist/utils/gitignore.js +32 -0
  30. package/dist/utils/hostkey.d.ts +39 -0
  31. package/dist/utils/hostkey.js +84 -0
  32. package/dist/utils/hosts.d.ts +70 -0
  33. package/dist/utils/hosts.js +90 -0
  34. package/dist/utils/local-cache.d.ts +69 -0
  35. package/dist/utils/local-cache.js +100 -0
  36. package/dist/utils/nextjs.d.ts +4 -2
  37. package/dist/utils/nextjs.js +107 -38
  38. package/dist/utils/prepare-bundle.js +1 -1
  39. package/dist/utils/top-format.d.ts +4 -0
  40. package/dist/utils/top-format.js +32 -0
  41. package/dist/utils/watch.d.ts +81 -0
  42. package/dist/utils/watch.js +87 -0
  43. package/package.json +2 -2
@@ -0,0 +1,65 @@
1
+ import { defineCommand } from "citty";
2
+ import consola from "consola";
3
+ import { globalArgs, resolveJsonMode, jsonOutput, shouldAutoConfirm } from "../utils/output.js";
4
+ import { CreekdClient, CreekdApiError, getCreekdUrl } from "../utils/creekd-client.js";
5
+ export const stopCommand = defineCommand({
6
+ meta: {
7
+ name: "stop",
8
+ description: "Stop an app on a creekd instance",
9
+ },
10
+ args: {
11
+ id: {
12
+ type: "positional",
13
+ description: "App ID to stop",
14
+ required: true,
15
+ },
16
+ server: {
17
+ type: "string",
18
+ description: "creekd admin API URL (or $CREEKD_URL)",
19
+ },
20
+ token: {
21
+ type: "string",
22
+ description: "Bearer token (or $CREEKD_TOKEN)",
23
+ },
24
+ ...globalArgs,
25
+ },
26
+ async run({ args }) {
27
+ const jsonMode = resolveJsonMode(args);
28
+ const client = new CreekdClient(args.server || getCreekdUrl(), args.token || process.env.CREEKD_TOKEN || process.env.CREEKCTL_TOKEN || "");
29
+ const id = args.id;
30
+ if (!shouldAutoConfirm(args) && !jsonMode) {
31
+ const readline = await import("node:readline/promises");
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
33
+ const answer = await rl.question(`Stop app "${id}"? [y/N] `);
34
+ rl.close();
35
+ if (answer.toLowerCase() !== "y") {
36
+ consola.info("Aborted.");
37
+ return;
38
+ }
39
+ }
40
+ try {
41
+ await client.stopApp(id);
42
+ if (jsonMode) {
43
+ jsonOutput({ ok: true, stopped: id }, 0, [
44
+ { command: `creek top`, description: "Live process overview" },
45
+ ]);
46
+ }
47
+ consola.success(`Stopped ${id}`);
48
+ }
49
+ catch (err) {
50
+ if (err instanceof CreekdApiError) {
51
+ if (jsonMode)
52
+ jsonOutput({ ok: false, error: err.code, message: err.message }, 1);
53
+ if (err.status === 404) {
54
+ consola.error(`App "${id}" not found.`);
55
+ }
56
+ else {
57
+ consola.error(`Stop failed: ${err.message}`);
58
+ }
59
+ process.exit(1);
60
+ }
61
+ throw err;
62
+ }
63
+ },
64
+ });
65
+ //# sourceMappingURL=stop.js.map
@@ -0,0 +1,28 @@
1
+ export declare const topCommand: import("citty").CommandDef<{
2
+ server: {
3
+ type: "string";
4
+ description: string;
5
+ required: false;
6
+ };
7
+ token: {
8
+ type: "string";
9
+ description: string;
10
+ required: false;
11
+ };
12
+ interval: {
13
+ type: "string";
14
+ description: string;
15
+ default: string;
16
+ };
17
+ json: {
18
+ type: "boolean";
19
+ description: string;
20
+ default: boolean;
21
+ };
22
+ yes: {
23
+ type: "boolean";
24
+ description: string;
25
+ default: boolean;
26
+ };
27
+ }>;
28
+ //# sourceMappingURL=top.d.ts.map
@@ -0,0 +1,171 @@
1
+ import { defineCommand } from "citty";
2
+ import consola from "consola";
3
+ import { globalArgs, resolveJsonMode, jsonOutput, isTTY } from "../utils/output.js";
4
+ import { CreekdClient, CreekdApiError, getCreekdUrl, } from "../utils/creekd-client.js";
5
+ import { fmtBytes, fmtDuration, calcCpuPercent } from "../utils/top-format.js";
6
+ export const topCommand = defineCommand({
7
+ meta: {
8
+ name: "top",
9
+ description: "Live view of apps on a creekd instance",
10
+ },
11
+ args: {
12
+ ...globalArgs,
13
+ server: {
14
+ type: "string",
15
+ description: "creekd admin API URL (or $CREEKD_URL)",
16
+ required: false,
17
+ },
18
+ token: {
19
+ type: "string",
20
+ description: "Bearer token (or $CREEKD_TOKEN)",
21
+ required: false,
22
+ },
23
+ interval: {
24
+ type: "string",
25
+ description: "Refresh interval in seconds (default 2)",
26
+ default: "2",
27
+ },
28
+ },
29
+ async run({ args }) {
30
+ const jsonMode = resolveJsonMode(args);
31
+ const client = new CreekdClient(args.server || getCreekdUrl(), args.token || process.env.CREEKD_TOKEN || process.env.CREEKCTL_TOKEN || "");
32
+ const intervalMs = Math.max(500, parseFloat(args.interval || "2") * 1000);
33
+ if (jsonMode) {
34
+ const snapshot = await collectSnapshot(client);
35
+ jsonOutput({ ok: true, ...snapshot }, 0, [
36
+ { command: "creek top --json", description: "Refresh snapshot" },
37
+ { command: "creek logs <app-id>", description: "Stream app logs" },
38
+ ]);
39
+ }
40
+ await liveTop(client, intervalMs);
41
+ },
42
+ });
43
+ let prevCpu = new Map();
44
+ async function collectSnapshot(client) {
45
+ const apps = await client.listApps();
46
+ const now = Date.now();
47
+ const rows = [];
48
+ const statsResults = await Promise.allSettled(apps.map((app) => client.getStats(app.id)));
49
+ for (let i = 0; i < apps.length; i++) {
50
+ const app = apps[i];
51
+ const stats = statsResults[i].status === "fulfilled"
52
+ ? statsResults[i].value
53
+ : null;
54
+ let cpuStr = "—";
55
+ if (stats?.cgroup_enabled && stats.cpu_usage_usec != null) {
56
+ const prev = prevCpu.get(app.id);
57
+ if (prev) {
58
+ const pct = calcCpuPercent(prev.usec, prev.ts, stats.cpu_usage_usec, now);
59
+ if (pct !== null)
60
+ cpuStr = pct.toFixed(1) + "%";
61
+ }
62
+ prevCpu.set(app.id, { usec: stats.cpu_usage_usec, ts: now });
63
+ }
64
+ rows.push({
65
+ id: app.id,
66
+ status: app.status,
67
+ cpu: cpuStr,
68
+ mem: stats?.memory_current_bytes != null ? fmtBytes(stats.memory_current_bytes) : "—",
69
+ memLimit: stats?.memory_max_bytes != null && stats.memory_max_bytes > 0
70
+ ? fmtBytes(stats.memory_max_bytes)
71
+ : "—",
72
+ pids: stats?.pids_current != null ? String(stats.pids_current) : "—",
73
+ restarts: app.restart_count,
74
+ uptime: fmtDuration(app.uptime_ms),
75
+ });
76
+ }
77
+ const running = apps.filter((a) => a.status === "running").length;
78
+ const crashed = apps.filter((a) => a.status === "crash_loop").length;
79
+ return {
80
+ apps: rows,
81
+ summary: { total: apps.length, running, crashed },
82
+ timestamp: new Date().toISOString(),
83
+ };
84
+ }
85
+ async function liveTop(client, intervalMs) {
86
+ const url = client instanceof CreekdClient
87
+ ? getCreekdUrl()
88
+ : "creekd";
89
+ let first = true;
90
+ // eslint-disable-next-line no-constant-condition
91
+ while (true) {
92
+ try {
93
+ const snap = await collectSnapshot(client);
94
+ if (isTTY)
95
+ process.stdout.write("\x1b[2J\x1b[H");
96
+ render(snap, url);
97
+ first = false;
98
+ }
99
+ catch (err) {
100
+ if (first) {
101
+ if (err instanceof CreekdApiError && err.status === 401) {
102
+ consola.error("Authentication failed. Set CREEKD_TOKEN or use --token.");
103
+ }
104
+ else {
105
+ consola.error(`Cannot reach creekd at ${url}`);
106
+ consola.info("Is creekd running? Check with: systemctl status creekd");
107
+ }
108
+ process.exit(1);
109
+ }
110
+ if (isTTY)
111
+ process.stdout.write("\x1b[2J\x1b[H");
112
+ consola.warn(`Refresh failed: ${err.message}`);
113
+ }
114
+ await sleep(intervalMs);
115
+ }
116
+ }
117
+ function render(snap, url) {
118
+ const { apps, summary } = snap;
119
+ const dim = "\x1b[2m";
120
+ const reset = "\x1b[0m";
121
+ const bold = "\x1b[1m";
122
+ const green = "\x1b[32m";
123
+ const red = "\x1b[31m";
124
+ const yellow = "\x1b[33m";
125
+ const header = `${bold}creek top${reset}${dim} — ${url}${reset} ` +
126
+ `${summary.total} apps, ${green}${summary.running} running${reset}` +
127
+ (summary.crashed > 0 ? `, ${red}${summary.crashed} crashed${reset}` : "");
128
+ process.stdout.write(header + "\n\n");
129
+ if (apps.length === 0) {
130
+ process.stdout.write(`${dim} No apps running.${reset}\n`);
131
+ return;
132
+ }
133
+ const cols = ["APP", "STATUS", "CPU", "MEM", "LIMIT", "PIDS", "RESTARTS", "UPTIME"];
134
+ const widths = cols.map((c, i) => {
135
+ const dataMax = Math.max(...apps.map((r) => String(cellValue(r, i)).length), 0);
136
+ return Math.max(c.length, dataMax);
137
+ });
138
+ const headerLine = cols.map((c, i) => c.padEnd(widths[i])).join(" ");
139
+ process.stdout.write(`${dim} ${headerLine}${reset}\n`);
140
+ for (const row of apps) {
141
+ const statusColor = row.status === "running" ? green
142
+ : row.status === "crash_loop" ? red
143
+ : row.status === "starting" ? yellow
144
+ : dim;
145
+ const cells = cols.map((_, i) => {
146
+ const val = String(cellValue(row, i));
147
+ if (i === 1)
148
+ return `${statusColor}${val.padEnd(widths[i])}${reset}`;
149
+ return val.padEnd(widths[i]);
150
+ });
151
+ process.stdout.write(" " + cells.join(" ") + "\n");
152
+ }
153
+ process.stdout.write(`\n${dim} Refreshing every ${snap._intervalS || 2}s — Ctrl+C to quit${reset}\n`);
154
+ }
155
+ function cellValue(row, col) {
156
+ switch (col) {
157
+ case 0: return row.id;
158
+ case 1: return row.status;
159
+ case 2: return row.cpu;
160
+ case 3: return row.mem;
161
+ case 4: return row.memLimit;
162
+ case 5: return row.pids;
163
+ case 6: return row.restarts;
164
+ case 7: return row.uptime;
165
+ default: return "";
166
+ }
167
+ }
168
+ function sleep(ms) {
169
+ return new Promise((resolve) => setTimeout(resolve, ms));
170
+ }
171
+ //# sourceMappingURL=top.js.map
@@ -0,0 +1,22 @@
1
+ import type { ResolvedConfig } from "@solcreek/sdk";
2
+ export interface CreekdDevServerOptions {
3
+ cwd: string;
4
+ port: number;
5
+ config: ResolvedConfig;
6
+ reset: boolean;
7
+ }
8
+ export declare class CreekdDevServer {
9
+ private options;
10
+ private sandboxProcess;
11
+ constructor(options: CreekdDevServerOptions);
12
+ start(): Promise<void>;
13
+ stop(): Promise<void>;
14
+ private requireCreekd;
15
+ private ensureSandbox;
16
+ private buildEnvVars;
17
+ private detectDevCommand;
18
+ /** For compatibility with DevServer interface */
19
+ triggerScheduled(): Promise<void>;
20
+ sendQueueMessage(_payload: unknown): Promise<void>;
21
+ }
22
+ //# sourceMappingURL=creekd-runner.d.ts.map
@@ -0,0 +1,188 @@
1
+ // CreekdDevServer for `creek dev --target creekd`.
2
+ //
3
+ // Orchestrates `creekd sandbox` (Go binary) instead of Miniflare.
4
+ // Real Postgres, Redis, SeaweedFS run inside a Lima VM.
5
+ // The app process runs inside the VM with env vars injected.
6
+ //
7
+ // Requirements:
8
+ // - `creekd` binary in PATH
9
+ // - Lima (`limactl`) for macOS/Linux sandbox VM
10
+ import { execSync, spawn } from "node:child_process";
11
+ import { existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { consola } from "consola";
14
+ export class CreekdDevServer {
15
+ options;
16
+ sandboxProcess = null;
17
+ constructor(options) {
18
+ this.options = options;
19
+ }
20
+ async start() {
21
+ const { cwd, port, config, reset } = this.options;
22
+ const startTime = Date.now();
23
+ // 1. Check creekd is installed
24
+ this.requireCreekd();
25
+ // 2. Start sandbox (provisions Lima VM + primitives from creek.toml)
26
+ consola.info("Starting creekd sandbox...");
27
+ const status = await this.ensureSandbox(cwd);
28
+ // 3. Build env var map from sandbox primitives
29
+ const env = this.buildEnvVars(config, status, port);
30
+ // 4. Load .env.local if present
31
+ const envLocalPath = join(cwd, ".env.local");
32
+ if (existsSync(envLocalPath)) {
33
+ const { readFileSync } = await import("node:fs");
34
+ const lines = readFileSync(envLocalPath, "utf-8").split("\n");
35
+ for (const line of lines) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed || trimmed.startsWith("#"))
38
+ continue;
39
+ const eqIdx = trimmed.indexOf("=");
40
+ if (eqIdx > 0) {
41
+ const key = trimmed.slice(0, eqIdx);
42
+ const val = trimmed.slice(eqIdx + 1).replace(/^["']|["']$/g, "");
43
+ if (!env[key])
44
+ env[key] = val;
45
+ }
46
+ }
47
+ consola.info(`.env.local loaded`);
48
+ }
49
+ // 5. Detect runtime and dev command
50
+ const devCmd = this.detectDevCommand(cwd, config);
51
+ // 6. Print status
52
+ const elapsed = Date.now() - startTime;
53
+ console.log("");
54
+ consola.success("⬡ creek dev (creekd sandbox)\n");
55
+ consola.info(`App: http://localhost:${port}`);
56
+ for (const p of status.ports) {
57
+ if (p.name !== "app") {
58
+ consola.info(`${p.name.padEnd(12)}localhost:${p.host}`);
59
+ }
60
+ }
61
+ console.log("");
62
+ for (const [k, v] of Object.entries(env)) {
63
+ if (k.startsWith("DATABASE") || k.startsWith("REDIS") || k.startsWith("S3_") || k.startsWith("SMTP")) {
64
+ const masked = v.replace(/:[^:@]+@/, ":***@");
65
+ consola.info(` ${k}=${masked}`);
66
+ }
67
+ }
68
+ console.log("");
69
+ consola.info(`Running: ${devCmd.join(" ")}`);
70
+ consola.info(`Ready in ${elapsed}ms`);
71
+ console.log("");
72
+ // 7. Start the dev server process with env vars
73
+ this.sandboxProcess = spawn(devCmd[0], devCmd.slice(1), {
74
+ cwd,
75
+ env: { ...process.env, ...env },
76
+ stdio: "inherit",
77
+ });
78
+ this.sandboxProcess.on("exit", (code) => {
79
+ if (code !== null && code !== 0) {
80
+ consola.error(`Dev server exited with code ${code}`);
81
+ }
82
+ });
83
+ }
84
+ async stop() {
85
+ if (this.sandboxProcess) {
86
+ this.sandboxProcess.kill("SIGTERM");
87
+ this.sandboxProcess = null;
88
+ }
89
+ // Don't stop the sandbox VM — it persists for fast restarts
90
+ }
91
+ // --- Internals ---
92
+ requireCreekd() {
93
+ try {
94
+ execSync("creekd --version", { stdio: "pipe" });
95
+ }
96
+ catch {
97
+ throw new Error([
98
+ "creekd is not installed.",
99
+ "",
100
+ " Install with: curl -fsSL https://install.creek.dev | sh",
101
+ "",
102
+ " Or use --target cf to develop with Miniflare (CF Workers local).",
103
+ ].join("\n"));
104
+ }
105
+ }
106
+ async ensureSandbox(cwd) {
107
+ try {
108
+ const output = execSync(`creekd sandbox --non-interactive --json "${cwd}"`, { encoding: "utf-8", timeout: 300_000 });
109
+ // Find the JSON line in output (creekd may print logs before JSON)
110
+ const lines = output.trim().split("\n");
111
+ for (let i = lines.length - 1; i >= 0; i--) {
112
+ try {
113
+ return JSON.parse(lines[i]);
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ }
119
+ throw new Error("No JSON status from creekd sandbox");
120
+ }
121
+ catch (e) {
122
+ if (e.message?.includes("not installed"))
123
+ throw e;
124
+ throw new Error(`creekd sandbox failed: ${e.message}`);
125
+ }
126
+ }
127
+ buildEnvVars(config, status, port) {
128
+ const env = {
129
+ PORT: String(port),
130
+ };
131
+ // Map sandbox ports to standard env vars
132
+ for (const p of status.ports) {
133
+ switch (p.name) {
134
+ case "postgres":
135
+ env.DATABASE_URL = `postgresql://creek:creek_sandbox@127.0.0.1:${p.host}/app`;
136
+ break;
137
+ case "mysql":
138
+ env.DATABASE_URL = `mysql://creek:creek_sandbox@127.0.0.1:${p.host}/app`;
139
+ break;
140
+ case "redis":
141
+ env.REDIS_URL = `redis://127.0.0.1:${p.host}/0`;
142
+ break;
143
+ case "s3":
144
+ env.S3_ENDPOINT = `http://127.0.0.1:${p.host}`;
145
+ env.S3_BUCKET = config.projectName;
146
+ env.AWS_ACCESS_KEY_ID = "creek";
147
+ env.AWS_SECRET_ACCESS_KEY = "creek_sandbox";
148
+ break;
149
+ case "smtp":
150
+ env.SMTP_URL = `smtp://127.0.0.1:${p.host}`;
151
+ break;
152
+ }
153
+ }
154
+ // SQLite fallback for database if no postgres/mysql port
155
+ if (!env.DATABASE_URL) {
156
+ const dbDir = join(this.options.cwd, ".creek", "dev");
157
+ env.DATABASE_URL = `sqlite://${dbDir}/dev.db`;
158
+ }
159
+ return env;
160
+ }
161
+ detectDevCommand(cwd, config) {
162
+ // Check package.json for dev script
163
+ const pkgPath = join(cwd, "package.json");
164
+ if (existsSync(pkgPath)) {
165
+ const { readFileSync } = require("node:fs");
166
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
167
+ if (pkg.scripts?.dev) {
168
+ return ["npm", "run", "dev"];
169
+ }
170
+ }
171
+ // Fallback to bun --watch or node --watch
172
+ const entryFiles = ["src/index.ts", "src/index.mjs", "src/index.js", "index.ts", "index.mjs", "index.js"];
173
+ for (const entry of entryFiles) {
174
+ if (existsSync(join(cwd, entry))) {
175
+ return ["bun", "--watch", entry];
176
+ }
177
+ }
178
+ return ["bun", "--watch", "."];
179
+ }
180
+ /** For compatibility with DevServer interface */
181
+ async triggerScheduled() {
182
+ consola.warn("Scheduled triggers not yet supported in creekd dev mode");
183
+ }
184
+ async sendQueueMessage(_payload) {
185
+ consola.warn("Queue messages not yet supported in creekd dev mode");
186
+ }
187
+ }
188
+ //# sourceMappingURL=creekd-runner.js.map
package/dist/index.js CHANGED
@@ -24,6 +24,10 @@ import { doctorCommand } from "./commands/doctor.js";
24
24
  import { dbCommand } from "./commands/db.js";
25
25
  import { storageCommand } from "./commands/storage.js";
26
26
  import { cacheCommand } from "./commands/cache.js";
27
+ import { topCommand } from "./commands/top.js";
28
+ import { restartCommand } from "./commands/restart.js";
29
+ import { stopCommand } from "./commands/stop.js";
30
+ import { dashboardCommand } from "./commands/dashboard.js";
27
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
32
  const cliPkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
29
33
  // Read version from the "creek" facade package (what users install),
@@ -63,6 +67,10 @@ const main = defineCommand({
63
67
  cache: cacheCommand,
64
68
  domains: domainsCommand,
65
69
  rollback: rollbackCommand,
70
+ top: topCommand,
71
+ dashboard: dashboardCommand,
72
+ restart: restartCommand,
73
+ stop: stopCommand,
66
74
  ops: opsCommand,
67
75
  },
68
76
  });
@@ -13,10 +13,10 @@ export interface AuthCallbackResult {
13
13
  * 4. This server receives the callback, validates state, resolves the promise
14
14
  * 5. Server auto-closes
15
15
  */
16
- export declare function startAuthServer(): {
16
+ export declare function startAuthServer(): Promise<{
17
17
  port: number;
18
18
  state: string;
19
19
  waitForCallback: () => Promise<string>;
20
20
  close: () => void;
21
- };
21
+ }>;
22
22
  //# sourceMappingURL=auth-server.d.ts.map
@@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto";
11
11
  * 4. This server receives the callback, validates state, resolves the promise
12
12
  * 5. Server auto-closes
13
13
  */
14
- export function startAuthServer() {
14
+ export async function startAuthServer() {
15
15
  const state = randomBytes(16).toString("hex");
16
16
  let resolveCallback;
17
17
  let rejectCallback;
@@ -45,10 +45,23 @@ export function startAuthServer() {
45
45
  res.writeHead(404);
46
46
  res.end("Not found");
47
47
  });
48
- // Listen on port 0 = OS picks a random available port
49
- server.listen(0, "localhost");
48
+ // Listen on port 0 = OS picks a random available port.
49
+ // server.listen() is async — we MUST await the 'listening' event before
50
+ // reading server.address(), otherwise address() returns null and the
51
+ // callback URL ships ?port=0 to the dashboard (which correctly rejects
52
+ // with "Missing port or state parameter"). Regression seen in the wild
53
+ // on macOS — Node's behavior here is timing-dependent.
54
+ await new Promise((resolve, reject) => {
55
+ server.once("listening", () => resolve());
56
+ server.once("error", (err) => reject(err));
57
+ server.listen(0, "localhost");
58
+ });
50
59
  const address = server.address();
51
60
  const port = typeof address === "object" && address ? address.port : 0;
61
+ if (port === 0) {
62
+ server.close();
63
+ throw new Error("Could not determine local port for auth callback — server.address() returned null after listening.");
64
+ }
52
65
  // Timeout after 2 minutes
53
66
  const timeout = setTimeout(() => {
54
67
  rejectCallback(new Error("Login timed out after 2 minutes"));