@hexclave/cli 1.0.61 → 1.0.62

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/index.js CHANGED
@@ -2,19 +2,23 @@
2
2
  import { n as CliError, r as errorMessage$1, t as AuthError } from "./errors-CyPbl0LV.js";
3
3
  import * as Sentry from "@sentry/node";
4
4
  import "@hexclave/shared/dist/utils/env";
5
- import { captureError, registerErrorSink, throwErr } from "@hexclave/shared/dist/utils/errors";
5
+ import { StatusError, captureError, registerErrorSink, throwErr } from "@hexclave/shared/dist/utils/errors";
6
6
  import { ignoreUnhandledRejection } from "@hexclave/shared/dist/utils/promises";
7
7
  import { sentryBaseConfig } from "@hexclave/shared/dist/utils/sentry";
8
8
  import { nicify } from "@hexclave/shared/dist/utils/strings";
9
9
  import * as os from "os";
10
10
  import { homedir } from "os";
11
- import * as fs from "fs";
11
+ import * as fs$1 from "fs";
12
12
  import { chmodSync, closeSync, cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "fs";
13
- import * as path from "path";
13
+ import * as path$1 from "path";
14
14
  import { dirname, join, resolve } from "path";
15
15
  import { fileURLToPath } from "url";
16
16
  import { Command } from "commander";
17
17
  import { StackClientApp } from "@hexclave/js";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { createTar } from "@hexclave/shared/dist/utils/tar";
21
+ import { gzipSync } from "node:zlib";
18
22
  import { hexclaveDevEnvStatePath } from "@hexclave/shared/dist/utils/dev-env-state-path";
19
23
  import { createHash, randomBytes } from "crypto";
20
24
  import { replaceConfigObject } from "@hexclave/shared-backend";
@@ -126,25 +130,25 @@ function initSentry() {
126
130
  //#endregion
127
131
  //#region src/lib/config.ts
128
132
  const ENV_CONFIG_PATH = process.env.STACK_CLI_CONFIG_PATH;
129
- const HEXCLAVE_CONFIG_PATH = path.join(os.homedir(), ".config", "hexclave", "credentials.json");
130
- const LEGACY_CONFIG_PATH = path.join(os.homedir(), ".config", "stack-auth", "credentials.json");
133
+ const HEXCLAVE_CONFIG_PATH = path$1.join(os.homedir(), ".config", "hexclave", "credentials.json");
134
+ const LEGACY_CONFIG_PATH = path$1.join(os.homedir(), ".config", "stack-auth", "credentials.json");
131
135
  const WRITE_CONFIG_PATH = ENV_CONFIG_PATH ?? HEXCLAVE_CONFIG_PATH;
132
136
  function resolveReadConfigPath() {
133
137
  if (ENV_CONFIG_PATH != null) return ENV_CONFIG_PATH;
134
- if (fs.existsSync(HEXCLAVE_CONFIG_PATH)) return HEXCLAVE_CONFIG_PATH;
135
- if (fs.existsSync(LEGACY_CONFIG_PATH)) return LEGACY_CONFIG_PATH;
138
+ if (fs$1.existsSync(HEXCLAVE_CONFIG_PATH)) return HEXCLAVE_CONFIG_PATH;
139
+ if (fs$1.existsSync(LEGACY_CONFIG_PATH)) return LEGACY_CONFIG_PATH;
136
140
  return HEXCLAVE_CONFIG_PATH;
137
141
  }
138
142
  function readConfigJson() {
139
143
  try {
140
- return JSON.parse(fs.readFileSync(resolveReadConfigPath(), "utf-8"));
144
+ return JSON.parse(fs$1.readFileSync(resolveReadConfigPath(), "utf-8"));
141
145
  } catch {
142
146
  return {};
143
147
  }
144
148
  }
145
149
  function writeConfigJson(data) {
146
- fs.mkdirSync(path.dirname(WRITE_CONFIG_PATH), { recursive: true });
147
- fs.writeFileSync(WRITE_CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
150
+ fs$1.mkdirSync(path$1.dirname(WRITE_CONFIG_PATH), { recursive: true });
151
+ fs$1.writeFileSync(WRITE_CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
148
152
  }
149
153
  function readConfigValue(key) {
150
154
  return readConfigJson()[key];
@@ -277,6 +281,453 @@ function registerLogoutCommand(program) {
277
281
  });
278
282
  }
279
283
  //#endregion
284
+ //#region src/lib/app.ts
285
+ var app_exports = /* @__PURE__ */ __exportAll({
286
+ getAdminProject: () => getAdminProject,
287
+ getInternalApp: () => getInternalApp,
288
+ getInternalUser: () => getInternalUser
289
+ });
290
+ function getInternalApp(auth) {
291
+ return new StackClientApp({
292
+ projectId: "internal",
293
+ publishableClientKey: auth.publishableClientKey,
294
+ baseUrl: auth.apiUrl,
295
+ tokenStore: {
296
+ accessToken: "",
297
+ refreshToken: auth.refreshToken
298
+ },
299
+ noAutomaticPrefetch: true
300
+ });
301
+ }
302
+ async function getInternalUser(auth) {
303
+ return await getInternalApp(auth).getUser({ or: "throw" });
304
+ }
305
+ async function getAdminProject(auth) {
306
+ const project = (await (await getInternalUser(auth)).listOwnedProjects()).find((p) => p.id === auth.projectId);
307
+ if (!project) throw new AuthError(`Project '${auth.projectId}' not found. Make sure you own this project.`);
308
+ return project;
309
+ }
310
+ //#endregion
311
+ //#region src/lib/ignore-rules.ts
312
+ function escapeRegExpChar(char) {
313
+ return /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
314
+ }
315
+ function globSegmentToRegex(segment) {
316
+ let result = "";
317
+ for (let i = 0; i < segment.length; i++) {
318
+ const char = segment[i];
319
+ if (char === "\\" && i + 1 < segment.length) {
320
+ result += escapeRegExpChar(segment[i + 1]);
321
+ i++;
322
+ } else if (char === "*") result += "[^/]*";
323
+ else if (char === "?") result += "[^/]";
324
+ else if (char === "[") {
325
+ let j = i + 1;
326
+ let negatedClass = false;
327
+ if (segment[j] === "!" || segment[j] === "^") {
328
+ negatedClass = true;
329
+ j++;
330
+ }
331
+ let classEnd = segment[j] === "]" ? segment.indexOf("]", j + 1) : segment.indexOf("]", j);
332
+ if (classEnd === -1) result += escapeRegExpChar(char);
333
+ else {
334
+ const safeInner = [...segment.slice(j, classEnd)].map((c) => c === "-" ? "-" : escapeRegExpChar(c)).join("");
335
+ result += `[${negatedClass ? "^" : ""}${safeInner}]`;
336
+ i = classEnd;
337
+ }
338
+ } else result += escapeRegExpChar(char);
339
+ }
340
+ return result;
341
+ }
342
+ function parseIgnorePattern(line) {
343
+ let pattern = line.replace(/\r$/, "");
344
+ pattern = pattern.replace(/(?<!\\) +$/, "");
345
+ if (pattern === "" || pattern.startsWith("#")) return;
346
+ let negated = false;
347
+ if (pattern.startsWith("!")) {
348
+ negated = true;
349
+ pattern = pattern.slice(1);
350
+ }
351
+ let dirOnly = false;
352
+ if (pattern.endsWith("/")) {
353
+ dirOnly = true;
354
+ pattern = pattern.slice(0, -1);
355
+ }
356
+ if (pattern === "") return;
357
+ const anchored = pattern.includes("/");
358
+ if (pattern.startsWith("/")) pattern = pattern.slice(1);
359
+ const segments = pattern.split("/");
360
+ const regexParts = [];
361
+ for (let i = 0; i < segments.length; i++) {
362
+ const segment = segments[i];
363
+ if (segment === "**") regexParts.push(i === segments.length - 1 ? ".*" : "(?:[^/]+/)*");
364
+ else regexParts.push(globSegmentToRegex(segment) + (i === segments.length - 1 ? "" : "/"));
365
+ }
366
+ const body = regexParts.join("");
367
+ return {
368
+ negated,
369
+ dirOnly,
370
+ regex: new RegExp(`^${anchored ? "" : "(?:[^/]+/)*"}${body}$`)
371
+ };
372
+ }
373
+ function parseIgnoreFile(content) {
374
+ return content.split("\n").map(parseIgnorePattern).filter((rule) => rule !== void 0);
375
+ }
376
+ //#endregion
377
+ //#region src/lib/source-packaging.ts
378
+ const IGNORE_FILE_NAMES = [".gitignore", ".vercelignore"];
379
+ const ALWAYS_EXCLUDED_DIR_NAMES = /* @__PURE__ */ new Set(["node_modules", ".git"]);
380
+ function relativeToScope(scope, absolutePath) {
381
+ const relativePath = path.relative(scope.baseDirectory, absolutePath);
382
+ if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return;
383
+ return relativePath.split(path.sep).join("/");
384
+ }
385
+ function isIgnored(scopes, absolutePath, isDirectory) {
386
+ let ignored = false;
387
+ for (const scope of scopes) {
388
+ const scopedPath = relativeToScope(scope, absolutePath);
389
+ if (scopedPath === void 0) continue;
390
+ for (const rule of scope.rules) {
391
+ if (rule.dirOnly && !isDirectory) continue;
392
+ if (rule.regex.test(scopedPath)) ignored = !rule.negated;
393
+ }
394
+ }
395
+ return ignored;
396
+ }
397
+ function readIgnoreScopes(directory) {
398
+ const scopes = [];
399
+ for (const ignoreFileName of IGNORE_FILE_NAMES) {
400
+ const ignoreFilePath = path.join(directory, ignoreFileName);
401
+ if (fs.existsSync(ignoreFilePath) && fs.statSync(ignoreFilePath).isFile()) scopes.push({
402
+ baseDirectory: directory,
403
+ rules: parseIgnoreFile(fs.readFileSync(ignoreFilePath, "utf-8"))
404
+ });
405
+ }
406
+ return scopes;
407
+ }
408
+ /**
409
+ * Packages `rootDirectory`. `ignoreRootDirectory` is the outermost directory
410
+ * whose ignore files apply; deploy passes the config directory so a service in
411
+ * a monorepo subdirectory inherits the repository-level .gitignore and
412
+ * .vercelignore rules.
413
+ */
414
+ function packageSourceDirectory(rootDirectory, ignoreRootDirectory = rootDirectory) {
415
+ const absoluteRootDirectory = path.resolve(rootDirectory);
416
+ const absoluteIgnoreRootDirectory = path.resolve(ignoreRootDirectory);
417
+ const rootStat = fs.statSync(absoluteRootDirectory, { throwIfNoEntry: false });
418
+ if (rootStat == null || !rootStat.isDirectory()) throw new CliError(`Source directory not found: ${absoluteRootDirectory}`);
419
+ const relativeRootFromIgnoreRoot = path.relative(absoluteIgnoreRootDirectory, absoluteRootDirectory);
420
+ if (relativeRootFromIgnoreRoot === ".." || relativeRootFromIgnoreRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRootFromIgnoreRoot)) throw new CliError(`Source directory ${absoluteRootDirectory} must be inside the config directory ${absoluteIgnoreRootDirectory}.`);
421
+ const entries = [];
422
+ let totalBytes = 0;
423
+ const walk = (absoluteDir, relativeDir, parentScopes) => {
424
+ const scopes = [...parentScopes, ...readIgnoreScopes(absoluteDir)];
425
+ const dirents = fs.readdirSync(absoluteDir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
426
+ for (const dirent of dirents) {
427
+ const relativePath = relativeDir === "" ? dirent.name : `${relativeDir}/${dirent.name}`;
428
+ const absolutePath = path.join(absoluteDir, dirent.name);
429
+ if (dirent.isSymbolicLink()) continue;
430
+ if (dirent.isDirectory()) {
431
+ if (ALWAYS_EXCLUDED_DIR_NAMES.has(dirent.name)) continue;
432
+ if (isIgnored(scopes, absolutePath, true)) continue;
433
+ walk(absolutePath, relativePath, scopes);
434
+ } else if (dirent.isFile()) {
435
+ if (isIgnored(scopes, absolutePath, false)) continue;
436
+ const data = fs.readFileSync(absolutePath);
437
+ totalBytes += data.length;
438
+ entries.push({
439
+ path: relativePath,
440
+ data
441
+ });
442
+ }
443
+ }
444
+ };
445
+ const ancestorScopes = [];
446
+ const relativeSegments = relativeRootFromIgnoreRoot === "" ? [] : relativeRootFromIgnoreRoot.split(path.sep);
447
+ let currentDirectory = absoluteIgnoreRootDirectory;
448
+ for (const segment of relativeSegments) {
449
+ ancestorScopes.push(...readIgnoreScopes(currentDirectory));
450
+ const childDirectory = path.join(currentDirectory, segment);
451
+ if (isIgnored(ancestorScopes, childDirectory, true)) throw new CliError(`No files to deploy in ${absoluteRootDirectory} (the source directory is ignored by a parent .gitignore or .vercelignore).`);
452
+ currentDirectory = childDirectory;
453
+ }
454
+ walk(absoluteRootDirectory, "", ancestorScopes);
455
+ if (entries.length === 0) throw new CliError(`No files to deploy in ${absoluteRootDirectory} (everything is ignored or the directory is empty).`);
456
+ let tarball;
457
+ try {
458
+ tarball = createTar(entries);
459
+ } catch (error) {
460
+ if (error instanceof StatusError) throw new CliError(error.message);
461
+ throw error;
462
+ }
463
+ return {
464
+ tarballGzipped: gzipSync(tarball),
465
+ fileCount: entries.length,
466
+ totalBytes
467
+ };
468
+ }
469
+ //#endregion
470
+ //#region src/commands/deploy.ts
471
+ const CONFIG_FILE_CANDIDATES = [
472
+ "hexclave.config.ts",
473
+ "hexclave.config.js",
474
+ "stack.config.ts",
475
+ "stack.config.js"
476
+ ];
477
+ const SECRET_KEY_REGEX = /^[a-zA-Z0-9_-]+$/;
478
+ const ENV_VAR_KEY_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
479
+ const CONNECTION_VALUE_REGEX = /^[a-zA-Z0-9_-]+\.[A-Za-z0-9_]+$/;
480
+ /**
481
+ * Parses repeated `--secret KEY=VALUE` options. Values may contain `=` (only
482
+ * the first one separates key from value). Keys are the secret keys named by
483
+ * `type: "secret"` env vars in the config; values are never persisted by
484
+ * Hexclave. Exported for unit tests.
485
+ */
486
+ function parseSecretOptions(secretOptions) {
487
+ const secrets = /* @__PURE__ */ new Map();
488
+ for (const option of secretOptions) {
489
+ const separatorIndex = option.indexOf("=");
490
+ if (separatorIndex <= 0) throw new CliError(`Invalid --secret value ${JSON.stringify(option)}. Expected the KEY=VALUE format.`);
491
+ const key = option.slice(0, separatorIndex);
492
+ const value = option.slice(separatorIndex + 1);
493
+ if (!SECRET_KEY_REGEX.test(key)) throw new CliError(`Invalid --secret key ${JSON.stringify(key)}. Secret keys must contain only letters, numbers, underscores, and hyphens.`);
494
+ if (secrets.has(key)) throw new CliError(`Duplicate --secret key ${JSON.stringify(key)}.`);
495
+ secrets.set(key, value);
496
+ }
497
+ return secrets;
498
+ }
499
+ const DEPLOYMENTS_CONFIG_SECTION = "deployments-alpha";
500
+ /**
501
+ * Extracts one service's definition from a loaded config module. The config
502
+ * file holds it under `deployments-alpha.services.<name>` — the exact shape that
503
+ * `hexclave config push` pushes as branch config. Exported for unit tests.
504
+ */
505
+ function extractServiceDefinition(config, serviceName) {
506
+ if (config == null || typeof config !== "object") throw new CliError("Config file must export a plain `config` object.");
507
+ const section = config[DEPLOYMENTS_CONFIG_SECTION];
508
+ const services = section != null && typeof section === "object" ? section.services : void 0;
509
+ if (services == null || typeof services !== "object") throw new CliError(`The config file has no \`${DEPLOYMENTS_CONFIG_SECTION}.services\` section. Add one, e.g.:\n export const config = {\n "${DEPLOYMENTS_CONFIG_SECTION}": {\n services: {\n ${serviceName}: { type: "vercel", rootDirectory: "./", framework: "nextjs" },\n },\n },\n };`);
510
+ const service = services[serviceName];
511
+ if (service == null || typeof service !== "object") {
512
+ const available = Object.keys(services);
513
+ throw new CliError(`No service named ${JSON.stringify(serviceName)} in the config file's \`${DEPLOYMENTS_CONFIG_SECTION}.services\`.${available.length > 0 ? ` Available services: ${available.join(", ")}` : ""}`);
514
+ }
515
+ const record = service;
516
+ if (record.type !== "vercel") throw new CliError(record.type === void 0 ? `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}\` has no \`type\`. Add \`type: "vercel"\`.` : `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.type\` must be "vercel" (got ${JSON.stringify(record.type)}).`);
517
+ const readString = (key) => {
518
+ const value = record[key];
519
+ if (value === void 0) return void 0;
520
+ if (typeof value !== "string") throw new CliError(`\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.${key}\` must be a string.`);
521
+ return value;
522
+ };
523
+ return {
524
+ framework: readString("framework"),
525
+ installCommand: readString("installCommand"),
526
+ buildCommand: readString("buildCommand"),
527
+ outputDirectory: readString("outputDirectory"),
528
+ rootDirectory: readString("rootDirectory"),
529
+ env: extractServiceEnv(record.env, serviceName)
530
+ };
531
+ }
532
+ function extractServiceEnv(env, serviceName) {
533
+ if (env === void 0) return {};
534
+ if (env == null || typeof env !== "object" || Array.isArray(env)) throw new CliError(`\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.env\` must be a record of env var entries, e.g. { MY_VAR: { value: "some-value" } }.`);
535
+ const result = {};
536
+ for (const [envVarKey, entryValue] of Object.entries(env)) {
537
+ const path = `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.env.${envVarKey}\``;
538
+ if (!ENV_VAR_KEY_REGEX.test(envVarKey)) throw new CliError(`${path} has an invalid key. Env var keys must start with a letter or underscore and contain only letters, digits, and underscores.`);
539
+ if (entryValue == null || typeof entryValue !== "object") throw new CliError(`${path} must be an object like { value: "..." }, { type: "secret", key: "..." }, or { type: "connection", value: "service.output" }.`);
540
+ const entry = entryValue;
541
+ switch (entry.type) {
542
+ case void 0:
543
+ if (typeof entry.value !== "string") throw new CliError(`${path} must have a string \`value\` (or a \`type\` of "secret" or "connection").`);
544
+ if (entry.key !== void 0) throw new CliError(`${path} must not have a \`key\` — that's only for env vars with \`type: "secret"\`.`);
545
+ result[envVarKey] = { value: entry.value };
546
+ break;
547
+ case "secret":
548
+ if (typeof entry.key !== "string" || !SECRET_KEY_REGEX.test(entry.key)) throw new CliError(`${path} has type "secret" and must have a \`key\` naming the secret to pass at deploy time (letters, numbers, underscores, and hyphens only).`);
549
+ if (entry.value !== void 0) throw new CliError(`${path} has type "secret" and must not have a \`value\` — pass it at deploy time with --secret ${entry.key}=<value> instead, so it is never committed.`);
550
+ result[envVarKey] = {
551
+ type: "secret",
552
+ key: entry.key
553
+ };
554
+ break;
555
+ case "connection":
556
+ if (typeof entry.value !== "string" || !CONNECTION_VALUE_REGEX.test(entry.value)) throw new CliError(`${path} has type "connection" and must have a \`value\` referencing a service output like "hexclave.projectId".`);
557
+ result[envVarKey] = {
558
+ type: "connection",
559
+ value: entry.value
560
+ };
561
+ break;
562
+ default: throw new CliError(`${path} has an unknown \`type\` ${JSON.stringify(entry.type)}. Supported: "secret", "connection", or no type for a plain value.`);
563
+ }
564
+ }
565
+ return result;
566
+ }
567
+ /**
568
+ * Checks the provided secrets against the secret keys the env definitions
569
+ * reference, so a missing or misspelled secret fails BEFORE packaging and
570
+ * uploading. The backend re-checks this authoritatively. Exported for unit
571
+ * tests.
572
+ */
573
+ function assertSecretsMatchEnv(env, secrets) {
574
+ const referencedKeys = new Set(Object.values(env).flatMap((entry) => "type" in entry && entry.type === "secret" ? [entry.key] : []));
575
+ const missing = [...referencedKeys].filter((key) => !secrets.has(key));
576
+ if (missing.length > 0) throw new CliError(`Missing secret values for: ${missing.join(", ")}. This service's env vars reference these secrets — pass them with --secret <key>=<value>.`);
577
+ const unused = [...secrets.keys()].filter((key) => !referencedKeys.has(key));
578
+ if (unused.length > 0) throw new CliError(`Unknown --secret key(s): ${unused.join(", ")}. No env var of this service references them — check for typos, or add an env var with \`type: "secret"\` referencing them.`);
579
+ }
580
+ /**
581
+ * Resolves the config file path: --config wins (and must exist); otherwise the
582
+ * first existing candidate in cwd, or undefined when there is none — the
583
+ * config file is optional, deploys then use the service's configuration as
584
+ * stored on the backend (dashboard-configured). Exported for unit tests.
585
+ */
586
+ function resolveDeployConfigPath(configOption, cwd) {
587
+ if (configOption != null && configOption !== "") {
588
+ const resolved = path.resolve(cwd, configOption);
589
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) throw new CliError(`Config file not found: ${resolved}`);
590
+ return resolved;
591
+ }
592
+ for (const candidate of CONFIG_FILE_CANDIDATES) {
593
+ const resolved = path.resolve(cwd, candidate);
594
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) return resolved;
595
+ }
596
+ }
597
+ async function buildAuthHeadersFactory(auth) {
598
+ if (isProjectAuthWithSecretServerKey(auth)) {
599
+ const headers = {
600
+ "x-stack-access-type": "server",
601
+ "x-stack-project-id": auth.projectId,
602
+ "x-stack-secret-server-key": auth.secretServerKey
603
+ };
604
+ return () => Promise.resolve(headers);
605
+ }
606
+ const user = await getInternalUser(auth);
607
+ return async () => {
608
+ const { accessToken } = await user.currentSession.getTokens();
609
+ if (accessToken == null) throw new AuthError("Could not obtain an access token. Run `hexclave login` again.");
610
+ return {
611
+ "x-stack-access-type": "admin",
612
+ "x-stack-project-id": auth.projectId,
613
+ "x-stack-admin-access-token": accessToken
614
+ };
615
+ };
616
+ }
617
+ async function deployApiFetch(auth, getAuthHeaders, apiPath, init) {
618
+ const url = `${auth.apiUrl.replace(/\/$/, "")}/api/latest${apiPath}`;
619
+ const response = await fetch(url, {
620
+ method: init.method,
621
+ headers: {
622
+ ...await getAuthHeaders(),
623
+ ...init.jsonBody !== void 0 ? { "content-type": "application/json" } : {}
624
+ },
625
+ body: init.jsonBody !== void 0 ? JSON.stringify(init.jsonBody) : void 0
626
+ });
627
+ const text = await response.text();
628
+ if (!response.ok) {
629
+ let message = text;
630
+ try {
631
+ const parsed = JSON.parse(text);
632
+ if (typeof parsed?.error === "string") message = parsed.error;
633
+ else if (typeof parsed?.error?.message === "string") message = parsed.error.message;
634
+ } catch {}
635
+ throw new CliError(`Deploy request failed (${response.status} at ${init.method} ${apiPath}): ${message.slice(0, 1e3)}`);
636
+ }
637
+ try {
638
+ return text === "" ? void 0 : JSON.parse(text);
639
+ } catch {
640
+ throw new CliError(`Unexpected non-JSON response from the Hexclave API at ${init.method} ${apiPath}.`);
641
+ }
642
+ }
643
+ async function uploadSource(uploadUrl, contentType, bytes) {
644
+ let parsedUrl;
645
+ try {
646
+ parsedUrl = new URL(uploadUrl);
647
+ } catch {
648
+ throw new CliError("The Hexclave API returned an invalid object-storage upload URL.");
649
+ }
650
+ if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") throw new CliError("The Hexclave API returned an upload URL with an unsupported protocol.");
651
+ const response = await fetch(parsedUrl, {
652
+ method: "PUT",
653
+ headers: {
654
+ "content-type": contentType,
655
+ "content-length": bytes.length.toString()
656
+ },
657
+ body: new Uint8Array(bytes).slice().buffer
658
+ });
659
+ if (!response.ok) {
660
+ const responseBody = await response.text();
661
+ throw new CliError(`Source upload failed (${response.status} from object storage): ${responseBody.slice(0, 1e3)}`);
662
+ }
663
+ }
664
+ function registerDeployCommand(program) {
665
+ program.command("deploy <service>").description("Deploy a service defined under `deployments-alpha.services` in your hexclave.config.ts. Uploads the service's source directory, waits for Vercel to accept the deployment, then prints the run id without waiting for the remote build to finish.").option("--config <path>", "Path to the config file (default: auto-discover hexclave.config.ts in the current directory)").option("--cloud-project-id <id>", "Hexclave project ID to deploy to (defaults to the HEXCLAVE_PROJECT_ID env var)").option("--secret <KEY=VALUE>", "Value for a secret env var of this deploy (repeatable). KEY is the secret key named by a `type: \"secret\"` env var in the config; the value is pushed to the deployment target and never persisted by Hexclave.", (value, previous) => [...previous, value], []).addHelpText("after", "\nAuthentication: uses HEXCLAVE_SECRET_SERVER_KEY if set (recommended for CI), otherwise your `hexclave login` session.").action(async (service, opts) => {
666
+ const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
667
+ const secrets = parseSecretOptions(opts.secret);
668
+ const authHeaders = await buildAuthHeadersFactory(auth);
669
+ const configPath = resolveDeployConfigPath(opts.config, process.cwd());
670
+ let definition;
671
+ let rootDirectory;
672
+ let ignoreRootDirectory;
673
+ if (configPath != null) {
674
+ const { createJiti } = await import("jiti");
675
+ const jiti = createJiti(import.meta.url);
676
+ let configModule;
677
+ try {
678
+ configModule = await jiti.import(configPath);
679
+ } catch (err) {
680
+ throw new CliError(`Failed to load config file ${configPath}: ${errorMessage$1(err)}`);
681
+ }
682
+ if (configModule.config == null) throw new CliError(`Config file ${configPath} must export a \`config\` object (e.g. \`export const config = { "deployments-alpha": { services: { ... } } }\`).`);
683
+ definition = extractServiceDefinition(configModule.config, service);
684
+ assertSecretsMatchEnv(definition.env, secrets);
685
+ ignoreRootDirectory = path.dirname(configPath);
686
+ rootDirectory = path.resolve(ignoreRootDirectory, definition.rootDirectory ?? ".");
687
+ } else {
688
+ const remoteService = await deployApiFetch(auth, authHeaders, `/deployments/services/${encodeURIComponent(service)}`, { method: "GET" });
689
+ const remoteRootDirectory = typeof remoteService?.root_directory === "string" && remoteService.root_directory !== "" ? remoteService.root_directory : ".";
690
+ const remoteEnv = {};
691
+ for (const envVar of Array.isArray(remoteService?.env) ? remoteService.env : []) if (envVar?.type === "secret" && typeof envVar.secret_key === "string" && typeof envVar.key === "string") remoteEnv[envVar.key] = {
692
+ type: "secret",
693
+ key: envVar.secret_key
694
+ };
695
+ assertSecretsMatchEnv(remoteEnv, secrets);
696
+ ignoreRootDirectory = process.cwd();
697
+ rootDirectory = path.resolve(process.cwd(), remoteRootDirectory);
698
+ console.error(`No config file found — using the service configuration stored in Hexclave (root directory: ${remoteRootDirectory}).`);
699
+ }
700
+ console.error(`Packaging ${rootDirectory}...`);
701
+ const packaged = packageSourceDirectory(rootDirectory, ignoreRootDirectory);
702
+ console.error(`Packaged ${packaged.fileCount} files (${(packaged.tarballGzipped.length / 1024).toFixed(1)} KiB compressed).`);
703
+ const upload = await deployApiFetch(auth, authHeaders, "/deployments/uploads", { method: "POST" });
704
+ if (typeof upload?.id !== "string" || typeof upload?.upload_url !== "string" || typeof upload?.content_type !== "string") throw new CliError("Unexpected response from the Hexclave API when creating the upload.");
705
+ if (typeof upload.max_bytes === "number" && packaged.tarballGzipped.length > upload.max_bytes) throw new CliError(`The packaged source is too large (${packaged.tarballGzipped.length} bytes, max ${upload.max_bytes}). Check your .gitignore/.vercelignore — build outputs and large assets shouldn't be uploaded.`);
706
+ console.error(`Uploading source...`);
707
+ await uploadSource(upload.upload_url, upload.content_type, packaged.tarballGzipped);
708
+ console.error(`Starting deployment of ${JSON.stringify(service)}...`);
709
+ const deployResponse = await deployApiFetch(auth, authHeaders, `/deployments/services/${encodeURIComponent(service)}/deploy`, {
710
+ method: "POST",
711
+ jsonBody: {
712
+ upload_id: upload.id,
713
+ ...definition !== void 0 ? {
714
+ build_config: {
715
+ framework: definition.framework ?? null,
716
+ install_command: definition.installCommand ?? null,
717
+ build_command: definition.buildCommand ?? null,
718
+ output_directory: definition.outputDirectory ?? null,
719
+ root_directory: definition.rootDirectory ?? null
720
+ },
721
+ env: definition.env
722
+ } : {},
723
+ ...secrets.size > 0 ? { secrets: Object.fromEntries(secrets) } : {}
724
+ }
725
+ });
726
+ if (typeof deployResponse?.run_id !== "string") throw new CliError("Unexpected response from the Hexclave API when starting the deployment.");
727
+ console.log(JSON.stringify({ runId: deployResponse.run_id }, null, 2));
728
+ });
729
+ }
730
+ //#endregion
280
731
  //#region src/lib/config-file-path.ts
281
732
  function resolveConfigFilePathOption(inputPath, options) {
282
733
  const resolved = resolve(inputPath);
@@ -486,33 +937,6 @@ async function resolveLocalDashboardAuthByConfigPath(configFile) {
486
937
  };
487
938
  }
488
939
  //#endregion
489
- //#region src/lib/app.ts
490
- var app_exports = /* @__PURE__ */ __exportAll({
491
- getAdminProject: () => getAdminProject,
492
- getInternalApp: () => getInternalApp,
493
- getInternalUser: () => getInternalUser
494
- });
495
- function getInternalApp(auth) {
496
- return new StackClientApp({
497
- projectId: "internal",
498
- publishableClientKey: auth.publishableClientKey,
499
- baseUrl: auth.apiUrl,
500
- tokenStore: {
501
- accessToken: "",
502
- refreshToken: auth.refreshToken
503
- },
504
- noAutomaticPrefetch: true
505
- });
506
- }
507
- async function getInternalUser(auth) {
508
- return await getInternalApp(auth).getUser({ or: "throw" });
509
- }
510
- async function getAdminProject(auth) {
511
- const project = (await (await getInternalUser(auth)).listOwnedProjects()).find((p) => p.id === auth.projectId);
512
- if (!project) throw new AuthError(`Project '${auth.projectId}' not found. Make sure you own this project.`);
513
- return project;
514
- }
515
- //#endregion
516
940
  //#region src/commands/exec.ts
517
941
  function getErrorMessage(err) {
518
942
  if (err instanceof Error) return err.message;
@@ -565,6 +989,77 @@ function registerExecCommand(program) {
565
989
  });
566
990
  }
