@hexclave/cli 1.0.118 → 1.0.119

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
@@ -14,7 +14,7 @@ import { dirname, join, resolve } from "path";
14
14
  import { fileURLToPath } from "url";
15
15
  import { Command } from "commander";
16
16
  import { StackClientApp } from "@hexclave/js";
17
- import { CONFIG_FILE_DEPLOYMENT_SOURCE_ID, DEPLOYMENT_ENV_VAR_KEY_REGEX, DEPLOYMENT_PORT_KEY_REGEX, DEPLOYMENT_SERVICE_TYPES, DEPLOYMENT_SOURCE_ID_REGEX, DEPLOYMENT_VOLUME_ID_REGEX, HEXCLAVE_OUTPUT_KEYS, HEXCLAVE_SERVICE_ID, MAX_DEPLOYMENT_COMMAND_LENGTH, MAX_DEPLOYMENT_SOURCE_ID_LENGTH, MAX_INSTANCES_PER_SERVICE, MAX_PERSISTENT_VOLUMES_PER_SERVICE, MAX_PORTS_PER_SERVICE, MAX_VOLUME_ID_LENGTH, MAX_VOLUME_SIZE_GB, MIN_VOLUME_SIZE_GB, SERVICE_OUTPUT_KEYS, buildSourceManifest, connectionRequiresTargetDeployed, deploymentPortEntries, deploymentPortEntry, deploymentPortOwnsStandardPorts, deploymentServiceIsBuilt, deploymentServiceUsesGeneratedDockerfile, formatConnectionValue, isValidDeploymentCommand, parseConnectionValue, parseDeploymentImageRef, reservedStandardPortConflicts, standardPortsHolderPort } from "@hexclave/shared/dist/deployments";
17
+ import { BUILDER_MEMORY_SIZES, DEFAULT_DEPLOYMENT_RUNTIME, DEPLOYMENT_ENV_VAR_KEY_REGEX, DEPLOYMENT_PORT_KEY_REGEX, DEPLOYMENT_SERVICE_TYPES, DEPLOYMENT_SOURCE_ID_REGEX, DEPLOYMENT_VERSION_TOKENS, DEPLOYMENT_VOLUME_ID_REGEX, HEXCLAVE_OUTPUT_KEYS, HEXCLAVE_SERVICE_ID, MAX_DEPLOYMENT_COMMAND_LENGTH, MAX_DEPLOYMENT_SOURCE_ID_LENGTH, MAX_INSTANCES_PER_SERVICE, MAX_PERSISTENT_VOLUMES_PER_SERVICE, MAX_PORTS_PER_SERVICE, MAX_VOLUME_ID_LENGTH, MAX_VOLUME_SIZE_GB, MIN_VOLUME_SIZE_GB, SERVICE_OUTPUT_KEYS, buildSourceManifest, connectionRequiresTargetDeployed, deploymentMemorySizesForType, deploymentPortEntries, deploymentPortEntry, deploymentPortOwnsStandardPorts, deploymentRuntimeForVersion, deploymentServiceIsBuilt, deploymentServiceUsesGeneratedDockerfile, formatConnectionValue, isValidDeploymentCommand, parseConnectionValue, parseDeploymentImageRef, reservedStandardPortConflicts, standardPortsHolderPort, suggestDeploymentMemorySize } from "@hexclave/shared/dist/deployments";
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
20
  import { createUrlIfValid } from "@hexclave/shared/dist/utils/urls";
@@ -24,11 +24,11 @@ import { gzipSync } from "node:zlib";
24
24
  import http from "node:http";
25
25
  import https from "node:https";
26
26
  import { PROJECT_SECRET_KEY_REGEX } from "@hexclave/shared/dist/project-secrets";
27
+ import { hexclaveDevEnvStatePath } from "@hexclave/shared/dist/utils/dev-env-state-path";
28
+ import { createHash, randomBytes } from "crypto";
27
29
  import { replaceConfigObject } from "@hexclave/shared-backend";
28
30
  import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
29
31
  import { isValidConfig } from "@hexclave/shared/dist/config/format";
30
- import { hexclaveDevEnvStatePath } from "@hexclave/shared/dist/utils/dev-env-state-path";
31
- import { createHash, randomBytes } from "crypto";
32
32
  import { ALL_APPS, expandAppSoftRequirements } from "@hexclave/shared/dist/apps/apps-config";
33
33
  import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
