@hexclave/cli 1.0.118 → 1.0.120
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 +507 -484
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/config-file.ts +18 -6
- package/src/commands/deploy.test.ts +80 -15
- package/src/commands/deploy.ts +95 -94
- package/src/commands/dev.ts +5 -4
- package/src/lib/deployment-config.test.ts +144 -36
- package/src/lib/deployment-config.ts +148 -74
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 {
|
|
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
|
|
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
|
|
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
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
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
|
-
*
|
|
1647
|
-
*
|
|
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
|
-
|
|
1654
|
-
const
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
return
|
|
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
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
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
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
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
|
-
|
|
1738
|
-
|
|
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
|
-
|
|
1760
|
+
await Promise.race([promise, timeout]);
|
|
1741
1761
|
} finally {
|
|
1742
|
-
|
|
1762
|
+
clearTimeout(timer);
|
|
1743
1763
|
}
|
|
1744
1764
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
1754
|
-
|
|
1755
|
-
return
|
|
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
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
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
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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
|
-
*
|
|
2232
|
-
*
|
|
2233
|
-
*
|
|
2234
|
-
*
|
|
2235
|
-
*
|
|
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("--
|
|
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
|
|
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
|
|
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.");
|