567
991
  //#endregion
992
+ //#region src/lib/progress.ts
993
+ const SPINNER_FRAMES$1 = [
994
+ "⠋",
995
+ "⠙",
996
+ "⠹",
997
+ "⠸",
998
+ "⠼",
999
+ "⠴",
1000
+ "⠦",
1001
+ "⠧",
1002
+ "⠇",
1003
+ "⠏"
1004
+ ];
1005
+ const SPINNER_INTERVAL_MS = 80;
1006
+ /**
1007
+ * Reports long-running CLI work without contaminating stdout. Interactive
1008
+ * terminals get a single animated line, while redirected output gets durable
1009
+ * lines that remain useful in CI logs.
1010
+ */
1011
+ function startProgress(initialMessage, options = {}) {
1012
+ const stream = options.stream ?? process.stderr;
1013
+ const prefix = options.prefix ?? "";
1014
+ let message = initialMessage;
1015
+ let stopped = false;
1016
+ if (!stream.isTTY) {
1017
+ stream.write(`${prefix}${message}...\n`);
1018
+ return {
1019
+ update(nextMessage) {
1020
+ if (stopped || nextMessage === message) return;
1021
+ message = nextMessage;
1022
+ stream.write(`${prefix}${message}...\n`);
1023
+ },
1024
+ stop(finalMessage) {
1025
+ if (stopped) return;
1026
+ stopped = true;
1027
+ if (finalMessage != null) stream.write(`${prefix}${finalMessage}\n`);
1028
+ }
1029
+ };
1030
+ }
1031
+ let frameIndex = 0;
1032
+ const render = () => {
1033
+ stream.write(`\r\x1b[2K${prefix}${SPINNER_FRAMES$1[frameIndex]} ${message}`);
1034
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES$1.length;
1035
+ };
1036
+ render();
1037
+ const timer = setInterval(render, SPINNER_INTERVAL_MS);
1038
+ timer.unref();
1039
+ return {
1040
+ update(nextMessage) {
1041
+ if (stopped || nextMessage === message) return;
1042
+ message = nextMessage;
1043
+ render();
1044
+ },
1045
+ stop(finalMessage) {
1046
+ if (stopped) return;
1047
+ stopped = true;
1048
+ clearInterval(timer);
1049
+ stream.write("\r\x1B[2K");
1050
+ if (finalMessage != null) stream.write(`${prefix}${finalMessage}\n`);
1051
+ }
1052
+ };
1053
+ }
1054
+ async function withProgress(message, operation, options) {
1055
+ const progress = startProgress(message, options);
1056
+ try {
1057
+ return await operation();
1058
+ } finally {
1059
+ progress.stop();
1060
+ }
1061
+ }
1062
+ //#endregion
568
1063
  //#region src/commands/config-file.ts