34
34
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
@@ -1057,13 +1057,6 @@ async function attemptUpload(request, settings) {
1057
1057
  //#endregion
1058
1058
  //#region src/lib/deployment-config.ts
1059
1059
  const DEPLOY_FILE_CANDIDATES = ["hexclave.deploy.ts", "hexclave.deploy.js"];
1060
- /** Whether `cwd` contains a deploy file at all. */
1061
- function hasDeployFile(cwd) {
1062
- return DEPLOY_FILE_CANDIDATES.some((candidate) => {
1063
- const resolved = path.resolve(cwd, candidate);
1064
- return fs.existsSync(resolved) && fs.statSync(resolved).isFile();
1065
- });
1066
- }
1067
1060
  /**
1068
1061
  * Resolves the deploy file path: --deploy-file wins (and must exist); otherwise
1069
1062
  * the first existing candidate in `cwd`. A deploy file is REQUIRED — service
@@ -1193,6 +1186,7 @@ const KNOWN_SERVICE_FIELDS = /* @__PURE__ */ new Set([
1193
1186
  "ports",
1194
1187
  "minInstances",
1195
1188
  "maxInstances",
1189
+ "memory",
1196
1190
  "rootDirectory",
1197
1191
  "dockerfilePath",
1198
1192
  "image",
@@ -1203,6 +1197,28 @@ const KNOWN_SERVICE_FIELDS = /* @__PURE__ */ new Set([
1203
1197
  "env"
1204
1198
  ]);
1205
1199
  const KNOWN_VOLUME_FIELDS = /* @__PURE__ */ new Set(["path", "sizeGb"]);
1200
+ const KNOWN_DEPLOY_FIELDS = /* @__PURE__ */ new Set(["services", "builder"]);
1201
+ const KNOWN_BUILDER_FIELDS = /* @__PURE__ */ new Set(["memory"]);
1202
+ /**
1203
+ * Reads a memory size token, refusing anything but the canonical spelling.
1204
+ *
1205
+ * `available` is the ladder for this particular slot — a "server" has no 512MB
1206
+ * shape and the builder starts where the services stop — so the message names
1207
+ * what this thing can be rather than every token that exists.
1208
+ *
1209
+ * Near-misses get a "did you mean": `"4gb"`, `"4 GB"` and `"4Gi"` are all
1210
+ * refused, but they are what somebody types first, and a bare list of seven
1211
+ * tokens leaves the reader to spot the difference by eye.
1212
+ */
1213
+ function readMemoryField(raw, at, available) {
1214
+ if (raw === void 0) return void 0;
1215
+ const options = available.map((size) => JSON.stringify(size)).join(", ");
1216
+ if (typeof raw !== "string") throw new CliError(`${at} must be one of ${options} (got ${JSON.stringify(raw)}). Write the size with its unit, e.g. \`memory: "4GB"\`.`);
1217
+ if (available.includes(raw)) return raw;
1218
+ const suggestion = suggestDeploymentMemorySize(raw);
1219
+ if (suggestion !== null && available.includes(suggestion)) throw new CliError(`${at} is ${JSON.stringify(raw)}. Write it as ${JSON.stringify(suggestion)} — sizes have one spelling each, in MB or GB (note the capital B; "Mb" is megabits).`);
1220
+ throw new CliError(`${at} must be one of ${options} (got ${JSON.stringify(raw)}).`);
1221
+ }
1206
1222
  function readOptionalIntegerField(record, serviceId, field) {
1207
1223
  const value = record[field];
1208
1224
  if (value === void 0) return void 0;
@@ -1269,6 +1285,21 @@ function evaluatePorts(serviceId, isPublic, portsRaw) {
1269
1285
  }
1270
1286
  return ports;
1271
1287
  }
1288
+ /**
1289
+ * Reads the deploy export's `builder`.
1290
+ *
1291
+ * Returns undefined when the file says nothing OR when it declares an empty
1292
+ * object: both mean "no opinion", and storing `{}` would make an empty
1293
+ * declaration look different from an absent one to every reader downstream.
1294
+ */
1295
+ function evaluateBuilder(deployFilePath, builderRaw) {
1296
+ if (builderRaw === void 0) return void 0;
1297
+ if (builderRaw === null || typeof builderRaw !== "object" || Array.isArray(builderRaw)) throw new CliError(`deploy.builder of ${deployFilePath} must be an object, e.g. \`builder: { memory: "32GB" }\`.`);
1298
+ const record = builderRaw;
1299
+ for (const field of Object.keys(record)) if (!KNOWN_BUILDER_FIELDS.has(field)) throw new CliError(`deploy.builder of ${deployFilePath} has an unknown field ${JSON.stringify(field)}. Known fields: ${[...KNOWN_BUILDER_FIELDS].join(", ")}.`);
1300
+ const memory = readMemoryField(record.memory, `deploy.builder.memory of ${deployFilePath}`, BUILDER_MEMORY_SIZES);
1301
+ return memory === void 0 ? void 0 : { memory };
1302
+ }
1272
1303
  function evaluatePersistentVolumes(serviceId, volumesRaw) {
1273
1304
  if (volumesRaw === void 0 || volumesRaw === null) return void 0;
1274
1305
  if (typeof volumesRaw !== "object" || Array.isArray(volumesRaw)) throw new CliError(`deploy.services.${serviceId}.persistentVolumes must be an object keyed by volume id, e.g. \`persistentVolumes: { data: { path: "/data", sizeGb: 10 } }\`.`);
@@ -1358,13 +1389,17 @@ const EXAMPLE_DEPLOYMENT_EXPORT = ` export const deploymentGroupId = "my-app";
1358
1389
  * its directory).
1359
1390
  */
1360
1391
  function evaluateDeploymentConfig(options) {
1361
- const { deployFilePath, deploymentGroupIdExport, legacyIdExport, deployExport, mode } = options;
1392
+ const { deployFilePath, deploymentGroupIdExport, legacyIdExport, deployExport, versionExport, mode } = options;
1362
1393
  const deployFileDirectory = path.dirname(deployFilePath);
1363
1394
  if (legacyIdExport !== void 0) throw new CliError(`The deploy file ${deployFilePath} has an \`id\` export, which is no longer supported. Rename it to \`deploymentGroupId\`, e.g. \`export const deploymentGroupId = ${JSON.stringify(typeof legacyIdExport === "string" ? legacyIdExport : "backend")};\`.`);
1364
1395
  if (deploymentGroupIdExport === void 0) throw new CliError(`The deploy file ${deployFilePath} has no \`deploymentGroupId\` export. Add one naming this deployment group, e.g. \`export const deploymentGroupId = "backend";\` — it is how Hexclave tells the deploy files of different repositories apart.`);
1365
1396
  if (typeof deploymentGroupIdExport !== "string") throw new CliError(`The \`deploymentGroupId\` export of ${deployFilePath} must be a string naming this deployment group (got ${typeof deploymentGroupIdExport}).`);
1366
1397
  const sourceId = deploymentGroupIdExport;
1367
1398
  if (!DEPLOYMENT_SOURCE_ID_REGEX.test(sourceId) || sourceId.length > MAX_DEPLOYMENT_SOURCE_ID_LENGTH) throw new CliError(`Invalid deployment group id ${JSON.stringify(sourceId)} in ${deployFilePath}. Ids must be at most ${MAX_DEPLOYMENT_SOURCE_ID_LENGTH} characters and contain only letters, numbers, underscores, dots, and hyphens (not starting with a dot or hyphen).`);
1399
+ const version = versionExport === void 0 ? void 0 : versionExport;
1400
+ if (version !== void 0 && typeof version !== "string") throw new CliError(`The \`version\` export of ${deployFilePath} must be a string (got ${typeof version}). Remove it unless you were given a value for it.`);
1401
+ const runtime = deploymentRuntimeForVersion(version);
1402
+ if (runtime === null) throw new CliError(`The deploy file ${deployFilePath} has an unknown \`version\` export ${JSON.stringify(version)}. Remove it, or use one of: ${DEPLOYMENT_VERSION_TOKENS.join(", ")}.`);
1368
1403
  if (deployExport === void 0) throw new CliError(`The deploy file ${deployFilePath} has no \`deploy\` export. Add one, e.g.:\n${EXAMPLE_DEPLOYMENT_EXPORT}`);
1369
1404
  if (typeof deployExport !== "function") throw new CliError(`The \`deploy\` export of ${deployFilePath} must be a function of the deployment context, e.g.:\n${EXAMPLE_DEPLOYMENT_EXPORT}`);
1370
1405
  const { context, referencedServiceIds } = createServicesContext(mode);
@@ -1379,7 +1414,8 @@ function evaluateDeploymentConfig(options) {
1379
1414
  if (deployRaw !== null && typeof deployRaw === "object" && "then" in deployRaw && typeof deployRaw.then === "function") throw new CliError(`The \`deploy\` export of ${deployFilePath} must be synchronous, but it returned a Promise. Remove the \`async\` keyword — secrets and connections are resolved for you, nothing here needs to be awaited.`);
1380
1415
  if (deployRaw === null || typeof deployRaw !== "object" || Array.isArray(deployRaw)) throw new CliError(`The \`deploy\` export of ${deployFilePath} must return an object with a \`services\` member, e.g.:\n${EXAMPLE_DEPLOYMENT_EXPORT}`);
1381
1416
  const deployRecord = deployRaw;
1382
- for (const field of Object.keys(deployRecord)) if (field !== "services") throw new CliError(`The \`deploy\` export of ${deployFilePath} returned an unknown field ${JSON.stringify(field)}. The only supported field is \`services\`.`);
1417
+ for (const field of Object.keys(deployRecord)) if (!KNOWN_DEPLOY_FIELDS.has(field)) throw new CliError(`The \`deploy\` export of ${deployFilePath} returned an unknown field ${JSON.stringify(field)}. Supported fields: ${[...KNOWN_DEPLOY_FIELDS].join(", ")}.`);
1418
+ const builder = evaluateBuilder(deployFilePath, deployRecord.builder);
1383
1419
  const servicesRaw = deployRecord.services;
1384
1420
  if (servicesRaw === void 0) throw new CliError(`The \`deploy\` export of ${deployFilePath} returned no \`services\`. Add them, e.g.:\n${EXAMPLE_DEPLOYMENT_EXPORT}`);
1385
1421
  if (servicesRaw === null || typeof servicesRaw !== "object" || Array.isArray(servicesRaw)) throw new CliError(`deploy.services of ${deployFilePath} must be a record of services keyed by service id.`);
@@ -1390,7 +1426,7 @@ function evaluateDeploymentConfig(options) {
1390
1426
  if (serviceRaw === null || typeof serviceRaw !== "object" || Array.isArray(serviceRaw)) throw new CliError(`deploy.services.${serviceId} must be an object.`);
1391
1427
  const record = serviceRaw;
1392
1428
  for (const field of Object.keys(record)) if (!KNOWN_SERVICE_FIELDS.has(field)) throw new CliError(`deploy.services.${serviceId} has an unknown field ${JSON.stringify(field)}. Known fields: ${[...KNOWN_SERVICE_FIELDS].join(", ")}.`);
1393
- if (!DEPLOYMENT_SERVICE_TYPES.includes(record.type)) throw new CliError(record.type === void 0 ? `deploy.services.${serviceId} has no \`type\`. Add \`type: "server"\` (single suspending instance, may have persistentVolumes) or \`type: "serverless"\` (scales out, stops on scale-down).` : `deploy.services.${serviceId}.type must be ${DEPLOYMENT_SERVICE_TYPES.map((knownType) => JSON.stringify(knownType)).join(" or ")} (got ${JSON.stringify(record.type)}).`);
1429
+ if (!DEPLOYMENT_SERVICE_TYPES.includes(record.type)) throw new CliError(record.type === void 0 ? `deploy.services.${serviceId} has no \`type\`. Add \`type: "server"\` (single always-on instance, may have persistentVolumes, paid plan) or \`type: "serverless"\` (scales out, stops on scale-down).` : `deploy.services.${serviceId}.type must be ${DEPLOYMENT_SERVICE_TYPES.map((knownType) => JSON.stringify(knownType)).join(" or ")} (got ${JSON.stringify(record.type)}).`);
1394
1430
  const serviceType = record.type;
1395
1431
  if (record.public !== void 0 && typeof record.public !== "boolean") throw new CliError(`deploy.services.${serviceId}.public must be true or false (got ${JSON.stringify(record.public)}). It defaults to false — services are private, reachable only by other services in this project.`);
1396
1432
  const isPublic = record.public === true;
@@ -1428,6 +1464,7 @@ function evaluateDeploymentConfig(options) {
1428
1464
  if (maxInstances !== void 0 && maxInstances !== 1) throw new CliError(`deploy.services.${serviceId} is a "server", which is always a single instance, so maxInstances must be 1 (got ${maxInstances}). Use \`type: "serverless"\` to scale out.`);
1429
1465
  if (minInstances !== void 0 && minInstances !== 0 && minInstances !== 1) throw new CliError(`deploy.services.${serviceId} is a "server", which holds a single instance, so minInstances must be 1 (always on, the default) or 0 (suspend when idle) — got ${minInstances}. Use \`type: "serverless"\` to run several instances.`);
1430
1466
  }
1467
+ const memory = readMemoryField(record.memory, `deploy.services.${serviceId}.memory`, deploymentMemorySizesForType(serviceType, runtime));
1431
1468
  const persistentVolumes = evaluatePersistentVolumes(serviceId, record.persistentVolumes);
1432
1469
  if (persistentVolumes !== void 0 && serviceType !== "server") throw new CliError(`deploy.services.${serviceId} declares persistentVolumes but is a "serverless" service. A volume is a disk on one machine — it cannot be shared between instances, so each one would get its own separate copy. Change it to \`type: "server"\`, or drop the volume and keep state in a database or object storage instead.`);
1433
1470
  if (buildCommand !== void 0 && startCommand === void 0 && image === void 0 && dockerfilePath === void 0) throw new CliError(`deploy.services.${serviceId} has a \`buildCommand\` but no \`image\` or \`dockerfilePath\`, so it is built on the Hexclave base image — which has no command of its own. Add a \`startCommand\` saying how to run it (e.g. startCommand: "npm start").`);
@@ -1441,6 +1478,7 @@ function evaluateDeploymentConfig(options) {
1441
1478
  ports,
1442
1479
  min_instances: serviceType === "server" ? minInstances ?? 1 : minInstances,
1443
1480
  max_instances: maxInstances,
1481
+ memory,
1444
1482
  root_directory: deploymentServiceIsBuilt({
1445
1483
  image,
1446
1484
  build_command: buildCommand
@@ -1495,16 +1533,17 @@ function evaluateDeploymentConfig(options) {
1495
1533
  for (const [serviceId, service] of services) for (const [envVarKey, value] of Object.entries(service.env)) {
1496
1534
  if (value.kind !== "connection") continue;
1497
1535
  const parsed = parseConnectionValue(value.reference);
1498
- if (parsed === null || parsed.serviceId !== serviceId || parsed.outputKey !== "url") continue;
1499
- if (service.definition.public !== true) continue;
1500
- const ports = deploymentPortEntries(service.definition.ports);
1501
- const port = parsed.port === null ? ports.filter((entry) => entry.protocol === "http")[0] : ports.find((entry) => entry.port === parsed.port);
1502
- if (port === void 0) continue;
1503
- throw new CliError(`deploy.services.${serviceId}.env.${envVarKey} connects to the service's own public URL (port ${port.port}), which cannot exist before the service does. Use service("${serviceId}").hostname() for its own address.`);
1536
+ if (parsed === null || parsed.serviceId !== serviceId) continue;
1537
+ const targetIsPublic = parsed.port === null || deploymentPortEntry(service.definition.ports, parsed.port) === null ? null : service.definition.public === true;
1538
+ if (!connectionRequiresTargetDeployed(runtime, parsed.outputKey, parsed.port, targetIsPublic)) continue;
1539
+ throw new CliError(`deploy.services.${serviceId}.env.${envVarKey} connects to the service's own ${parsed.outputKey}, which cannot exist before the service is deployed. ${runtime === "fly" && parsed.outputKey === "url" ? `Use service("${serviceId}").hostname() for its own address.` : "Set it as a plain env var, or have the service read its own address at runtime."}`);
1504
1540
  }
1505
1541
  return {
1506
1542
  sourceId,
1507
- services
1543
+ services,
1544
+ builder,
1545
+ runtime,
1546
+ version
1508
1547
  };
1509
1548
  }
1510
1549
  /**
@@ -1513,7 +1552,7 @@ function evaluateDeploymentConfig(options) {
1513
1552
  * services within one level are independent of each other. Throws on circular
1514
1553
  * dependencies, naming the cycle.
1515
1554
  */
1516
- function computeDeploymentLevels(services) {
1555
+ function computeDeploymentLevels(services, runtime = DEFAULT_DEPLOYMENT_RUNTIME) {
1517
1556
  const dependencies = /* @__PURE__ */ new Map();
1518
1557
  for (const [serviceId, service] of services) {
1519
1558
  const serviceDependencies = /* @__PURE__ */ new Set();
@@ -1525,7 +1564,7 @@ function computeDeploymentLevels(services) {
1525
1564
  const target = services.get(targetServiceId);
1526
1565
  if (target === void 0) continue;
1527
1566
  const targetIsPublic = parsed.port === null || deploymentPortEntry(target.definition.ports, parsed.port) === null ? null : target.definition.public === true;
1528
- if (!connectionRequiresTargetDeployed(parsed.outputKey, parsed.port, targetIsPublic)) continue;
1567
+ if (!connectionRequiresTargetDeployed(runtime, parsed.outputKey, parsed.port, targetIsPublic)) continue;
1529
1568
  if (targetServiceId !== HEXCLAVE_SERVICE_ID && targetServiceId !== serviceId) serviceDependencies.add(targetServiceId);
1530
1569
  }
1531
1570
  dependencies.set(serviceId, serviceDependencies);
@@ -1615,9 +1654,9 @@ function resolveHexclaveOutputFromSessionEnv(envVarKey, outputKey, sessionEnv) {
1615
1654
  }
1616
1655
  }
1617
1656
  /**
1618
- * Loads a TypeScript/JavaScript module via jiti. Shared by the deploy-file and
1619
- * config-file loaders below so both fail with the same error on an unloadable
1620
- * file; `description` names which of the two it was.
1657
+ * Loads a TypeScript/JavaScript module via jiti, reporting an unloadable file as
1658
+ * a CliError rather than whatever jiti threw. `description` is how the file is
1659
+ * named in that error.
1621
1660
  */
1622
1661
  async function importModule(filePath, description) {
1623
1662
  const { createJiti } = await import("jiti");
@@ -1639,452 +1678,157 @@ async function importDeployModule(deployFilePath) {
1639
1678
  return {
1640
1679
  deploymentGroupId: module.deploymentGroupId,
1641
1680
  legacyId: module.id,
1642
- deploy: module.deploy
1681
+ deploy: module.deploy,
1682
+ version: module.version
1643
1683
  };
1644
1684
  }
1685
+ //#endregion
1686
+ //#region src/commands/deploy.ts
1687
+ const RUN_POLL_INTERVAL_MS = 3e3;
1688
+ const RUN_POLL_TIMEOUT_MS = 3600 * 1e3;
1689
+ const MAX_CONSECUTIVE_POLL_FAILURES = 5;
1690
+ const BUILD_LOG_DRAIN_TIMEOUT_MS = 15e3;
1645
1691
  /**
1646
- * Loads a config file (hexclave.config.ts) and returns its `config` export, plus
1647
- * a `deploy` export if it has one — a project small enough to keep its
1648
- * services in the config file may declare them there instead of in a deploy
1649
- * file. Those services belong to a deployment group named after the file (the
1650
- * config file has no `deploymentGroupId` export of its own), so they can coexist
1651
- * with the deploy files of other repositories deploying into the same project.
1692
+ * The secret keys that MUST have a stored value for these services to deploy:
1693
+ * every secret referenced without a default value. Exported for unit tests.
1652
1694
  */
1653
- async function importConfigModule(configPath) {
1654
- const module = await importModule(configPath, "config file");
1655
- return {
1656
- config: module.config,
1657
- deploy: module.deploy
1658
- };
1695
+ function collectRequiredSecretKeys(services) {
1696
+ const requiredKeys = /* @__PURE__ */ new Set();
1697
+ for (const service of services) for (const value of Object.values(service.env)) if (value.kind === "secret" && value.defaultValue === void 0) requiredKeys.add(value.secretKey);
1698
+ return [...requiredKeys].sort();
1659
1699
  }
1660
- //#endregion
1661
- //#region src/lib/config-file-path.ts
1662
- function resolveConfigFilePathOption(inputPath, options) {
1663
- const resolved = resolve(inputPath);
1664
- const optionName = options?.optionName ?? "--config-file";
1665
- if (!existsSync(resolved)) {
1666
- if (options?.mustExist === true) throw new CliError(`Config file not found: ${resolved}`);
1667
- return resolved;
1700
+ async function buildAuthHeadersFactory(auth) {
1701
+ if (isProjectAuthWithSecretServerKey(auth)) {
1702
+ const headers = {
1703
+ "x-stack-access-type": "server",
1704
+ "x-stack-project-id": auth.projectId,
1705
+ "x-stack-secret-server-key": auth.secretServerKey
1706
+ };
1707
+ return () => Promise.resolve(headers);
1668
1708
  }
1669
- const stat = statSync(resolved);
1670
- if (stat.isDirectory()) throw new CliError(`${optionName} must point to a config file, but got a directory: ${resolved}`);
1671
- if (!stat.isFile()) throw new CliError(`${optionName} must point to a regular config file: ${resolved}`);
1672
- return resolved;
1673
- }
1674
- //#endregion
1675
- //#region src/lib/progress.ts
1676
- const SPINNER_FRAMES$1 = [
1677
- "⠋",
1678
- "⠙",
1679
- "⠹",
1680
- "⠸",
1681
- "⠼",
1682
- "⠴",
1683
- "⠦",
1684
- "⠧",
1685
- "⠇",
1686
- "⠏"
1687
- ];
1688
- const SPINNER_INTERVAL_MS = 80;
1689
- /**
1690
- * Reports long-running CLI work without contaminating stdout. Interactive
1691
- * terminals get a single animated line, while redirected output gets durable
1692
- * lines that remain useful in CI logs.
1693
- */
1694
- function startProgress(initialMessage, options = {}) {
1695
- const stream = options.stream ?? process.stderr;
1696
- const prefix = options.prefix ?? "";
1697
- let message = initialMessage;
1698
- let stopped = false;
1699
- if (!stream.isTTY) {
1700
- stream.write(`${prefix}${message}...\n`);
1709
+ const user = await getInternalUser(auth);
1710
+ return async () => {
1711
+ const { accessToken } = await user.currentSession.getTokens();
1712
+ if (accessToken == null) throw new AuthError("Could not obtain an access token. Run `hexclave login` again.");
1701
1713
  return {
1702
- update(nextMessage) {
1703
- if (stopped || nextMessage === message) return;
1704
- message = nextMessage;
1705
- stream.write(`${prefix}${message}...\n`);
1706
- },
1707
- stop(finalMessage) {
1708
- if (stopped) return;
1709
- stopped = true;
1710
- if (finalMessage != null) stream.write(`${prefix}${finalMessage}\n`);
1711
- }
1714
+ "x-stack-access-type": "admin",
1715
+ "x-stack-project-id": auth.projectId,
1716
+ "x-stack-admin-access-token": accessToken
1712
1717
  };
1713
- }
1714
- let frameIndex = 0;
1715
- const render = () => {
1716
- stream.write(`\r\x1b[2K${prefix}${SPINNER_FRAMES$1[frameIndex]} ${message}`);
1717
- frameIndex = (frameIndex + 1) % SPINNER_FRAMES$1.length;
1718
1718
  };
1719
- render();
1720
- const timer = setInterval(render, SPINNER_INTERVAL_MS);
1721
- timer.unref();
1722
- return {
1723
- update(nextMessage) {
1724
- if (stopped || nextMessage === message) return;
1725
- message = nextMessage;
1726
- render();
1719
+ }
1720
+ async function deployApiFetch(auth, getAuthHeaders, apiPath, init) {
1721
+ const url = `${auth.apiUrl.replace(/\/$/, "")}/api/latest${apiPath}`;
1722
+ const response = await fetch(url, {
1723
+ method: init.method,
1724
+ headers: {
1725
+ ...await getAuthHeaders(),
1726
+ ...init.jsonBody !== void 0 ? { "content-type": "application/json" } : {}
1727
1727
  },
1728
- stop(finalMessage) {
1729
- if (stopped) return;
1730
- stopped = true;
1731
- clearInterval(timer);
1732
- stream.write("\r\x1B[2K");
1733
- if (finalMessage != null) stream.write(`${prefix}${finalMessage}\n`);
1734
- }
1735
- };
1728
+ body: init.jsonBody !== void 0 ? JSON.stringify(init.jsonBody) : void 0
1729
+ });
1730
+ const text = await response.text();
1731
+ if (!response.ok) {
1732
+ let message = text;
1733
+ try {
1734
+ const parsed = JSON.parse(text);
1735
+ if (typeof parsed?.error === "string") message = parsed.error;
1736
+ else if (typeof parsed?.error?.message === "string") message = parsed.error.message;
1737
+ } catch {}
1738
+ throw new CliError(`Deploy request failed (${response.status} at ${init.method} ${apiPath}): ${message.slice(0, 1e3)}`);
1739
+ }
1740
+ try {
1741
+ return text === "" ? void 0 : JSON.parse(text);
1742
+ } catch {
1743
+ throw new CliError(`Unexpected non-JSON response from the Hexclave API at ${init.method} ${apiPath}.`);
1744
+ }
1736
1745
  }
1737
- async function withProgress(message, operation, options) {
1738
- const progress = startProgress(message, options);
1746
+ function wait$1(ms) {
1747
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
1748
+ }
1749
+ /**
1750
+ * Awaits `promise`, giving up after `ms`. The timer is cleared either way, so
1751
+ * winning the race doesn't leave a pending timeout holding the process open for
1752
+ * the rest of the window.
1753
+ */
1754
+ async function awaitAtMost(promise, ms) {
1755
+ let timer = void 0;
1756
+ const timeout = new Promise((resolvePromise) => {
1757
+ timer = setTimeout(resolvePromise, ms);
1758
+ });
1739
1759
  try {
1740
- return await operation();
1760
+ await Promise.race([promise, timeout]);
1741
1761
  } finally {
1742
- progress.stop();
1762
+ clearTimeout(timer);
1743
1763
  }
1744
1764
  }
1745
- //#endregion
1746
- //#region src/commands/config-file.ts
1747
- const SHOW_ONBOARDING_STACK_CONFIG_VALUE = "show-onboarding";
1748
- function isConfigOverride(value) {
1749
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1750
- const prototype = Object.getPrototypeOf(value);
1751
- return prototype === Object.prototype || prototype === null;
1765
+ /**
1766
+ * followBuildLogs, with any failure of the log stream ITSELF reduced to a
1767
+ * warning. Wrapped rather than left bare because this promise is only awaited
1768
+ * once the deploy is over: an unhandled rejection in the meantime would be
1769
+ * reported as a crash, and a build log that cannot be read is a degraded
1770
+ * deploy, never a failed one.
1771
+ */
1772
+ async function followBuildLogsSafely(options) {
1773
+ try {
1774
+ await followBuildLogs(options);
1775
+ } catch (error) {
1776
+ console.error(`Warning: stopped streaming the build logs (${errorMessage$1(error)}). They are still readable in the dashboard.`);
1777
+ }
1752
1778
  }
1753
- function parseConfigOverride(value) {
1754
- if (value === SHOW_ONBOARDING_STACK_CONFIG_VALUE) return {};
1755
- return isConfigOverride(value) ? value : null;
1779
+ /** The build-log endpoint for one deployment. */
1780
+ function deploymentBuildLogsUrl(apiUrl, deploymentId) {
1781
+ return `${apiUrl.replace(/\/$/, "")}/api/latest/deployments/deployments/${encodeURIComponent(deploymentId)}/logs`;
1756
1782
  }
1757
- const OWNER_REPO_SEGMENT = /^[A-Za-z0-9._-]+$/;
1758
- function parseOwnerRepo(value, flagName) {
1759
- const parts = value.split("/");
1760
- if (parts.length !== 2 || !OWNER_REPO_SEGMENT.test(parts[0]) || !OWNER_REPO_SEGMENT.test(parts[1])) throw new CliError(`${flagName} must be in the format 'owner/repo' using only letters, digits, '.', '_' or '-' (got '${value}').`);
1761
- return {
1762
- owner: parts[0],
1763
- repo: parts[1]
1764
- };
1783
+ function collectPublicUrls(deploySet, services, results) {
1784
+ return deploySet.flatMap((serviceId) => {
1785
+ const service = services.get(serviceId) ?? (() => {
1786
+ throw new CliError(`Internal error: deploy set contains unknown service ${JSON.stringify(serviceId)}.`);
1787
+ })();
1788
+ const result = results.get(serviceId) ?? (() => {
1789
+ throw new CliError(`Internal error: no deploy result for service ${JSON.stringify(serviceId)}.`);
1790
+ })();
1791
+ if (service.definition.public !== true || result.status !== "deployed" || result.url === null) return [];
1792
+ const holderUrl = result.url;
1793
+ return deploymentPortEntries(service.definition.ports).map((entry) => deploymentPortOwnsStandardPorts(service.definition.ports, true, entry.port) ? {
1794
+ serviceId,
1795
+ url: holderUrl
1796
+ } : {
1797
+ serviceId,
1798
+ url: `${holderUrl}:${entry.port}`
1799
+ });
1800
+ });
1765
1801
  }
1766
- function parseGitHubRepositoryEnv() {
1767
- const repository = process.env.GITHUB_REPOSITORY;
1768
- if (!repository) return null;
1769
- try {
1770
- return parseOwnerRepo(repository, "GITHUB_REPOSITORY");
1771
- } catch {
1772
- return null;
1802
+ /**
1803
+ * Where to read this deployment in the dashboard.
1804
+ *
1805
+ * The deployments page keeps its selection in the URL, so this can open the
1806
+ * exact thing a reader wants: the deployment's service map, or one service's
1807
+ * build log. `panel` is the dashboard's own tab id, not a name invented here.
1808
+ */
1809
+ function deploymentDashboardUrl(options) {
1810
+ const params = new URLSearchParams({ deploymentId: options.deploymentId });
1811
+ if (options.serviceId != null && options.serviceId !== "") {
1812
+ params.set("serviceId", options.serviceId);
1813
+ if (options.buildLogs === true) params.set("panel", "build-logs");
1773
1814
  }
1815
+ const route = `/projects/${encodeURIComponent(options.projectId)}/deployments`;
1816
+ const parsed = createUrlIfValid(options.dashboardUrl);
1817
+ if (parsed == null) return `${options.dashboardUrl.replace(/\/+$/, "")}${route}?${params.toString()}`;
1818
+ parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}${route}`;
1819
+ parsed.search = params.toString();
1820
+ parsed.hash = "";
1821
+ return parsed.toString();
1774
1822
  }
1775
- function normalizeRepoRelativePath(value, flagName) {
1776
- const normalized = value.trim().replace(/^(?:\.?\/+)+/, "");
1777
- if (normalized.length === 0) throw new CliError(`${flagName} must be a non-empty repo-relative path string.`);
1778
- return normalized;
1779
- }
1780
- function buildConfigPushSource(configFilePath, flags) {
1781
- const dependentFlags = [
1782
- ["--source-repo", flags.sourceRepo],
1783
- ["--source-path", flags.sourcePath],
1784
- ["--source-workflow-path", flags.sourceWorkflowPath]
1785
- ];
1786
- const providedDependent = dependentFlags.filter(([, v]) => v !== void 0).map(([k]) => k);
1787
- if (flags.source !== void 0) {
1788
- if (flags.source !== "github") throw new CliError(`Invalid --source value '${flags.source}'. Only 'github' is supported.`);
1789
- const missing = dependentFlags.filter(([, v]) => v === void 0).map(([k]) => k);
1790
- if (missing.length > 0) throw new CliError(`When --source github is specified, the following flags are also required: ${missing.join(", ")}.`);
1791
- const { owner, repo } = parseOwnerRepo(flags.sourceRepo ?? throwErr("Expected --source-repo to be provided when --source github is specified; this should have been caught by the missing-flags check."), "--source-repo");
1792
- const sourcePath = normalizeRepoRelativePath(flags.sourcePath ?? throwErr("Expected --source-path to be provided when --source github is specified; this should have been caught by the missing-flags check."), "--source-path");
1793
- const sourceWorkflowPath = normalizeRepoRelativePath(flags.sourceWorkflowPath ?? throwErr("Expected --source-workflow-path to be provided when --source github is specified; this should have been caught by the missing-flags check."), "--source-workflow-path");
1794
- const sha = process.env.GITHUB_SHA;
1795
- const branch = process.env.GITHUB_REF_NAME;
1796
- if (!sha) throw new CliError("--source github requires the GITHUB_SHA environment variable (commit hash) to be set.");
1797
- if (!branch) throw new CliError("--source github requires the GITHUB_REF_NAME environment variable (branch) to be set.");
1798
- return {
1799
- type: "pushed-from-github",
1800
- owner,
1801
- repo,
1802
- branch,
1803
- commit_hash: sha,
1804
- config_file_path: sourcePath,
1805
- workflow_path: sourceWorkflowPath
1806
- };
1807
- }
1808
- if (providedDependent.length > 0) throw new CliError(`${providedDependent.join(", ")} can only be used with --source github.`);
1809
- const repository = parseGitHubRepositoryEnv();
1810
- const sha = process.env.GITHUB_SHA;
1811
- const branch = process.env.GITHUB_REF_NAME;
1812
- if (repository && sha && branch) return {
1813
- type: "pushed-from-github",
1814
- owner: repository.owner,
1815
- repo: repository.repo,
1816
- branch,
1817
- commit_hash: sha,
1818
- config_file_path: configFilePath
1819
- };
1820
- return { type: "pushed-from-unknown" };
1821
- }
1822
- async function pushConfigWithSecretServerKey(auth, config, source) {
1823
- const endpoint = `${auth.apiUrl.replace(/\/$/, "")}/api/v1/internal/config/override/branch`;
1824
- const response = await fetch(endpoint, {
1825
- method: "PUT",
1826
- headers: {
1827
- "content-type": "application/json",
1828
- "x-stack-project-id": auth.projectId,
1829
- "x-stack-access-type": "server",
1830
- "x-stack-secret-server-key": auth.secretServerKey
1831
- },
1832
- body: JSON.stringify({
1833
- config_string: JSON.stringify(config),
1834
- source
1835
- })
1836
- });
1837
- if (response.ok) return;
1838
- const responseText = await response.text();
1839
- throw new CliError(`Failed to push config with HEXCLAVE_SECRET_SERVER_KEY: ${responseText.length > 0 ? responseText : `Request failed with status ${response.status}.`}`);
1840
- }
1841
- /**
1842
- * Pushes a parsed config override to the project's branch config. Shared by
1843
- * `hexclave config push` and `hexclave deploy` (which pushes the config file
1844
- * by default before deploying) so both use the exact same auth paths.
1845
- */
1846
- async function pushConfigToProject(auth, config, source) {
1847
- if (isProjectAuthWithSecretServerKey(auth)) await pushConfigWithSecretServerKey(auth, config, source);
1848
- else {
1849
- if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("Pushing the config requires either HEXCLAVE_SECRET_SERVER_KEY or `hexclave login`.");
1850
- await (await getAdminProject(auth)).pushConfig(config, { source: sourceToSdkSource(source) });
1851
- }
1852
- }
1853
- function sourceToSdkSource(source) {
1854
- if (source.type === "pushed-from-github") return {
1855
- type: "pushed-from-github",
1856
- owner: source.owner,
1857
- repo: source.repo,
1858
- branch: source.branch,
1859
- commitHash: source.commit_hash,
1860
- configFilePath: source.config_file_path,
1861
- workflowPath: source.workflow_path
1862
- };
1863
- if (source.type === "pushed-from-unknown") return { type: "pushed-from-unknown" };
1864
- return { type: "unlinked" };
1865
- }
1866
- function resolveConfigFilePathForPull(opts, cwd) {
1867
- if (opts.configFile != null && opts.configFile !== "") return resolveConfigFilePathOption(opts.configFile);
1868
- const hexclaveCandidate = path$1.join(cwd, "hexclave.config.ts");
1869
- const legacyCandidate = path$1.join(cwd, "stack.config.ts");
1870
- const candidate = fs$1.existsSync(hexclaveCandidate) ? hexclaveCandidate : legacyCandidate;
1871
- if (!fs$1.existsSync(candidate)) return hexclaveCandidate;
1872
- if (fs$1.statSync(candidate).isDirectory()) throw new CliError(`Default config path points to a directory instead of a file: ${candidate}`);
1873
- return candidate;
1874
- }
1875
- function assertConfigPullTarget(filePath, opts) {
1876
- if (opts.overwrite === true) return;
1877
- 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.`);
1878
- }
1879
- function registerConfigCommand(program) {
1880
- const config = program.command("config").description("Manage project configuration files");
1881
- config.command("pull").description("Pull branch config to a local file").option("--cloud-project-id <id>", "Cloud project ID to pull config from (defaults to the HEXCLAVE_PROJECT_ID env var; STACK_PROJECT_ID is also supported)").option("--config-file <path>", "Path to write config file (.ts); defaults to ./hexclave.config.ts in the current directory").option("--overwrite", "Replace the config file if one already exists at the target path").action(async (opts) => {
1882
- const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
1883
- if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`hexclave config pull` requires `hexclave login`. Unset HEXCLAVE_SECRET_SERVER_KEY (or the legacy STACK_SECRET_SERVER_KEY) and try again.");
1884
- const filePath = resolveConfigFilePathForPull(opts, process.cwd());
1885
- if (path$1.extname(filePath) !== ".ts") throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
1886
- assertConfigPullTarget(filePath, opts);
1887
- await withProgress("Pulling config", async () => {
1888
- const configOverride = await (await getAdminProject(auth)).getConfigOverride("branch");
1889
- if (!isValidConfig(configOverride)) throw new CliError("Pulled branch config is not a valid local config object.");
1890
- await replaceConfigObject(filePath, configOverride);
1891
- });
1892
- console.log(`Config written to ${filePath}`);
1893
- });
1894
- 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 HEXCLAVE_PROJECT_ID env var; STACK_PROJECT_ID is also supported)").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) => {
1895
- const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
1896
- const filePath = resolveConfigFilePathOption(opts.configFile, { mustExist: true });
1897
- const ext = path$1.extname(filePath);
1898
- if (ext !== ".js" && ext !== ".ts") throw new CliError("Config file must have a .js or .ts extension.");
1899
- const source = buildConfigPushSource(opts.configFile, {
1900
- source: opts.source,
1901
- sourceRepo: opts.sourceRepo,
1902
- sourcePath: opts.sourcePath,
1903
- sourceWorkflowPath: opts.sourceWorkflowPath
1904
- });
1905
- const progress = startProgress("Loading config");
1906
- try {
1907
- const { createJiti } = await import("jiti");
1908
- const config = parseConfigOverride((await createJiti(import.meta.url).import(filePath)).config);
1909
- 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 = { ... };`);
1910
- progress.update("Pushing config");
1911
- await pushConfigToProject(auth, config, source);
1912
- } finally {
1913
- progress.stop();
1914
- }
1915
- console.log("Config pushed successfully.");
1916
- });
1917
- }
1918
- //#endregion
1919
- //#region src/commands/deploy.ts
1920
- const CONFIG_FILE_CANDIDATES = [
1921
- "hexclave.config.ts",
1922
- "hexclave.config.js",
1923
- "stack.config.ts",
1924
- "stack.config.js"
1925
- ];
1926
- const RUN_POLL_INTERVAL_MS = 3e3;
1927
- const RUN_POLL_TIMEOUT_MS = 3600 * 1e3;
1928
- const MAX_CONSECUTIVE_POLL_FAILURES = 5;
1929
- const BUILD_LOG_DRAIN_TIMEOUT_MS = 15e3;
1930
- /**
1931
- * Resolves the project config file for `--config-push`: --config-file wins (and
1932
- * must exist); otherwise the first existing candidate in cwd. Returns null when
1933
- * nothing was passed and no candidate exists. Exported for unit tests.
1934
- */
1935
- function resolveConfigPushPath(configOption, cwd) {
1936
- if (configOption != null && configOption !== "") {
1937
- const resolved = path.resolve(cwd, configOption);
1938
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) throw new CliError(`Config file not found: ${resolved}`);
1939
- return resolved;
1940
- }
1941
- for (const candidate of CONFIG_FILE_CANDIDATES) {
1942
- const resolved = path.resolve(cwd, candidate);
1943
- if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) return resolved;
1944
- }
1945
- return null;
1946
- }
1947
- /**
1948
- * The secret keys that MUST have a stored value for these services to deploy:
1949
- * every secret referenced without a default value. Exported for unit tests.
1950
- */
1951
- function collectRequiredSecretKeys(services) {
1952
- const requiredKeys = /* @__PURE__ */ new Set();
1953
- for (const service of services) for (const value of Object.values(service.env)) if (value.kind === "secret" && value.defaultValue === void 0) requiredKeys.add(value.secretKey);
1954
- return [...requiredKeys].sort();
1955
- }
1956
- async function buildAuthHeadersFactory(auth) {
1957
- if (isProjectAuthWithSecretServerKey(auth)) {
1958
- const headers = {
1959
- "x-stack-access-type": "server",
1960
- "x-stack-project-id": auth.projectId,
1961
- "x-stack-secret-server-key": auth.secretServerKey
1962
- };
1963
- return () => Promise.resolve(headers);
1964
- }
1965
- const user = await getInternalUser(auth);
1966
- return async () => {
1967
- const { accessToken } = await user.currentSession.getTokens();
1968
- if (accessToken == null) throw new AuthError("Could not obtain an access token. Run `hexclave login` again.");
1969
- return {
1970
- "x-stack-access-type": "admin",
1971
- "x-stack-project-id": auth.projectId,
1972
- "x-stack-admin-access-token": accessToken
1973
- };
1974
- };
1975
- }
1976
- async function deployApiFetch(auth, getAuthHeaders, apiPath, init) {
1977
- const url = `${auth.apiUrl.replace(/\/$/, "")}/api/latest${apiPath}`;
1978
- const response = await fetch(url, {
1979
- method: init.method,
1980
- headers: {
1981
- ...await getAuthHeaders(),
1982
- ...init.jsonBody !== void 0 ? { "content-type": "application/json" } : {}
1983
- },
1984
- body: init.jsonBody !== void 0 ? JSON.stringify(init.jsonBody) : void 0
1985
- });
1986
- const text = await response.text();
1987
- if (!response.ok) {
1988
- let message = text;
1989
- try {
1990
- const parsed = JSON.parse(text);
1991
- if (typeof parsed?.error === "string") message = parsed.error;
1992
- else if (typeof parsed?.error?.message === "string") message = parsed.error.message;
1993
- } catch {}
1994
- throw new CliError(`Deploy request failed (${response.status} at ${init.method} ${apiPath}): ${message.slice(0, 1e3)}`);
1995
- }
1996
- try {
1997
- return text === "" ? void 0 : JSON.parse(text);
1998
- } catch {
1999
- throw new CliError(`Unexpected non-JSON response from the Hexclave API at ${init.method} ${apiPath}.`);
2000
- }
2001
- }
2002
- function wait$1(ms) {
2003
- return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
2004
- }
2005
- /**
2006
- * Awaits `promise`, giving up after `ms`. The timer is cleared either way, so
2007
- * winning the race doesn't leave a pending timeout holding the process open for
2008
- * the rest of the window.
2009
- */
2010
- async function awaitAtMost(promise, ms) {
2011
- let timer = void 0;
2012
- const timeout = new Promise((resolvePromise) => {
2013
- timer = setTimeout(resolvePromise, ms);
2014
- });
2015
- try {
2016
- await Promise.race([promise, timeout]);
2017
- } finally {
2018
- clearTimeout(timer);
2019
- }
2020
- }
2021
- /**
2022
- * followBuildLogs, with any failure of the log stream ITSELF reduced to a
2023
- * warning. Wrapped rather than left bare because this promise is only awaited
2024
- * once the deploy is over: an unhandled rejection in the meantime would be
2025
- * reported as a crash, and a build log that cannot be read is a degraded
2026
- * deploy, never a failed one.
2027
- */
2028
- async function followBuildLogsSafely(options) {
2029
- try {
2030
- await followBuildLogs(options);
2031
- } catch (error) {
2032
- console.error(`Warning: stopped streaming the build logs (${errorMessage$1(error)}). They are still readable in the dashboard.`);
2033
- }
2034
- }
2035
- /** The build-log endpoint for one deployment. */
2036
- function deploymentBuildLogsUrl(apiUrl, deploymentId) {
2037
- return `${apiUrl.replace(/\/$/, "")}/api/latest/deployments/deployments/${encodeURIComponent(deploymentId)}/logs`;
2038
- }
2039
- function collectPublicUrls(deploySet, services, results) {
2040
- return deploySet.flatMap((serviceId) => {
2041
- const service = services.get(serviceId) ?? (() => {
2042
- throw new CliError(`Internal error: deploy set contains unknown service ${JSON.stringify(serviceId)}.`);
2043
- })();
2044
- const result = results.get(serviceId) ?? (() => {
2045
- throw new CliError(`Internal error: no deploy result for service ${JSON.stringify(serviceId)}.`);
2046
- })();
2047
- if (service.definition.public !== true || result.status !== "deployed" || result.url === null) return [];
2048
- const holderUrl = result.url;
2049
- return deploymentPortEntries(service.definition.ports).map((entry) => deploymentPortOwnsStandardPorts(service.definition.ports, true, entry.port) ? {
2050
- serviceId,
2051
- url: holderUrl
2052
- } : {
2053
- serviceId,
2054
- url: `${holderUrl}:${entry.port}`
2055
- });
2056
- });
2057
- }
2058
- /**
2059
- * Where to read this deployment in the dashboard.
2060
- *
2061
- * The deployments page keeps its selection in the URL, so this can open the
2062
- * exact thing a reader wants: the deployment's service map, or one service's
2063
- * build log. `panel` is the dashboard's own tab id, not a name invented here.
2064
- */
2065
- function deploymentDashboardUrl(options) {
2066
- const params = new URLSearchParams({ deploymentId: options.deploymentId });
2067
- if (options.serviceId != null && options.serviceId !== "") {
2068
- params.set("serviceId", options.serviceId);
2069
- if (options.buildLogs === true) params.set("panel", "build-logs");
2070
- }
2071
- const route = `/projects/${encodeURIComponent(options.projectId)}/deployments`;
2072
- const parsed = createUrlIfValid(options.dashboardUrl);
2073
- if (parsed == null) return `${options.dashboardUrl.replace(/\/+$/, "")}${route}?${params.toString()}`;
2074
- parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}${route}`;
2075
- parsed.search = params.toString();
2076
- parsed.hash = "";
2077
- return parsed.toString();
2078
- }
2079
- /**
2080
- * The service whose build log the reader is about to go looking for: the first
2081
- * one that failed, in the order the deploy applied them. Null when none did.
2082
- *
2083
- * The FIRST rather than every one, because a level's failure skips everything
2084
- * after it — later failures are consequences, and the first is the cause.
2085
- */
2086
- function firstFailedService(deploySet, results) {
2087
- return deploySet.find((serviceId) => results.get(serviceId)?.status === "failed") ?? null;
1823
+ /**
1824
+ * The service whose build log the reader is about to go looking for: the first
1825
+ * one that failed, in the order the deploy applied them. Null when none did.
1826
+ *
1827
+ * The FIRST rather than every one, because a level's failure skips everything
1828
+ * after it — later failures are consequences, and the first is the cause.
1829
+ */
1830
+ function firstFailedService(deploySet, results) {
1831
+ return deploySet.find((serviceId) => results.get(serviceId)?.status === "failed") ?? null;
2088
1832
  }
2089
1833
  /**
2090
1834
  * Packages the deployment source ONCE and uploads it. Every service is built
@@ -2228,51 +1972,69 @@ async function waitForDeployment(options) {
2228
1972
  }
2229
1973
  }
2230
1974
  /**
2231
- * What this deploy ships: a deploy file when there is one, otherwise the config
2232
- * file, for a project small enough to keep its services there. The config file
2233
- * has no `deploymentGroupId` export, so its deployments belong to a group named
2234
- * after the file itself — which is what lets them coexist with the deploy files
2235
- * of other repositories deploying into the same project.
1975
+ * The environment a `hexclave deploy` inherits, reduced to the GitLab-style
1976
+ * `CI_*` variables that describe the commit being deployed. Sent with the deploy
1977
+ * request and injected into every service's env, so a build can stamp the
1978
+ * revision it came from without the deploy file having to name a CI provider.
1979
+ *
1980
+ * GitLab already sets these, so they pass straight through; GitHub Actions gets
1981
+ * translated into the same names, and anything else that exports them by hand
1982
+ * wins over both. A variable nothing can answer is simply absent — never an
1983
+ * empty string, which a service would read as "set, but blank".
1984
+ *
1985
+ * Exported for unit tests.
1986
+ */
1987
+ function collectCiEnv(rawEnv) {
1988
+ const env = Object.fromEntries(Object.entries(rawEnv).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
1989
+ const ciEnv = {
1990
+ CI_COMMIT_SHA: env.CI_COMMIT_SHA ?? env.GITHUB_SHA,
1991
+ CI_COMMIT_SHORT_SHA: env.CI_COMMIT_SHORT_SHA ?? env.GITHUB_SHA?.slice(0, 8),
1992
+ CI_COMMIT_REF_NAME: env.CI_COMMIT_REF_NAME ?? env.GITHUB_HEAD_REF ?? env.GITHUB_REF_NAME,
1993
+ CI_COMMIT_BRANCH: env.CI_COMMIT_BRANCH ?? (env.GITHUB_REF_TYPE === "branch" && !env.GITHUB_HEAD_REF ? env.GITHUB_REF_NAME : void 0),
1994
+ CI_COMMIT_TAG: env.CI_COMMIT_TAG ?? (env.GITHUB_REF_TYPE === "tag" ? env.GITHUB_REF_NAME : void 0),
1995
+ CI_REPOSITORY_URL: env.CI_SERVER_URL && env.CI_PROJECT_PATH ? `${env.CI_SERVER_URL}/${env.CI_PROJECT_PATH}.git` : env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}.git` : void 0
1996
+ };
1997
+ return Object.fromEntries(Object.entries(ciEnv).filter((entry) => entry[1] !== void 0 && entry[1] !== ""));
1998
+ }
1999
+ /**
2000
+ * What this deploy ships: the `deploy` export of a deploy file. Deployments live
2001
+ * in their OWN file — hexclave.config.ts holds the project's configuration and
2002
+ * nothing else — so a project with no deploy file has nothing to deploy.
2236
2003
  */
2237
2004
  async function resolveDeploySource(deployFileOption, cwd) {
2238
- if (deployFileOption == null || deployFileOption === "") {
2239
- const configPath = resolveConfigPushPath(void 0, cwd);
2240
- if (configPath !== null && !hasDeployFile(cwd)) {
2241
- const configModule = await importConfigModule(configPath);
2242
- if (configModule.deploy !== void 0) return {
2243
- path: configPath,
2244
- deploymentGroupIdExport: CONFIG_FILE_DEPLOYMENT_SOURCE_ID,
2245
- deployExport: configModule.deploy
2246
- };
2247
- }
2248
- }
2249
2005
  const deployFilePath = resolveDeployFilePath(deployFileOption, cwd);
2250
2006
  const deployModule = await importDeployModule(deployFilePath);
2251
2007
  return {
2252
2008
  path: deployFilePath,
2253
2009
  deploymentGroupIdExport: deployModule.deploymentGroupId,
2254
2010
  legacyIdExport: deployModule.legacyId,
2255
- deployExport: deployModule.deploy
2011
+ deployExport: deployModule.deploy,
2012
+ versionExport: deployModule.version
2256
2013
  };
2257
2014
  }
2258
2015
  function registerDeployCommand(program) {
2259
- program.command("deploy").description("Deploy the services defined by the `deploy` export of your hexclave.deploy.ts. Syncs the service definitions, then deploys every service in dependency order and waits for the remote builds to finish.").option("--service-id <id>", "Deploy only this service (its connections resolve against already-deployed services)").option("--deploy-file <path>", "Path to the deploy file (default: auto-discover hexclave.deploy.ts in the current directory)").option("--config-push", "Also push the project config file's `config` export to the project before deploying").option("--config-file <path>", "Path to the project config file for --config-push (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("--no-build-logs", "Don't stream the remote build's output; report service status only").addHelpText("after", "\nAuthentication: uses HEXCLAVE_SECRET_SERVER_KEY if set (recommended for CI), otherwise your `hexclave login` session.\nSecrets: values for secret() env vars are read from the dashboard (Project Settings > Secrets); the deploy fails up front and lists every secret that still needs a value there.").action(async (opts) => {
2016
+ program.command("deploy").description("Deploy the services defined by the `deploy` export of your hexclave.deploy.ts. Syncs the service definitions, then deploys every service in dependency order and waits for the remote builds to finish.").option("--service-id <id>", "Deploy only this service (its connections resolve against already-deployed services)").option("--deploy-file <path>", "Path to the deploy file (default: auto-discover hexclave.deploy.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("--no-build-logs", "Don't stream the remote build's output; report service status only").addHelpText("after", "\nAuthentication: uses HEXCLAVE_SECRET_SERVER_KEY if set (recommended for CI), otherwise your `hexclave login` session.\nSecrets: values for secret() env vars are read from the dashboard (Project Settings > Secrets); the deploy fails up front and lists every secret that still needs a value there.").action(async (opts) => {
2260
2017
  const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
2261
2018
  const authHeaders = await buildAuthHeadersFactory(auth);
2262
2019
  const deploySource = await resolveDeploySource(opts.deployFile, process.cwd());
2263
- const { sourceId, services } = evaluateDeploymentConfig({
2020
+ const { sourceId, services, builder, runtime, version } = evaluateDeploymentConfig({
2264
2021
  deployFilePath: deploySource.path,
2265
2022
  deploymentGroupIdExport: deploySource.deploymentGroupIdExport,
2266
2023
  legacyIdExport: deploySource.legacyIdExport,
2267
2024
  deployExport: deploySource.deployExport,
2025
+ versionExport: deploySource.versionExport,
2268
2026
  mode: "deploy"
2269
2027
  });
2270
2028
  let levels;
2271
2029
  if (opts.serviceId != null) {
2272
2030
  if (services.get(opts.serviceId) == null) throw new CliError(`No service named ${JSON.stringify(opts.serviceId)} in the deploy file's \`deploy\` export. Available services: ${[...services.keys()].join(", ")}.`);
2273
2031
  levels = [[opts.serviceId]];
2274
- } else levels = computeDeploymentLevels(services);
2032
+ } else levels = computeDeploymentLevels(services, runtime);
2275
2033
  const deploySet = levels.flat();
2034
+ if (builder?.memory !== void 0 && !deploySet.some((serviceId) => {
2035
+ const service = services.get(serviceId);
2036
+ return service !== void 0 && deploymentServiceIsBuilt(service.definition);
2037
+ })) console.error(`Note: deploy.builder sets memory ${JSON.stringify(builder.memory)}, but ${opts.serviceId != null ? `services.${opts.serviceId} runs` : "every service in this deploy runs"} an already-built image, so no builder machine starts and the size has no effect here.`);
2276
2038
  const requiredSecretKeys = collectRequiredSecretKeys(deploySet.map((serviceId) => services.get(serviceId) ?? (() => {
2277
2039
  throw new CliError(`Internal error: deploy set contains unknown service ${JSON.stringify(serviceId)}.`);
2278
2040
  })()));
@@ -2287,22 +2049,14 @@ function registerDeployCommand(program) {
2287
2049
  "All of them must be set in the dashboard under Project Settings > Secrets before this deploy can run."
2288
2050
  ].join("\n"));
2289
2051
  }
2290
- if (opts.configPush === true) {
2291
- const configPath = resolveConfigPushPath(opts.configFile, process.cwd());
2292
- if (configPath == null) throw new CliError(`--config-push was passed, but no config file was found in ${process.cwd()} (looked for ${CONFIG_FILE_CANDIDATES.join(", ")}). Pass --config-file <path>, or drop --config-push to deploy without publishing the project config.`);
2293
- const configModule = await importConfigModule(configPath);
2294
- if (configModule.config === void 0) throw new CliError(`--config-push was passed, but ${configPath} has no \`config\` export. Add one, or drop --config-push.`);
2295
- const config = parseConfigOverride(configModule.config);
2296
- if (config == null) throw new CliError(`The \`config\` export of ${configPath} must be a plain object (or "show-onboarding"). Fix it, or drop --config-push to deploy without pushing the config.`);
2297
- console.error("Pushing config...");
2298
- await pushConfigToProject(auth, config, buildConfigPushSource(path.relative(process.cwd(), configPath).split(path.sep).join("/"), {}));
2299
- }
2300
2052
  console.error(`Syncing ${services.size} service definition${services.size === 1 ? "" : "s"} for deployment source ${JSON.stringify(sourceId)}...`);
2301
2053
  const syncResponse = await deployApiFetch(auth, authHeaders, "/deployments/services", {
2302
2054
  method: "PUT",
2303
2055
  jsonBody: {
2304
2056
  source_id: sourceId,
2305
- services: Object.fromEntries([...services.values()].map((service) => [service.serviceId, service.definition]))
2057
+ services: Object.fromEntries([...services.values()].map((service) => [service.serviceId, service.definition])),
2058
+ ...builder === void 0 ? {} : { builder },
2059
+ ...version === void 0 ? {} : { version }
2306
2060
  }
2307
2061
  });
2308
2062
  if (typeof syncResponse?.sync_id !== "string") throw new CliError("Unexpected response from the Hexclave API when syncing service definitions.");
@@ -2334,12 +2088,18 @@ function registerDeployCommand(program) {
2334
2088
  secret_defaults: Object.fromEntries(deploySet.map((serviceId) => [serviceId, collectSecretDefaults(services.get(serviceId) ?? (() => {
2335
2089
  throw new CliError(`Internal error: deploy set contains unknown service ${JSON.stringify(serviceId)}.`);
2336
2090
  })())])),
2091
+ ci_env: collectCiEnv(process.env),
2337
2092
  triggered_by: "cli"
2338
2093
  }
2339
2094
  });
2340
2095
  if (typeof deploymentResponse?.id !== "string") throw new CliError("Unexpected response from the Hexclave API when starting the deployment.");
2341
2096
  const deploymentId = deploymentResponse.id;
2342
2097
  console.error(`Deployment #${deploymentResponse.number} started. ${buildsFromSource ? "Waiting for the remote build..." : "Nothing to build — waiting for the services to come up..."}`);
2098
+ for (const notice of Array.isArray(deploymentResponse.notices) ? deploymentResponse.notices : []) {
2099
+ if (typeof notice !== "string") continue;
2100
+ console.error("");
2101
+ console.error(notice);
2102
+ }
2343
2103
  const streamBuildLogs = buildsFromSource && opts.buildLogs !== false;
2344
2104
  let deploymentFinished = false;
2345
2105
  const buildLogsAbort = new AbortController();
@@ -2433,6 +2193,20 @@ function registerDeployCommand(program) {
2433
2193
  });
2434
2194
  }
2435
2195
  //#endregion
2196
+ //#region src/lib/config-file-path.ts
2197
+ function resolveConfigFilePathOption(inputPath, options) {
2198
+ const resolved = resolve(inputPath);
2199
+ const optionName = options?.optionName ?? "--config-file";
2200
+ if (!existsSync(resolved)) {
2201
+ if (options?.mustExist === true) throw new CliError(`Config file not found: ${resolved}`);
2202
+ return resolved;
2203
+ }
2204
+ const stat = statSync(resolved);
2205
+ if (stat.isDirectory()) throw new CliError(`${optionName} must point to a config file, but got a directory: ${resolved}`);
2206
+ if (!stat.isFile()) throw new CliError(`${optionName} must point to a regular config file: ${resolved}`);
2207
+ return resolved;
2208
+ }
2209
+ //#endregion
2436
2210
  //#region src/lib/dev-env-state.ts
2437
2211
  function devEnvStatePath() {
2438
2212
  return hexclaveDevEnvStatePath();
@@ -2680,6 +2454,254 @@ function registerExecCommand(program) {
2680
2454
  });
2681
2455
  }
2682
2456
  //#endregion
2457
+ //#region src/lib/progress.ts
2458
+ const SPINNER_FRAMES$1 = [
2459
+ "⠋",
2460
+ "⠙",
2461
+ "⠹",
2462
+ "⠸",
2463
+ "⠼",
2464
+ "⠴",
2465
+ "⠦",
2466
+ "⠧",
2467
+ "⠇",
2468
+ "⠏"
2469
+ ];
2470
+ const SPINNER_INTERVAL_MS = 80;
2471
+ /**
2472
+ * Reports long-running CLI work without contaminating stdout. Interactive
2473
+ * terminals get a single animated line, while redirected output gets durable
2474
+ * lines that remain useful in CI logs.
2475
+ */
2476
+ function startProgress(initialMessage, options = {}) {
2477
+ const stream = options.stream ?? process.stderr;
2478
+ const prefix = options.prefix ?? "";
2479
+ let message = initialMessage;
2480
+ let stopped = false;
2481
+ if (!stream.isTTY) {
2482
+ stream.write(`${prefix}${message}...\n`);
2483
+ return {
2484
+ update(nextMessage) {
2485
+ if (stopped || nextMessage === message) return;
2486
+ message = nextMessage;
2487
+ stream.write(`${prefix}${message}...\n`);
2488
+ },
2489
+ stop(finalMessage) {
2490
+ if (stopped) return;
2491
+ stopped = true;
2492
+ if (finalMessage != null) stream.write(`${prefix}${finalMessage}\n`);
2493
+ }
2494
+ };
2495
+ }
2496
+ let frameIndex = 0;
2497
+ const render = () => {
2498
+ stream.write(`\r\x1b[2K${prefix}${SPINNER_FRAMES$1[frameIndex]} ${message}`);
2499
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES$1.length;
2500
+ };
2501
+ render();
2502
+ const timer = setInterval(render, SPINNER_INTERVAL_MS);
2503
+ timer.unref();
2504
+ return {
2505
+ update(nextMessage) {
2506
+ if (stopped || nextMessage === message) return;
2507
+ message = nextMessage;
2508
+ render();
2509
+ },
2510
+ stop(finalMessage) {
2511
+ if (stopped) return;
2512
+ stopped = true;
2513
+ clearInterval(timer);
2514
+ stream.write("\r\x1B[2K");
2515
+ if (finalMessage != null) stream.write(`${prefix}${finalMessage}\n`);
2516
+ }
2517
+ };
2518
+ }
2519
+ async function withProgress(message, operation, options) {
2520
+ const progress = startProgress(message, options);
2521
+ try {
2522
+ return await operation();
2523
+ } finally {
2524
+ progress.stop();
2525
+ }
2526
+ }
2527
+ //#endregion
2528
+ //#region src/commands/config-file.ts
2529
+ const SHOW_ONBOARDING_STACK_CONFIG_VALUE = "show-onboarding";
2530
+ function isConfigOverride(value) {
2531
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
2532
+ const prototype = Object.getPrototypeOf(value);
2533
+ return prototype === Object.prototype || prototype === null;
2534
+ }
2535
+ function parseConfigOverride(value) {
2536
+ if (value === SHOW_ONBOARDING_STACK_CONFIG_VALUE) return {};
2537
+ return isConfigOverride(value) ? value : null;
2538
+ }
2539
+ const OWNER_REPO_SEGMENT = /^[A-Za-z0-9._-]+$/;
2540
+ function parseOwnerRepo(value, flagName) {
2541
+ const parts = value.split("/");
2542
+ if (parts.length !== 2 || !OWNER_REPO_SEGMENT.test(parts[0]) || !OWNER_REPO_SEGMENT.test(parts[1])) throw new CliError(`${flagName} must be in the format 'owner/repo' using only letters, digits, '.', '_' or '-' (got '${value}').`);
2543
+ return {
2544
+ owner: parts[0],
2545
+ repo: parts[1]
2546
+ };
2547
+ }
2548
+ function parseGitHubRepositoryEnv() {
2549
+ const repository = process.env.GITHUB_REPOSITORY;
2550
+ if (!repository) return null;
2551
+ try {
2552
+ return parseOwnerRepo(repository, "GITHUB_REPOSITORY");
2553
+ } catch {
2554
+ return null;
2555
+ }
2556
+ }
2557
+ function normalizeRepoRelativePath(value, flagName) {
2558
+ const normalized = value.trim().replace(/^(?:\.?\/+)+/, "");
2559
+ if (normalized.length === 0) throw new CliError(`${flagName} must be a non-empty repo-relative path string.`);
2560
+ return normalized;
2561
+ }
2562
+ function buildConfigPushSource(configFilePath, flags) {
2563
+ const dependentFlags = [
2564
+ ["--source-repo", flags.sourceRepo],
2565
+ ["--source-path", flags.sourcePath],
2566
+ ["--source-workflow-path", flags.sourceWorkflowPath]
2567
+ ];
2568
+ const providedDependent = dependentFlags.filter(([, v]) => v !== void 0).map(([k]) => k);
2569
+ if (flags.source !== void 0) {
2570
+ if (flags.source !== "github") throw new CliError(`Invalid --source value '${flags.source}'. Only 'github' is supported.`);
2571
+ const missing = dependentFlags.filter(([, v]) => v === void 0).map(([k]) => k);
2572
+ if (missing.length > 0) throw new CliError(`When --source github is specified, the following flags are also required: ${missing.join(", ")}.`);
2573
+ const { owner, repo } = parseOwnerRepo(flags.sourceRepo ?? throwErr("Expected --source-repo to be provided when --source github is specified; this should have been caught by the missing-flags check."), "--source-repo");
2574
+ const sourcePath = normalizeRepoRelativePath(flags.sourcePath ?? throwErr("Expected --source-path to be provided when --source github is specified; this should have been caught by the missing-flags check."), "--source-path");
2575
+ const sourceWorkflowPath = normalizeRepoRelativePath(flags.sourceWorkflowPath ?? throwErr("Expected --source-workflow-path to be provided when --source github is specified; this should have been caught by the missing-flags check."), "--source-workflow-path");
2576
+ const sha = process.env.GITHUB_SHA;
2577
+ const branch = process.env.GITHUB_REF_NAME;
2578
+ if (!sha) throw new CliError("--source github requires the GITHUB_SHA environment variable (commit hash) to be set.");
2579
+ if (!branch) throw new CliError("--source github requires the GITHUB_REF_NAME environment variable (branch) to be set.");
2580
+ return {
2581
+ type: "pushed-from-github",
2582
+ owner,
2583
+ repo,
2584
+ branch,
2585
+ commit_hash: sha,
2586
+ config_file_path: sourcePath,
2587
+ workflow_path: sourceWorkflowPath
2588
+ };
2589
+ }
2590
+ if (providedDependent.length > 0) throw new CliError(`${providedDependent.join(", ")} can only be used with --source github.`);
2591
+ const repository = parseGitHubRepositoryEnv();
2592
+ const sha = process.env.GITHUB_SHA;
2593
+ const branch = process.env.GITHUB_REF_NAME;
2594
+ if (repository && sha && branch) return {
2595
+ type: "pushed-from-github",
2596
+ owner: repository.owner,
2597
+ repo: repository.repo,
2598
+ branch,
2599
+ commit_hash: sha,
2600
+ config_file_path: configFilePath
2601
+ };
2602
+ return { type: "pushed-from-unknown" };
2603
+ }
2604
+ async function pushConfigWithSecretServerKey(auth, config, source) {
2605
+ const endpoint = `${auth.apiUrl.replace(/\/$/, "")}/api/v1/internal/config/override/branch`;
2606
+ const response = await fetch(endpoint, {
2607
+ method: "PUT",
2608
+ headers: {
2609
+ "content-type": "application/json",
2610
+ "x-stack-project-id": auth.projectId,
2611
+ "x-stack-access-type": "server",
2612
+ "x-stack-secret-server-key": auth.secretServerKey
2613
+ },
2614
+ body: JSON.stringify({
2615
+ config_string: JSON.stringify(config),
2616
+ source
2617
+ })
2618
+ });
2619
+ if (response.ok) return;
2620
+ const responseText = await response.text();
2621
+ throw new CliError(`Failed to push config with HEXCLAVE_SECRET_SERVER_KEY: ${responseText.length > 0 ? responseText : `Request failed with status ${response.status}.`}`);
2622
+ }
2623
+ /**
2624
+ * Pushes a parsed config override to the project's branch config, for
2625
+ * `hexclave config push`. Publishing the project's configuration is deliberately
2626
+ * its own command: `hexclave deploy` ships this repository's services and never
2627
+ * touches the config, so one of several repositories deploying into a project
2628
+ * cannot overwrite the others' configuration on the way past.
2629
+ */
2630
+ async function pushConfigToProject(auth, config, source) {
2631
+ if (isProjectAuthWithSecretServerKey(auth)) await pushConfigWithSecretServerKey(auth, config, source);
2632
+ else {
2633
+ if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("Pushing the config requires either HEXCLAVE_SECRET_SERVER_KEY or `hexclave login`.");
2634
+ await (await getAdminProject(auth)).pushConfig(config, { source: sourceToSdkSource(source) });
2635
+ }
2636
+ }
2637
+ function sourceToSdkSource(source) {
2638
+ if (source.type === "pushed-from-github") return {
2639
+ type: "pushed-from-github",
2640
+ owner: source.owner,
2641
+ repo: source.repo,
2642
+ branch: source.branch,
2643
+ commitHash: source.commit_hash,
2644
+ configFilePath: source.config_file_path,
2645
+ workflowPath: source.workflow_path
2646
+ };
2647
+ if (source.type === "pushed-from-unknown") return { type: "pushed-from-unknown" };
2648
+ return { type: "unlinked" };
2649
+ }
2650
+ function resolveConfigFilePathForPull(opts, cwd) {
2651
+ if (opts.configFile != null && opts.configFile !== "") return resolveConfigFilePathOption(opts.configFile);
2652
+ const hexclaveCandidate = path$1.join(cwd, "hexclave.config.ts");
2653
+ const legacyCandidate = path$1.join(cwd, "stack.config.ts");
2654
+ const candidate = fs$1.existsSync(hexclaveCandidate) ? hexclaveCandidate : legacyCandidate;
2655
+ if (!fs$1.existsSync(candidate)) return hexclaveCandidate;
2656
+ if (fs$1.statSync(candidate).isDirectory()) throw new CliError(`Default config path points to a directory instead of a file: ${candidate}`);
2657
+ return candidate;
2658
+ }
2659
+ function assertConfigPullTarget(filePath, opts) {
2660
+ if (opts.overwrite === true) return;
2661
+ 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.`);
2662
+ }
2663
+ function registerConfigCommand(program) {
2664
+ const config = program.command("config").description("Manage project configuration files");
2665
+ config.command("pull").description("Pull branch config to a local file").option("--cloud-project-id <id>", "Cloud project ID to pull config from (defaults to the HEXCLAVE_PROJECT_ID env var; STACK_PROJECT_ID is also supported)").option("--config-file <path>", "Path to write config file (.ts); defaults to ./hexclave.config.ts in the current directory").option("--overwrite", "Replace the config file if one already exists at the target path").action(async (opts) => {
2666
+ const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
2667
+ if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`hexclave config pull` requires `hexclave login`. Unset HEXCLAVE_SECRET_SERVER_KEY (or the legacy STACK_SECRET_SERVER_KEY) and try again.");
2668
+ const filePath = resolveConfigFilePathForPull(opts, process.cwd());
2669
+ if (path$1.extname(filePath) !== ".ts") throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
2670
+ assertConfigPullTarget(filePath, opts);
2671
+ await withProgress("Pulling config", async () => {
2672
+ const configOverride = await (await getAdminProject(auth)).getConfigOverride("branch");
2673
+ if (!isValidConfig(configOverride)) throw new CliError("Pulled branch config is not a valid local config object.");
2674
+ await replaceConfigObject(filePath, configOverride);
2675
+ });
2676
+ console.log(`Config written to ${filePath}`);
2677
+ });
2678
+ 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 HEXCLAVE_PROJECT_ID env var; STACK_PROJECT_ID is also supported)").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) => {
2679
+ const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
2680
+ const filePath = resolveConfigFilePathOption(opts.configFile, { mustExist: true });
2681
+ const ext = path$1.extname(filePath);
2682
+ if (ext !== ".js" && ext !== ".ts") throw new CliError("Config file must have a .js or .ts extension.");
2683
+ const source = buildConfigPushSource(opts.configFile, {
2684
+ source: opts.source,
2685
+ sourceRepo: opts.sourceRepo,
2686
+ sourcePath: opts.sourcePath,
2687
+ sourceWorkflowPath: opts.sourceWorkflowPath
2688
+ });
2689
+ const progress = startProgress("Loading config");
2690
+ try {
2691
+ const { createJiti } = await import("jiti");
2692
+ const configModule = await createJiti(import.meta.url).import(filePath);
2693
+ if ("deploy" in configModule) throw new CliError(`${filePath} has a \`deploy\` export. Service definitions no longer live in the config file — move it to a hexclave.deploy.ts (with an \`export const deploymentGroupId = "..."\` naming the deployment group) and ship it with \`hexclave deploy\`, which is a separate command from \`hexclave config push\`.`);
2694
+ const config = parseConfigOverride(configModule.config);
2695
+ 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 = { ... };`);
2696
+ progress.update("Pushing config");
2697
+ await pushConfigToProject(auth, config, source);
2698
+ } finally {
2699
+ progress.stop();
2700
+ }
2701
+ console.log("Config pushed successfully.");
2702
+ });
2703
+ }
2704
+ //#endregion
2683
2705
  //#region src/lib/claude-agent.ts
2684
2706
  const SPINNER_FRAMES = [
2685
2707
  "⠋",
@@ -3571,7 +3593,7 @@ function splitDevCommandArgs(commandArgs) {
3571
3593
  };
3572
3594
  }
3573
3595
  /**
3574
- * Wraps a devCommand string (from the config's `services` export) for
3596
+ * Wraps a devCommand string (from the deploy file's `deploy` export) for
3575
3597
  * execution through the platform shell — unlike the `-- <command> [args...]`
3576
3598
  * form, a devCommand is a single command LINE (e.g. "pnpm dev --port 3000")
3577
3599
  * and may use shell syntax. Exported for unit tests.
@@ -4189,6 +4211,7 @@ function registerDevCommand(program) {
4189
4211
  deploymentGroupIdExport: deployModule.deploymentGroupId,
4190
4212
  legacyIdExport: deployModule.legacyId,
4191
4213
  deployExport: deployModule.deploy,
4214
+ versionExport: deployModule.version,
4192
4215
  mode: "dev"
4193
4216
  });
4194
4217
  devService = services.get(opts.serviceId);
@@ -4201,7 +4224,7 @@ function registerDevCommand(program) {
4201
4224
  return null;
4202
4225
  }
4203
4226
  })();
4204
- if (rootDirectoryStats == null || !rootDirectoryStats.isDirectory()) throw new CliError(`The rootDirectory of the service ${JSON.stringify(opts.serviceId)} is ${devService.absoluteRootDirectory}, which ${rootDirectoryStats == null ? "does not exist" : "is not a directory"}. Fix \`rootDirectory\` in the config file's services export — it is where the service's devCommand runs.`);
4227
+ if (rootDirectoryStats == null || !rootDirectoryStats.isDirectory()) throw new CliError(`The rootDirectory of the service ${JSON.stringify(opts.serviceId)} is ${devService.absoluteRootDirectory}, which ${rootDirectoryStats == null ? "does not exist" : "is not a directory"}. Fix \`rootDirectory\` in the deploy file's \`deploy\` export — it is where the service's devCommand runs.`);
4205
4228
  }
4206
4229
  const childCommand = commandArgs.length > 0 ? splitDevCommandArgs(commandArgs) : shellChildCommand(devService?.devCommand ?? (() => {
4207
4230
  throw new CliError("Internal error: no dev command resolved despite the checks above.");