569
1064
  const SHOW_ONBOARDING_STACK_CONFIG_VALUE = "show-onboarding";
570
1065
  function isConfigOverride(value) {
@@ -675,16 +1170,16 @@ function sourceToSdkSource(source) {
675
1170
  }
676
1171
  function resolveConfigFilePathForPull(opts, cwd) {
677
1172
  if (opts.configFile != null && opts.configFile !== "") return resolveConfigFilePathOption(opts.configFile);
678
- const hexclaveCandidate = path.join(cwd, "hexclave.config.ts");
679
- const legacyCandidate = path.join(cwd, "stack.config.ts");
680
- const candidate = fs.existsSync(hexclaveCandidate) ? hexclaveCandidate : legacyCandidate;
681
- if (!fs.existsSync(candidate)) return hexclaveCandidate;
682
- if (fs.statSync(candidate).isDirectory()) throw new CliError(`Default config path points to a directory instead of a file: ${candidate}`);
1173
+ const hexclaveCandidate = path$1.join(cwd, "hexclave.config.ts");
1174
+ const legacyCandidate = path$1.join(cwd, "stack.config.ts");
1175
+ const candidate = fs$1.existsSync(hexclaveCandidate) ? hexclaveCandidate : legacyCandidate;
1176
+ if (!fs$1.existsSync(candidate)) return hexclaveCandidate;
1177
+ if (fs$1.statSync(candidate).isDirectory()) throw new CliError(`Default config path points to a directory instead of a file: ${candidate}`);
683
1178
  return candidate;
684
1179
  }
685
1180
  function assertConfigPullTarget(filePath, opts) {
686
1181
  if (opts.overwrite === true) return;
687
- if (fs.existsSync(filePath)) throw new CliError(`A config file already exists at ${filePath}. Pass --overwrite to replace it with the pulled config, or remove the file first.`);
1182
+ if (fs$1.existsSync(filePath)) throw new CliError(`A config file already exists at ${filePath}. Pass --overwrite to replace it with the pulled config, or remove the file first.`);
688
1183
  }
689
1184
  function registerConfigCommand(program) {
690
1185
  const config = program.command("config").description("Manage project configuration files");
@@ -692,31 +1187,39 @@ function registerConfigCommand(program) {
692
1187
  const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
693
1188
  if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`hexclave config pull` requires `hexclave login`. Remove STACK_SECRET_SERVER_KEY and try again.");
694
1189
  const filePath = resolveConfigFilePathForPull(opts, process.cwd());
695
- if (path.extname(filePath) !== ".ts") throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
1190
+ if (path$1.extname(filePath) !== ".ts") throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
696
1191
  assertConfigPullTarget(filePath, opts);
697
- const configOverride = await (await getAdminProject(auth)).getConfigOverride("branch");
698
- if (!isValidConfig(configOverride)) throw new CliError("Pulled branch config is not a valid local config object.");
699
- await replaceConfigObject(filePath, configOverride);
1192
+ await withProgress("Pulling config", async () => {
1193
+ const configOverride = await (await getAdminProject(auth)).getConfigOverride("branch");
1194
+ if (!isValidConfig(configOverride)) throw new CliError("Pulled branch config is not a valid local config object.");
1195
+ await replaceConfigObject(filePath, configOverride);
1196
+ });
700
1197
  console.log(`Config written to ${filePath}`);
701
1198
  });
702
1199
  config.command("push").description("Push a local config file to branch config").option("--cloud-project-id <id>", "Cloud project ID to push config to (defaults to the STACK_PROJECT_ID env var)").requiredOption("--config-file <path>", "Path to config file (.js or .ts)").option("--source <type>", "Explicit source type for this push. Only 'github' is supported.").option("--source-repo <owner/repo>", "GitHub repository in 'owner/repo' format. Only allowed with --source github.").option("--source-path <path>", "Path to the config file within the source repository. Only allowed with --source github.").option("--source-workflow-path <path>", "Path to the syncing workflow file within the source repository. Only allowed with --source github.").action(async (opts) => {
703
1200
  const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
704
1201
  const filePath = resolveConfigFilePathOption(opts.configFile, { mustExist: true });
705
- const ext = path.extname(filePath);
1202
+ const ext = path$1.extname(filePath);
706
1203
  if (ext !== ".js" && ext !== ".ts") throw new CliError("Config file must have a .js or .ts extension.");
707
- const { createJiti } = await import("jiti");
708
- const config = parseConfigOverride((await createJiti(import.meta.url).import(filePath)).config);
709
- if (config == null) throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { HexclaveConfig } from "${detectImportPackageFromDir(path.dirname(filePath)) ?? "@hexclave/js"}"; export const config: HexclaveConfig = { ... };`);
710
1204
  const source = buildConfigPushSource(opts.configFile, {
711
1205
  source: opts.source,
712
1206
  sourceRepo: opts.sourceRepo,
713
1207
  sourcePath: opts.sourcePath,
714
1208
  sourceWorkflowPath: opts.sourceWorkflowPath
715
1209
  });
716
- if (isProjectAuthWithSecretServerKey(auth)) await pushConfigWithSecretServerKey(auth, config, source);
717
- else {
718
- if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`hexclave config push` requires either STACK_SECRET_SERVER_KEY or `hexclave login`.");
719
- await (await getAdminProject(auth)).pushConfig(config, { source: sourceToSdkSource(source) });
1210
+ const progress = startProgress("Loading config");
1211
+ try {
1212
+ const { createJiti } = await import("jiti");
1213
+ const config = parseConfigOverride((await createJiti(import.meta.url).import(filePath)).config);
1214
+ if (config == null) throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { HexclaveConfig } from "${detectImportPackageFromDir(path$1.dirname(filePath)) ?? "@hexclave/js"}"; export const config: HexclaveConfig = { ... };`);
1215
+ progress.update("Pushing config");
1216
+ if (isProjectAuthWithSecretServerKey(auth)) await pushConfigWithSecretServerKey(auth, config, source);
1217
+ else {
1218
+ if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`hexclave config push` requires either STACK_SECRET_SERVER_KEY or `hexclave login`.");
1219
+ await (await getAdminProject(auth)).pushConfig(config, { source: sourceToSdkSource(source) });
1220
+ }
1221
+ } finally {
1222
+ progress.stop();
720
1223
  }
721
1224
  console.log("Config pushed successfully.");
722
1225
  });
@@ -885,11 +1388,13 @@ async function createProjectInteractively(user, opts = {}) {
885
1388
  validate: (v) => v.trim().length > 0 || "Display name cannot be empty."
886
1389
  })).trim();
887
1390
  }
888
- const teams = await user.listTeams();
889
- if (teams.length === 0) throw new CliError(`No teams found on your account. Create a team at ${opts.dashboardUrl ?? "https://app.hexclave.com"} first.`);
890
- return await user.createProject({
891
- displayName,
892
- teamId: teams[0].id
1391
+ return await withProgress("Creating project", async () => {
1392
+ const teams = await user.listTeams();
1393
+ if (teams.length === 0) throw new CliError(`No teams found on your account. Create a team at ${opts.dashboardUrl ?? "https://app.hexclave.com"} first.`);
1394
+ return await user.createProject({
1395
+ displayName,
1396
+ teamId: teams[0].id
1397
+ });
893
1398
  });
894
1399
  }
895
1400
  //#endregion
@@ -938,8 +1443,8 @@ function validateOptions(opts) {
938
1443
  }
939
1444
  async function runInit(program, opts) {
940
1445
  const flags = program.opts();
941
- const outputDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd();
942
- if (!fs.existsSync(outputDir)) throw new CliError(`Output directory does not exist: ${outputDir}`);
1446
+ const outputDir = opts.outputDir ? path$1.resolve(opts.outputDir) : process.cwd();
1447
+ if (!fs$1.existsSync(outputDir)) throw new CliError(`Output directory does not exist: ${outputDir}`);
943
1448
  validateOptions(opts);
944
1449
  console.log("Welcome to Hexclave!\n");
945
1450
  let mode;
@@ -1010,9 +1515,9 @@ async function handleLinkFromConfigFile(opts) {
1010
1515
  const configPath = resolveConfigFilePathOption(opts.configFile ?? await input({
1011
1516
  message: "Path to your existing hexclave.config.ts (or stack.config.ts):",
1012
1517
  validate: (value) => {
1013
- const resolved = path.resolve(value);
1014
- if (!fs.existsSync(resolved)) return `File not found: ${resolved}`;
1015
- if (fs.statSync(resolved).isDirectory()) return `--config-file must point to a config file, but got a directory: ${resolved}`;
1518
+ const resolved = path$1.resolve(value);
1519
+ if (!fs$1.existsSync(resolved)) return `File not found: ${resolved}`;
1520
+ if (fs$1.statSync(resolved).isDirectory()) return `--config-file must point to a config file, but got a directory: ${resolved}`;
1016
1521
  return true;
1017
1522
  }
1018
1523
  }), { mustExist: true });
@@ -1033,12 +1538,14 @@ async function ensureLoggedInSession() {
1033
1538
  }
1034
1539
  }
1035
1540
  async function writeProjectKeysToEnv(project, outputDir) {
1036
- const apiKey = await project.app.createInternalApiKey({
1037
- description: "Created by CLI init script",
1038
- expiresAt: new Date(Date.now() + 1e3 * 60 * 60 * 24 * 365 * 200),
1039
- hasPublishableClientKey: true,
1040
- hasSecretServerKey: true,
1041
- hasSuperSecretAdminKey: false
1541
+ const apiKey = await withProgress("Creating project keys", async () => {
1542
+ return await project.app.createInternalApiKey({
1543
+ description: "Created by CLI init script",
1544
+ expiresAt: new Date(Date.now() + 1e3 * 60 * 60 * 24 * 365 * 200),
1545
+ hasPublishableClientKey: true,
1546
+ hasSecretServerKey: true,
1547
+ hasSuperSecretAdminKey: false
1548
+ });
1042
1549
  });
1043
1550
  const publishableClientKey = apiKey.publishableClientKey ?? throwErr("createInternalApiKey returned no publishableClientKey despite hasPublishableClientKey=true");
1044
1551
  const secretServerKey = apiKey.secretServerKey ?? throwErr("createInternalApiKey returned no secretServerKey despite hasSecretServerKey=true");
@@ -1048,33 +1555,34 @@ async function writeProjectKeysToEnv(project, outputDir) {
1048
1555
  `NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY=${publishableClientKey}`,
1049
1556
  `HEXCLAVE_SECRET_SERVER_KEY=${secretServerKey}`
1050
1557
  ].join("\n");
1051
- const envPath = path.resolve(outputDir, ".env");
1052
- if (fs.existsSync(envPath)) {
1053
- const separator = fs.readFileSync(envPath, "utf-8").endsWith("\n") ? "\n" : "\n\n";
1558
+ const envPath = path$1.resolve(outputDir, ".env");
1559
+ if (fs$1.existsSync(envPath)) {
1560
+ const separator = fs$1.readFileSync(envPath, "utf-8").endsWith("\n") ? "\n" : "\n\n";
1054
1561
  if (isNonInteractiveEnv()) {
1055
- fs.appendFileSync(envPath, separator + envLines + "\n");
1562
+ fs$1.appendFileSync(envPath, separator + envLines + "\n");
1056
1563
  console.log("\nAppended Hexclave keys to .env");
1057
1564
  } else if (await confirm({
1058
1565
  message: `.env file already exists. Append Hexclave keys?`,
1059
1566
  default: true
1060
1567
  })) {
1061
- fs.appendFileSync(envPath, separator + envLines + "\n");
1568
+ fs$1.appendFileSync(envPath, separator + envLines + "\n");
1062
1569
  console.log("\nAppended Hexclave keys to .env");
1063
1570
  } else {
1064
1571
  console.log("\nHere are your environment variables:\n");
1065
1572
  console.log(envLines);
1066
1573
  }
1067
1574
  } else {
1068
- fs.writeFileSync(envPath, envLines + "\n");
1575
+ fs$1.writeFileSync(envPath, envLines + "\n");
1069
1576
  console.log("\nCreated .env with Hexclave keys");
1070
1577
  }
1071
1578
  }
1072
1579
  async function handleCreateCloud(_flags, opts, outputDir) {
1073
- const user = await getInternalUser(await ensureLoggedInSession());
1580
+ const sessionAuth = await ensureLoggedInSession();
1581
+ const user = await withProgress("Loading account", async () => await getInternalUser(sessionAuth));
1074
1582
  const { dashboardUrl } = resolveLoginConfig();
1075
1583
  const newProject = await createProjectInteractively(user, {
1076
1584
  displayName: opts.displayName,
1077
- defaultDisplayName: path.basename(outputDir),
1585
+ defaultDisplayName: path$1.basename(outputDir),
1078
1586
  dashboardUrl
1079
1587
  });
1080
1588
  console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
@@ -1082,8 +1590,15 @@ async function handleCreateCloud(_flags, opts, outputDir) {
1082
1590
  return { projectId: newProject.id };
1083
1591
  }
1084
1592
  async function handleLinkFromCloud(_flags, opts, outputDir) {
1085
- const user = await getInternalUser(await ensureLoggedInSession());
1086
- let projects = await user.listOwnedProjects();
1593
+ const sessionAuth = await ensureLoggedInSession();
1594
+ const { user, ownedProjects } = await withProgress("Loading projects", async () => {
1595
+ const user = await getInternalUser(sessionAuth);
1596
+ return {
1597
+ user,
1598
+ ownedProjects: await user.listOwnedProjects()
1599
+ };
1600
+ });
1601
+ let projects = ownedProjects;
1087
1602
  let autoCreatedProjectId = null;
1088
1603
  if (projects.length === 0) {
1089
1604
  if (opts.selectProjectId) throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects. Check the ID or omit --select-project-id to create a new project interactively.`);
@@ -1097,7 +1612,7 @@ async function handleLinkFromCloud(_flags, opts, outputDir) {
1097
1612
  }
1098
1613
  const { dashboardUrl } = resolveLoginConfig();
1099
1614
  const newProject = await createProjectInteractively(user, {
1100
- defaultDisplayName: path.basename(outputDir),
1615
+ defaultDisplayName: path$1.basename(outputDir),
1101
1616
  dashboardUrl
1102
1617
  });
1103
1618
  console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
@@ -1135,7 +1650,7 @@ async function performLogin() {
1135
1650
  console.log("Login successful!\n");
1136
1651
  }
1137
1652
  async function handleCreate(opts, outputDir) {
1138
- const configPath = path.resolve(outputDir, "hexclave.config.ts");
1653
+ const configPath = path$1.resolve(outputDir, "hexclave.config.ts");
1139
1654
  console.log(`\nCreating a new config file at ${configPath}!\n`);
1140
1655
  let selectedApps;
1141
1656
  if (opts.apps) {
@@ -1157,9 +1672,9 @@ async function handleCreate(opts, outputDir) {
1157
1672
  }))
1158
1673
  });
1159
1674
  }
1160
- const content = renderConfigFileContent({ apps: { installed: Object.fromEntries(selectedApps.map((appId) => [appId, { enabled: true }])) } }, detectImportPackageFromDir(path.dirname(configPath)));
1161
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
1162
- if (fs.existsSync(configPath)) {
1675
+ const content = renderConfigFileContent({ apps: { installed: Object.fromEntries(selectedApps.map((appId) => [appId, { enabled: true }])) } }, detectImportPackageFromDir(path$1.dirname(configPath)));
1676
+ fs$1.mkdirSync(path$1.dirname(configPath), { recursive: true });
1677
+ if (fs$1.existsSync(configPath)) {
1163
1678
  if (isNonInteractiveEnv()) throw new CliError(`Config file already exists at ${configPath}. Refusing to overwrite in non-interactive mode.`);
1164
1679
  if (!await confirm({
1165
1680
  message: `Config file already exists at ${configPath}. Overwrite?`,
@@ -1169,7 +1684,7 @@ async function handleCreate(opts, outputDir) {
1169
1684
  return { configPath };
1170
1685
  }
1171
1686
  }
1172
- fs.writeFileSync(configPath, content);
1687
+ fs$1.writeFileSync(configPath, content);
1173
1688
  console.log(`\nConfig file written to ${configPath}`);
1174
1689
  return { configPath };
1175
1690
  }
@@ -1199,7 +1714,10 @@ function registerProjectCommand(program) {
1199
1714
  project.command("list").description("List your projects (defaults to both cloud and development-environment projects)").option("--cloud", "Only list cloud projects").option("--local", "Only list development-environment projects").action(async (opts) => {
1200
1715
  const sources = resolveProjectListSources(opts);
1201
1716
  const results = [];
1202
- const ownedProjects = await (await getInternalUser(resolveSessionAuth())).listOwnedProjects();
1717
+ const auth = resolveSessionAuth();
1718
+ const ownedProjects = await withProgress("Loading projects", async () => {
1719
+ return await (await getInternalUser(auth)).listOwnedProjects();
1720
+ });
1203
1721
  for (const p of ownedProjects) {
1204
1722
  const target = p.isDevelopmentEnvironment ? "local" : "cloud";
1205
1723
  if (target === "cloud" && sources.cloud || target === "local" && sources.local) results.push({
@@ -1218,7 +1736,8 @@ function registerProjectCommand(program) {
1218
1736
  Promise.resolve().then(() => auth_exports),
1219
1737
  Promise.resolve().then(() => create_project_exports)
1220
1738
  ]);
1221
- const user = await getInternalUser(resolveSessionAuth());
1739
+ const auth = resolveSessionAuth();
1740
+ const user = await withProgress("Loading account", async () => await getInternalUser(auth));
1222
1741
  const { dashboardUrl } = resolveLoginConfig();
1223
1742
  const newProject = await createProjectInteractively(user, {
1224
1743
  displayName: opts.displayName,
@@ -1373,7 +1892,7 @@ async function sha256File(path) {
1373
1892
  await pipeline(createReadStream(path), hash);
1374
1893
  return hash.digest("hex");
1375
1894
  }
1376
- async function downloadDashboardRelease(manifest) {
1895
+ async function downloadDashboardRelease(manifest, onProgress) {
1377
1896
  const cacheRoot = dashboardCacheRoot();
1378
1897
  mkdirSync(cacheRoot, { recursive: true });
1379
1898
  const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
@@ -1381,6 +1900,7 @@ async function downloadDashboardRelease(manifest) {
1381
1900
  const tmpDir = join(cacheRoot, `.extract-${manifest.version}-${suffix}`);
1382
1901
  const targetDir = dashboardVersionDir(manifest.version);
1383
1902
  try {
1903
+ onProgress?.(`Downloading Hexclave dashboard ${manifest.version}`);
1384
1904
  const response = await fetch(manifest.url, {
1385
1905
  redirect: "follow",
1386
1906
  signal: AbortSignal.timeout(DASHBOARD_DOWNLOAD_TIMEOUT_MS)
@@ -1388,6 +1908,7 @@ async function downloadDashboardRelease(manifest) {
1388
1908
  if (!isAllowedDownloadUrl(response.url)) throw new CliError(`Dashboard ${manifest.version} download was redirected to a disallowed URL (${response.url}).`);
1389
1909
  if (!response.ok || response.body == null) throw new CliError(`Failed to download dashboard ${manifest.version} (HTTP ${response.status}) from ${manifest.url}.`);
1390
1910
  await pipeline(Readable.fromWeb(response.body), createWriteStream(tmpZip));
1911
+ onProgress?.(`Verifying Hexclave dashboard ${manifest.version}`);
1391
1912
  const digest = await sha256File(tmpZip);
1392
1913
  if (digest !== manifest.sha256) throw new CliError(`Dashboard ${manifest.version} failed its integrity check (expected ${manifest.sha256}, got ${digest}).`);
1393
1914
  rmSync(tmpDir, {
@@ -1395,6 +1916,7 @@ async function downloadDashboardRelease(manifest) {
1395
1916
  force: true
1396
1917
  });
1397
1918
  mkdirSync(tmpDir, { recursive: true });
1919
+ onProgress?.(`Extracting Hexclave dashboard ${manifest.version}`);
1398
1920
  await extractZip(tmpZip, { dir: tmpDir });
1399
1921
  if (!existsSync(join(tmpDir, DASHBOARD_SERVER_RELATIVE_PATH))) throw new CliError(`Dashboard ${manifest.version} archive is missing its server entrypoint.`);
1400
1922
  writeFileSync(join(tmpDir, DASHBOARD_COMPLETE_MARKER), `${manifest.sha256}\n`);
@@ -1433,7 +1955,7 @@ async function resolveDashboardRuntime(opts = {}) {
1433
1955
  version: manifest.version
1434
1956
  };
1435
1957
  try {
1436
- await downloadDashboardRelease(manifest);
1958
+ await downloadDashboardRelease(manifest, opts.onProgress);
1437
1959
  return {
1438
1960
  root: dashboardVersionDir(manifest.version),
1439
1961
  version: manifest.version
@@ -1548,30 +2070,6 @@ function maybeOpenOnboardingPage(session, port) {
1548
2070
  if (openUrlInBrowser(url)) logDev(`Onboarding is still pending for project ${session.project_id}. Opened: ${url}`);
1549
2071
  else logDev(`Onboarding is still pending for project ${session.project_id}. Open this URL manually: ${url}`);
1550
2072
  }
1551
- function startProgressLog(message) {
1552
- if (!process.stderr.isTTY) {
1553
- logDev(`${message}...`);
1554
- return { stop() {
1555
- logDev(`${message}... done!`);
1556
- } };
1557
- }
1558
- let dotCount = 0;
1559
- let stopped = false;
1560
- const render = () => {
1561
- process.stderr.write(`\r\x1b[2K${LOG_PREFIX}${message}${".".repeat(dotCount)}`);
1562
- dotCount = (dotCount + 1) % 4;
1563
- };
1564
- render();
1565
- const timer = setInterval(render, 400);
1566
- timer.unref();
1567
- return { stop() {
1568
- if (stopped) return;
1569
- stopped = true;
1570
- clearInterval(timer);
1571
- process.stderr.write("\r\x1B[2K");
1572
- logDev(`${message}... done!`);
1573
- } };
1574
- }
1575
2073
  function dashboardRuntimeRoot(port) {
1576
2074
  return join(dirname(devEnvStatePath()), `${DASHBOARD_RUNTIME_DIR_NAME}-${port}`);
1577
2075
  }
@@ -1731,7 +2229,9 @@ async function startDashboardIfNeeded(options) {
1731
2229
  const url = dashboardUrl(options.port);
1732
2230
  const devDashboardCommand = devDashboardCommandFromEnv(process.env);
1733
2231
  const dashboardOverride = dashboardDirOverride();
1734
- const manifest = devDashboardCommand != null || dashboardOverride != null ? null : await fetchDashboardManifest();
2232
+ const skipReleaseLookup = devDashboardCommand != null || dashboardOverride != null;
2233
+ if (!skipReleaseLookup) logDev("Checking for Hexclave dashboard updates...");
2234
+ const manifest = skipReleaseLookup ? null : await fetchDashboardManifest();
1735
2235
  const latestVersion = manifest?.version;
1736
2236
  if (await isDashboardReachable(url, options.secret)) {
1737
2237
  const runningDashboard = readDevEnvState().localDashboardsByPort?.[String(options.port)];
@@ -1751,8 +2251,11 @@ async function startDashboardIfNeeded(options) {
1751
2251
  return;
1752
2252
  }
1753
2253
  }
1754
- const release = devDashboardCommand == null ? await resolveDashboardRuntime({ manifest }) : null;
1755
- const progress = startProgressLog(`Hexclave dashboard not found on port ${options.port}. Starting now`);
2254
+ const release = devDashboardCommand == null ? await resolveDashboardRuntime({
2255
+ manifest,
2256
+ onProgress: (message) => logDev(`${message}...`)
2257
+ }) : null;
2258
+ const progress = startProgress(`Hexclave dashboard not found on port ${options.port}. Starting now`, { prefix: LOG_PREFIX });
1756
2259
  const dashboardEnv = {
1757
2260
  ...process.env,
1758
2261
  NODE_ENV: devDashboardCommand == null ? "production" : "development",
@@ -2267,7 +2770,7 @@ function registerDoctorCommand(program) {
2267
2770
  });
2268
2771
  }
2269
2772
  async function runDoctor(opts) {
2270
- const projectDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd();
2773
+ const projectDir = opts.outputDir ? path$1.resolve(opts.outputDir) : process.cwd();
2271
2774
  const pkgRead = readPackageJson(projectDir);
2272
2775
  if (pkgRead.kind === "missing") {
2273
2776
  if (opts.json) console.log(JSON.stringify({
@@ -2328,9 +2831,9 @@ function isPackageJson(value) {
2328
2831
  return value !== null && typeof value === "object" && !Array.isArray(value);
2329
2832
  }
2330
2833
  function readPackageJson(projectDir) {
2331
- const pkgPath = path.join(projectDir, "package.json");
2332
- if (!fs.existsSync(pkgPath)) return { kind: "missing" };
2333
- const raw = fs.readFileSync(pkgPath, "utf-8");
2834
+ const pkgPath = path$1.join(projectDir, "package.json");
2835
+ if (!fs$1.existsSync(pkgPath)) return { kind: "missing" };
2836
+ const raw = fs$1.readFileSync(pkgPath, "utf-8");
2334
2837
  try {
2335
2838
  const parsed = JSON.parse(raw);
2336
2839
  if (!isPackageJson(parsed)) return {
@@ -2350,8 +2853,8 @@ function readPackageJson(projectDir) {
2350
2853
  }
2351
2854
  }
2352
2855
  function resolveSrcPrefix(framework, projectDir) {
2353
- if (framework === "next") return fs.existsSync(path.join(projectDir, "src/app")) ? "src/" : "";
2354
- return fs.existsSync(path.join(projectDir, "src")) ? "src/" : "";
2856
+ if (framework === "next") return fs$1.existsSync(path$1.join(projectDir, "src/app")) ? "src/" : "";
2857
+ return fs$1.existsSync(path$1.join(projectDir, "src")) ? "src/" : "";
2355
2858
  }
2356
2859
  function resolveFramework(override, pkg, projectDir) {
2357
2860
  if (override) {
@@ -2369,7 +2872,7 @@ function resolveFramework(override, pkg, projectDir) {
2369
2872
  ...pkg.devDependencies ?? {}
2370
2873
  };
2371
2874
  if (allDeps.next) {
2372
- if (!(fs.existsSync(path.join(projectDir, "app")) || fs.existsSync(path.join(projectDir, "src/app")))) return {
2875
+ if (!(fs$1.existsSync(path$1.join(projectDir, "app")) || fs$1.existsSync(path$1.join(projectDir, "src/app")))) return {
2373
2876
  kind: "unsupported",
2374
2877
  reason: "Detected Next.js but no app router (app/ or src/app/). The pages router is not yet supported by Hexclave doctor."
2375
2878
  };
@@ -2536,7 +3039,7 @@ function fileExistsCheck(id, label, candidates, extraHint) {
2536
3039
  label,
2537
3040
  run: (ctx) => {
2538
3041
  const resolved = candidates.map((c) => `${ctx.srcPrefix}${c}`);
2539
- for (const rel of resolved) if (fs.existsSync(path.join(ctx.projectDir, rel))) return {
3042
+ for (const rel of resolved) if (fs$1.existsSync(path$1.join(ctx.projectDir, rel))) return {
2540
3043
  id,
2541
3044
  label: `${label} found (${rel})`,
2542
3045
  status: "pass"
@@ -2567,8 +3070,8 @@ function layoutWrapsStackProviderCheck() {
2567
3070
  const candidates = baseCandidates.map((c) => `${ctx.srcPrefix}${c}`);
2568
3071
  let foundPath = null;
2569
3072
  for (const candidate of candidates) {
2570
- const full = path.join(ctx.projectDir, candidate);
2571
- if (fs.existsSync(full)) {
3073
+ const full = path$1.join(ctx.projectDir, candidate);
3074
+ if (fs$1.existsSync(full)) {
2572
3075
  foundPath = full;
2573
3076
  break;
2574
3077
  }
@@ -2579,10 +3082,10 @@ function layoutWrapsStackProviderCheck() {
2579
3082
  status: "fail",
2580
3083
  detail: `Expected one of: ${candidates.join(", ")}`
2581
3084
  };
2582
- const content = fs.readFileSync(foundPath, "utf-8");
3085
+ const content = fs$1.readFileSync(foundPath, "utf-8");
2583
3086
  const importsProvider = /import\s*\{[^}]*\b(?:HexclaveProvider|StackProvider)\b[^}]*\}\s*from\s*["'](?:@hexclave\/next|@stackframe\/stack)["']/.test(content);
2584
3087
  const wrapsJsx = /<(?:HexclaveProvider|StackProvider)\b/.test(content);
2585
- const rel = path.relative(ctx.projectDir, foundPath);
3088
+ const rel = path$1.relative(ctx.projectDir, foundPath);
2586
3089
  if (importsProvider && wrapsJsx) return {
2587
3090
  id,
2588
3091
  label,
@@ -2665,8 +3168,8 @@ function configFileCheck() {
2665
3168
  let foundPath = null;
2666
3169
  let foundRel = null;
2667
3170
  for (const c of candidates) {
2668
- const full = path.join(ctx.projectDir, c);
2669
- if (fs.existsSync(full)) {
3171
+ const full = path$1.join(ctx.projectDir, c);
3172
+ if (fs$1.existsSync(full)) {
2670
3173
  foundPath = full;
2671
3174
  foundRel = c;
2672
3175
  break;
@@ -2721,9 +3224,9 @@ function readEnvFiles(projectDir) {
2721
3224
  const files = [".env.local", ".env"];
2722
3225
  const result = /* @__PURE__ */ new Map();
2723
3226
  for (const f of files) {
2724
- const full = path.join(projectDir, f);
2725
- if (!fs.existsSync(full)) continue;
2726
- const content = fs.readFileSync(full, "utf-8");
3227
+ const full = path$1.join(projectDir, f);
3228
+ if (!fs$1.existsSync(full)) continue;
3229
+ const content = fs$1.readFileSync(full, "utf-8");
2727
3230
  for (const line of content.split("\n")) {
2728
3231
  const trimmed = line.trim();
2729
3232
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -2772,8 +3275,13 @@ function registerWhoamiCommand(program) {
2772
3275
  program.command("whoami").description("Show the currently logged-in Hexclave CLI user").action(async () => {
2773
3276
  const flags = program.opts();
2774
3277
  const auth = resolveSessionAuth();
2775
- const user = await getInternalUser(auth);
2776
- const teams = await user.listTeams();
3278
+ const { user, teams } = await withProgress("Loading account", async () => {
3279
+ const user = await getInternalUser(auth);
3280
+ return {
3281
+ user,
3282
+ teams: await user.listTeams()
3283
+ };
3284
+ });
2777
3285
  const result = {
2778
3286
  id: user.id,
2779
3287
  displayName: user.displayName,
@@ -2810,6 +3318,7 @@ program.name("hexclave").description("Hexclave CLI. For more information, go to
2810
3318
  registerLoginCommand(program);
2811
3319
  registerLogoutCommand(program);
2812
3320
  registerExecCommand(program);
3321
+ registerDeployCommand(program);
2813
3322
  registerConfigCommand(program);
2814
3323
  registerInitCommand(program);
2815
3324
  registerProjectCommand(program);
@@ -2834,9 +3343,9 @@ async function main() {
2834
3343
  console.error(`Error: ${err.message}`);
2835
3344
  process.exit(1);
2836
3345
  }
3346
+ console.error(err);
2837
3347
  captureError("stack-cli-fatal", err);
2838
3348
  await Sentry.flush(2e3);
2839
- console.error(err);
2840
3349
  process.exit(1);
2841
3350
  }
2842
3351
  }