@hexclave/cli 1.0.117 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexclave/cli",
3
- "version": "1.0.117",
3
+ "version": "1.0.119",
4
4
  "repository": "https://github.com/hexclave/hexclave",
5
5
  "description": "The CLI for Hexclave. https://hexclave.com",
6
6
  "main": "dist/index.js",
@@ -27,9 +27,9 @@
27
27
  "extract-zip": "^2.0.1",
28
28
  "jiti": "^2.4.2",
29
29
  "tar": "^7.4.3",
30
- "@hexclave/shared-backend": "1.0.117",
31
- "@hexclave/js": "1.0.117",
32
- "@hexclave/shared": "1.0.117"
30
+ "@hexclave/shared-backend": "1.0.119",
31
+ "@hexclave/js": "1.0.119",
32
+ "@hexclave/shared": "1.0.119"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/cross-spawn": "^6.0.6",
@@ -22,7 +22,7 @@ function isConfigOverride(value: unknown): value is EnvironmentConfigOverrideOve
22
22
  return prototype === Object.prototype || prototype === null;
23
23
  }
24
24
 
25
- export function parseConfigOverride(value: unknown): EnvironmentConfigOverrideOverride | null {
25
+ function parseConfigOverride(value: unknown): EnvironmentConfigOverrideOverride | null {
26
26
  if (value === SHOW_ONBOARDING_STACK_CONFIG_VALUE) {
27
27
  return {};
28
28
  }
@@ -176,11 +176,13 @@ async function pushConfigWithSecretServerKey(
176
176
  }
177
177
 
178
178
  /**
179
- * Pushes a parsed config override to the project's branch config. Shared by
180
- * `hexclave config push` and `hexclave deploy` (which pushes the config file
181
- * by default before deploying) so both use the exact same auth paths.
179
+ * Pushes a parsed config override to the project's branch config, for
180
+ * `hexclave config push`. Publishing the project's configuration is deliberately
181
+ * its own command: `hexclave deploy` ships this repository's services and never
182
+ * touches the config, so one of several repositories deploying into a project
183
+ * cannot overwrite the others' configuration on the way past.
182
184
  */
183
- export async function pushConfigToProject(auth: ProjectAuth, config: EnvironmentConfigOverrideOverride, source: BranchConfigSourceApi): Promise<void> {
185
+ async function pushConfigToProject(auth: ProjectAuth, config: EnvironmentConfigOverrideOverride, source: BranchConfigSourceApi): Promise<void> {
184
186
  if (isProjectAuthWithSecretServerKey(auth)) {
185
187
  await pushConfigWithSecretServerKey(auth, config, source);
186
188
  } else {
@@ -322,7 +324,17 @@ export function registerConfigCommand(program: Command) {
322
324
  // `@hexclave/js`) from the project's own node_modules.
323
325
  const { createJiti } = await import("jiti");
324
326
  const jiti = createJiti(import.meta.url);
325
- const configModule: { config?: unknown } = await jiti.import(filePath);
327
+ const configModule: { config?: unknown, deploy?: unknown } = await jiti.import(filePath);
328
+
329
+ // Services used to be allowed here. Refused rather than ignored: a
330
+ // silently skipped `deploy` export would leave the author believing
331
+ // their services were published by a command that never looks at them.
332
+ // Keyed on the export EXISTING rather than on its value: `export let
333
+ // deploy;` is still a file whose author thinks it deploys something.
334
+ // (`in` rather than Object.hasOwn: this package targets ES2021.)
335
+ if ("deploy" in configModule) {
336
+ 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\`.`);
337
+ }
326
338
 
327
339
  const config = parseConfigOverride(configModule.config);
328
340
  if (config == null) {
@@ -2,8 +2,8 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { evaluateDeploymentConfig, importConfigModule, importDeployModule, resolveDeployFilePath, type ServicesFunctionContext } from "../lib/deployment-config.js";
6
- import { collectPublicUrls, collectRequiredSecretKeys, deploymentDashboardUrl, firstFailedService, packageAndUploadSource, resolveConfigPushPath, type ServiceDeployResult } from "./deploy.js";
5
+ import { evaluateDeploymentConfig, importDeployModule, resolveDeployFilePath, type ServicesFunctionContext } from "../lib/deployment-config.js";
6
+ import { collectCiEnv, collectPublicUrls, collectRequiredSecretKeys, deploymentDashboardUrl, firstFailedService, packageAndUploadSource, type ServiceDeployResult } from "./deploy.js";
7
7
 
8
8
  const TEST_AUTH = {
9
9
  apiUrl: "https://api.example.com",
@@ -46,19 +46,15 @@ describe("deploy command helpers", () => {
46
46
  expect(resolveDeployFilePath(undefined, dir)).toBe(path.join(dir, "hexclave.deploy.ts"));
47
47
  });
48
48
 
49
- it("loads the author-facing deploy export from deploy and config files", async () => {
49
+ it("loads the author-facing deploy export from a deploy file", async () => {
50
50
  const dir = makeTempDir();
51
51
  const deployFilePath = path.join(dir, "hexclave.deploy.ts");
52
- const configFilePath = path.join(dir, "hexclave.config.ts");
53
52
  fs.writeFileSync(deployFilePath, 'export const deploymentGroupId = "source"; export const deploy = () => ({ services: {} });');
54
- fs.writeFileSync(configFilePath, "export const config = {}; export const deploy = () => ({ services: {} });");
55
53
 
56
54
  const deployModule = await importDeployModule(deployFilePath);
57
- const configModule = await importConfigModule(configFilePath);
58
55
  expect(deployModule.deploymentGroupId).toBe("source");
59
56
  expect(deployModule.legacyId).toBeUndefined();
60
57
  expect(deployModule.deploy).toBeTypeOf("function");
61
- expect(configModule.deploy).toBeTypeOf("function");
62
58
  });
63
59
 
64
60
  // The deploy file is a different document from hexclave.config.ts, so a
@@ -69,14 +65,83 @@ describe("deploy command helpers", () => {
69
65
  expect(() => resolveDeployFilePath(undefined, dir)).toThrow("No deploy file found");
70
66
  });
71
67
 
72
- it("resolves the config file for --config-push, and returns null when there is none", () => {
73
- const dir = makeTempDir();
74
- expect(resolveConfigPushPath(undefined, dir)).toBe(null);
75
- fs.writeFileSync(path.join(dir, "stack.config.ts"), "export const config = {};");
76
- expect(resolveConfigPushPath(undefined, dir)).toBe(path.join(dir, "stack.config.ts"));
77
- fs.writeFileSync(path.join(dir, "hexclave.config.ts"), "export const config = {};");
78
- expect(resolveConfigPushPath(undefined, dir)).toBe(path.join(dir, "hexclave.config.ts"));
79
- expect(() => resolveConfigPushPath("missing.config.ts", dir)).toThrow("Config file not found");
68
+ describe("collectCiEnv", () => {
69
+ it("is empty outside CI", () => {
70
+ expect(collectCiEnv({ PATH: "/usr/bin", HOME: "/home/me" })).toEqual({});
71
+ });
72
+
73
+ it("passes GitLab's own variables through", () => {
74
+ expect(collectCiEnv({
75
+ CI_COMMIT_SHA: "0123456789abcdef",
76
+ CI_COMMIT_SHORT_SHA: "01234567",
77
+ CI_COMMIT_REF_NAME: "feat/x",
78
+ CI_COMMIT_BRANCH: "feat/x",
79
+ CI_SERVER_URL: "https://gitlab.com",
80
+ CI_PROJECT_PATH: "acme/app",
81
+ })).toEqual({
82
+ CI_COMMIT_SHA: "0123456789abcdef",
83
+ CI_COMMIT_SHORT_SHA: "01234567",
84
+ CI_COMMIT_REF_NAME: "feat/x",
85
+ CI_COMMIT_BRANCH: "feat/x",
86
+ CI_REPOSITORY_URL: "https://gitlab.com/acme/app.git",
87
+ });
88
+ });
89
+
90
+ it("translates a GitHub Actions push", () => {
91
+ // GITHUB_HEAD_REF is exported as an EMPTY string on a push, which is why
92
+ // the ?? chains cannot be trusted with the raw environment: CI_COMMIT_REF_NAME
93
+ // would come out blank instead of falling through to GITHUB_REF_NAME.
94
+ expect(collectCiEnv({
95
+ GITHUB_SHA: "0123456789abcdef",
96
+ GITHUB_HEAD_REF: "",
97
+ GITHUB_REF_NAME: "main",
98
+ GITHUB_REF_TYPE: "branch",
99
+ GITHUB_SERVER_URL: "https://github.com",
100
+ GITHUB_REPOSITORY: "acme/app",
101
+ })).toEqual({
102
+ CI_COMMIT_SHA: "0123456789abcdef",
103
+ CI_COMMIT_SHORT_SHA: "01234567",
104
+ CI_COMMIT_REF_NAME: "main",
105
+ CI_COMMIT_BRANCH: "main",
106
+ CI_REPOSITORY_URL: "https://github.com/acme/app.git",
107
+ });
108
+ });
109
+
110
+ it("reports a GitHub Actions pull request's head branch, and no CI_COMMIT_BRANCH", () => {
111
+ // GITHUB_REF_NAME is the merge ref on a pull request, so the commit is on
112
+ // no branch of its own — naming one would point at something that isn't
113
+ // what was built.
114
+ expect(collectCiEnv({
115
+ GITHUB_SHA: "0123456789abcdef",
116
+ GITHUB_HEAD_REF: "feat/x",
117
+ GITHUB_REF_NAME: "42/merge",
118
+ GITHUB_REF_TYPE: "branch",
119
+ })).toEqual({
120
+ CI_COMMIT_SHA: "0123456789abcdef",
121
+ CI_COMMIT_SHORT_SHA: "01234567",
122
+ CI_COMMIT_REF_NAME: "feat/x",
123
+ });
124
+ });
125
+
126
+ it("reports a GitHub Actions tag build as a tag", () => {
127
+ expect(collectCiEnv({
128
+ GITHUB_SHA: "0123456789abcdef",
129
+ GITHUB_REF_NAME: "v1.2.3",
130
+ GITHUB_REF_TYPE: "tag",
131
+ })).toEqual({
132
+ CI_COMMIT_SHA: "0123456789abcdef",
133
+ CI_COMMIT_SHORT_SHA: "01234567",
134
+ CI_COMMIT_REF_NAME: "v1.2.3",
135
+ CI_COMMIT_TAG: "v1.2.3",
136
+ });
137
+ });
138
+
139
+ it("lets an explicitly set CI_ variable win over the GitHub translation", () => {
140
+ expect(collectCiEnv({
141
+ CI_COMMIT_SHA: "handwritten",
142
+ GITHUB_SHA: "0123456789abcdef",
143
+ })).toMatchObject({ CI_COMMIT_SHA: "handwritten", CI_COMMIT_SHORT_SHA: "01234567" });
144
+ });
80
145
  });
81
146
 
82
147
  it("returns one final URL for each successfully deployed public service", () => {
@@ -1,4 +1,4 @@
1
- import { CONFIG_FILE_DEPLOYMENT_SOURCE_ID, buildSourceManifest, connectionRequiresTargetDeployed, deploymentPortEntries, deploymentPortEntry, deploymentPortOwnsStandardPorts, deploymentServiceIsBuilt, deploymentServiceUsesGeneratedDockerfile, parseConnectionValue, type DeploymentSourceManifest } from "@hexclave/shared/dist/deployments";
1
+ import { buildSourceManifest, connectionRequiresTargetDeployed, deploymentPortEntries, deploymentPortEntry, deploymentPortOwnsStandardPorts, deploymentServiceIsBuilt, deploymentServiceUsesGeneratedDockerfile, parseConnectionValue, type DeploymentRuntime, type DeploymentSourceManifest } from "@hexclave/shared/dist/deployments";
2
2
  import { Command } from "commander";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
@@ -9,13 +9,7 @@ import { AuthError, CliError, errorMessage } from "../lib/errors.js";
9
9
  import { followBuildLogs, type FollowBuildLogsOptions } from "../lib/build-logs.js";
10
10
  import { packageSourceDirectory } from "../lib/source-packaging.js";
11
11
  import { formatDuration, uploadSource, uploadSourceMultipart, type MultipartUploadSlot } from "../lib/source-upload.js";
12
- import { collectSecretDefaults, computeDeploymentLevels, evaluateDeploymentConfig, hasDeployFile, importConfigModule, importDeployModule, resolveDeployFilePath, type EvaluatedService } from "../lib/deployment-config.js";
13
- import { buildConfigPushSource, parseConfigOverride, pushConfigToProject } from "./config-file.js";
14
-
15
- // The names checked (in order) when --config-file is not passed with
16
- // --config-push; same preference order as `hexclave config push`'s pull-side
17
- // resolution.
18
- const CONFIG_FILE_CANDIDATES = ["hexclave.config.ts", "hexclave.config.js", "stack.config.ts", "stack.config.js"];
12
+ import { collectSecretDefaults, computeDeploymentLevels, evaluateDeploymentConfig, importDeployModule, resolveDeployFilePath, type EvaluatedService } from "../lib/deployment-config.js";
19
13
 
20
14
  const RUN_POLL_INTERVAL_MS = 3_000;
21
15
  // Generous cap so a wedged remote build doesn't hang CI forever; the remote
@@ -30,40 +24,12 @@ const BUILD_LOG_DRAIN_TIMEOUT_MS = 15_000;
30
24
  export type DeployOptions = {
31
25
  serviceId?: string,
32
26
  deployFile?: string,
33
- configFile?: string,
34
27
  cloudProjectId?: string,
35
- // Opt-in: pushing the project's configuration is a separate concern from
36
- // deploying this repository's services, and several repositories can deploy
37
- // into one project — so a deploy must not silently publish whichever config
38
- // file happens to sit next to the deploy file.
39
- configPush?: boolean,
40
28
  // Commander's `--no-build-logs`: undefined/true stream the remote build's
41
29
  // output into this terminal, false leaves the deploy reporting status only.
42
30
  buildLogs?: boolean,
43
31
  };
44
32
 
45
- /**
46
- * Resolves the project config file for `--config-push`: --config-file wins (and
47
- * must exist); otherwise the first existing candidate in cwd. Returns null when
48
- * nothing was passed and no candidate exists. Exported for unit tests.
49
- */
50
- export function resolveConfigPushPath(configOption: string | undefined, cwd: string): string | null {
51
- if (configOption != null && configOption !== "") {
52
- const resolved = path.resolve(cwd, configOption);
53
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
54
- throw new CliError(`Config file not found: ${resolved}`);
55
- }
56
- return resolved;
57
- }
58
- for (const candidate of CONFIG_FILE_CANDIDATES) {
59
- const resolved = path.resolve(cwd, candidate);
60
- if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
61
- return resolved;
62
- }
63
- }
64
- return null;
65
- }
66
-
67
33
  /**
68
34
  * The secret keys that MUST have a stored value for these services to deploy:
69
35
  * every secret referenced without a default value. Exported for unit tests.
@@ -480,7 +446,7 @@ async function waitForDeployment(options: {
480
446
  }
481
447
 
482
448
  /** Transitive dependents of `failedServiceId`, by connection edges within this deploy. */
483
- function collectTransitiveDependents(failedServiceId: string, services: Map<string, EvaluatedService>): Set<string> {
449
+ function collectTransitiveDependents(failedServiceId: string, services: Map<string, EvaluatedService>, runtime: DeploymentRuntime): Set<string> {
484
450
  const directDependents = new Map<string, Set<string>>();
485
451
  for (const [serviceId, service] of services) {
486
452
  for (const value of Object.values(service.env)) {
@@ -494,10 +460,9 @@ function collectTransitiveDependents(failedServiceId: string, services: Map<stri
494
460
  const targetIsPublic = parsed.port === null || deploymentPortEntry(target.definition.ports, parsed.port) === null
495
461
  ? null
496
462
  : target.definition.public === true;
497
- // Same rule as computeDeploymentLevels: a deterministic reference is not a
498
- // dependency, so a failed target must not skip services that never needed
499
- // it to be deployed.
500
- if (!connectionRequiresTargetDeployed(parsed.outputKey, parsed.port, targetIsPublic)) continue;
463
+ // Same rule as computeDeploymentLevels: only a reference that needed its
464
+ // target deployed makes its holder a dependent of it.
465
+ if (!connectionRequiresTargetDeployed(runtime, parsed.outputKey, parsed.port, targetIsPublic)) continue;
501
466
  const dependents = directDependents.get(parsed.serviceId) ?? new Set<string>();
502
467
  dependents.add(serviceId);
503
468
  directDependents.set(parsed.serviceId, dependents);
@@ -518,35 +483,62 @@ function collectTransitiveDependents(failedServiceId: string, services: Map<stri
518
483
  }
519
484
 
520
485
  /**
521
- * What this deploy ships: a deploy file when there is one, otherwise the config
522
- * file, for a project small enough to keep its services there. The config file
523
- * has no `deploymentGroupId` export, so its deployments belong to a group named
524
- * after the file itself — which is what lets them coexist with the deploy files
525
- * of other repositories deploying into the same project.
486
+ * The environment a `hexclave deploy` inherits, reduced to the GitLab-style
487
+ * `CI_*` variables that describe the commit being deployed. Sent with the deploy
488
+ * request and injected into every service's env, so a build can stamp the
489
+ * revision it came from without the deploy file having to name a CI provider.
490
+ *
491
+ * GitLab already sets these, so they pass straight through; GitHub Actions gets
492
+ * translated into the same names, and anything else that exports them by hand
493
+ * wins over both. A variable nothing can answer is simply absent — never an
494
+ * empty string, which a service would read as "set, but blank".
495
+ *
496
+ * Exported for unit tests.
497
+ */
498
+ export function collectCiEnv(rawEnv: NodeJS.ProcessEnv): Record<string, string> {
499
+ // GitHub Actions exports the variables it has no answer for as EMPTY strings
500
+ // rather than leaving them unset — GITHUB_HEAD_REF is "" on a push. Folding
501
+ // those to undefined first is what makes the `??` chains below fall through
502
+ // to the next candidate instead of stopping on a blank.
503
+ const env: Record<string, string | undefined> = Object.fromEntries(
504
+ Object.entries(rawEnv).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== ""),
505
+ );
506
+ const ciEnv: Record<string, string | undefined> = {
507
+ CI_COMMIT_SHA: env.CI_COMMIT_SHA ?? env.GITHUB_SHA,
508
+ CI_COMMIT_SHORT_SHA: env.CI_COMMIT_SHORT_SHA ?? env.GITHUB_SHA?.slice(0, 8),
509
+ CI_COMMIT_REF_NAME: env.CI_COMMIT_REF_NAME ?? env.GITHUB_HEAD_REF ?? env.GITHUB_REF_NAME,
510
+ // The branch a commit is ON, which a pull-request build is not: GITHUB_REF_NAME
511
+ // is the merge ref there, so GITHUB_HEAD_REF being set rules this out.
512
+ CI_COMMIT_BRANCH: env.CI_COMMIT_BRANCH ?? (env.GITHUB_REF_TYPE === "branch" && !env.GITHUB_HEAD_REF ? env.GITHUB_REF_NAME : undefined),
513
+ CI_COMMIT_TAG: env.CI_COMMIT_TAG ?? (env.GITHUB_REF_TYPE === "tag" ? env.GITHUB_REF_NAME : undefined),
514
+ CI_REPOSITORY_URL: env.CI_SERVER_URL && env.CI_PROJECT_PATH
515
+ ? `${env.CI_SERVER_URL}/${env.CI_PROJECT_PATH}.git`
516
+ : env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY
517
+ ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}.git`
518
+ : undefined,
519
+ };
520
+ return Object.fromEntries(
521
+ Object.entries(ciEnv).filter((entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== ""),
522
+ );
523
+ }
524
+
525
+ /**
526
+ * What this deploy ships: the `deploy` export of a deploy file. Deployments live
527
+ * in their OWN file — hexclave.config.ts holds the project's configuration and
528
+ * nothing else — so a project with no deploy file has nothing to deploy.
526
529
  */
527
530
  async function resolveDeploySource(deployFileOption: string | undefined, cwd: string): Promise<{
528
531
  path: string,
529
532
  deploymentGroupIdExport: unknown,
530
- // The deploy file's `id` export, if it still uses that old name. Only a deploy
531
- // file can have one; the config file's group id is the file name.
533
+ // The deploy file's `id` export, if it still uses that old name.
532
534
  legacyIdExport?: unknown,
533
535
  deployExport: unknown,
536
+ // The internal `version` export, if any.
537
+ versionExport: unknown,
534
538
  }> {
535
- if (deployFileOption == null || deployFileOption === "") {
536
- const configPath = resolveConfigPushPath(undefined, cwd);
537
- // Only if there is no deploy file at all: a repository that has both keeps
538
- // its services in the deploy file, and silently preferring the config file's
539
- // would deploy something other than what the author is editing.
540
- if (configPath !== null && !hasDeployFile(cwd)) {
541
- const configModule = await importConfigModule(configPath);
542
- if (configModule.deploy !== undefined) {
543
- return { path: configPath, deploymentGroupIdExport: CONFIG_FILE_DEPLOYMENT_SOURCE_ID, deployExport: configModule.deploy };
544
- }
545
- }
546
- }
547
539
  const deployFilePath = resolveDeployFilePath(deployFileOption, cwd);
548
540
  const deployModule = await importDeployModule(deployFilePath);
549
- return { path: deployFilePath, deploymentGroupIdExport: deployModule.deploymentGroupId, legacyIdExport: deployModule.legacyId, deployExport: deployModule.deploy };
541
+ return { path: deployFilePath, deploymentGroupIdExport: deployModule.deploymentGroupId, legacyIdExport: deployModule.legacyId, deployExport: deployModule.deploy, versionExport: deployModule.version };
550
542
  }
551
543
 
552
544
  export function registerDeployCommand(program: Command) {
@@ -555,8 +547,6 @@ export function registerDeployCommand(program: Command) {
555
547
  .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.")
556
548
  .option("--service-id <id>", "Deploy only this service (its connections resolve against already-deployed services)")
557
549
  .option("--deploy-file <path>", "Path to the deploy file (default: auto-discover hexclave.deploy.ts in the current directory)")
558
- .option("--config-push", "Also push the project config file's `config` export to the project before deploying")
559
- .option("--config-file <path>", "Path to the project config file for --config-push (default: auto-discover hexclave.config.ts in the current directory)")
560
550
  .option("--cloud-project-id <id>", "Hexclave project ID to deploy to (defaults to the HEXCLAVE_PROJECT_ID env var)")
561
551
  .option("--no-build-logs", "Don't stream the remote build's output; report service status only")
562
552
  .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.")
@@ -564,15 +554,14 @@ export function registerDeployCommand(program: Command) {
564
554
  const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
565
555
  const authHeaders = await buildAuthHeadersFactory(auth);
566
556
 
567
- // A deploy file if there is one; otherwise the config file, for a project
568
- // that keeps its services there. The config file has no `deploymentGroupId`
569
- // export, so its deployments belong to a group named after the file itself.
557
+ // Always the deploy file: services live there, never in hexclave.config.ts.
570
558
  const deploySource = await resolveDeploySource(opts.deployFile, process.cwd());
571
- const { sourceId, services } = evaluateDeploymentConfig({
559
+ const { sourceId, services, builder, runtime, version } = evaluateDeploymentConfig({
572
560
  deployFilePath: deploySource.path,
573
561
  deploymentGroupIdExport: deploySource.deploymentGroupIdExport,
574
562
  legacyIdExport: deploySource.legacyIdExport,
575
563
  deployExport: deploySource.deployExport,
564
+ versionExport: deploySource.versionExport,
576
565
  mode: "deploy",
577
566
  });
578
567
 
@@ -587,10 +576,21 @@ export function registerDeployCommand(program: Command) {
587
576
  }
588
577
  levels = [[opts.serviceId]];
589
578
  } else {
590
- levels = computeDeploymentLevels(services);
579
+ levels = computeDeploymentLevels(services, runtime);
591
580
  }
592
581
  const deploySet = levels.flat();
593
582
 
583
+ // A builder size with nothing to build is a note, not an error: the deploy
584
+ // is still exactly what the author asked for, and a `--service-id` deploy
585
+ // of one prebuilt service out of a file whose other services DO build is
586
+ // an entirely ordinary thing to do.
587
+ if (builder?.memory !== undefined && !deploySet.some((serviceId) => {
588
+ const service = services.get(serviceId);
589
+ return service !== undefined && deploymentServiceIsBuilt(service.definition);
590
+ })) {
591
+ 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.`);
592
+ }
593
+
594
594
  // Pre-flight: every secret without a default must have a stored value
595
595
  // BEFORE anything is packaged or uploaded. The backend re-checks this
596
596
  // authoritatively per deploy.
@@ -619,32 +619,6 @@ export function registerDeployCommand(program: Command) {
619
619
  }
620
620
  }
621
621
 
622
- // Config push is OPT-IN. A project can be deployed from several
623
- // repositories, and each push replaces the project's whole configuration —
624
- // so a deploy that published it by default would let any of those
625
- // repositories silently overwrite the others' config with its own.
626
- if (opts.configPush === true) {
627
- const configPath = resolveConfigPushPath(opts.configFile, process.cwd());
628
- if (configPath == null) {
629
- 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.`);
630
- }
631
- const configModule = await importConfigModule(configPath);
632
- if (configModule.config === undefined) {
633
- throw new CliError(`--config-push was passed, but ${configPath} has no \`config\` export. Add one, or drop --config-push.`);
634
- }
635
- const config = parseConfigOverride(configModule.config);
636
- if (config == null) {
637
- 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.`);
638
- }
639
- console.error("Pushing config...");
640
- // The GitHub-Actions auto-detection inside buildConfigPushSource
641
- // records this path verbatim as the repo-relative config_file_path,
642
- // so pass a cwd-relative posix path, not the resolved absolute one
643
- // (which would bake the runner's filesystem layout into the source).
644
- const relativeConfigPath = path.relative(process.cwd(), configPath).split(path.sep).join("/");
645
- await pushConfigToProject(auth, config, buildConfigPushSource(relativeConfigPath, {}));
646
- }
647
-
648
622
  // Sync ALL definitions (even for a --service-id deploy) so the server sees
649
623
  // the deploy file's whole truth: services it no longer declares are torn
650
624
  // down here, and syncing only a subset would read as "the rest were
@@ -655,6 +629,14 @@ export function registerDeployCommand(program: Command) {
655
629
  jsonBody: {
656
630
  source_id: sourceId,
657
631
  services: Object.fromEntries([...services.values()].map((service) => [service.serviceId, service.definition])),
632
+ // Synced with the definitions rather than sent with the deploy: the
633
+ // builder is part of what the deploy file says, so it belongs inside
634
+ // the same sync fence — a deploy cannot then pair one checkout's
635
+ // source with another checkout's builder size.
636
+ ...(builder === undefined ? {} : { builder }),
637
+ // The internal `version` export, when set: which infrastructure runtime the
638
+ // project runs on. Synced with the definitions for the same reason the builder is.
639
+ ...(version === undefined ? {} : { version }),
658
640
  },
659
641
  });
660
642
  if (typeof syncResponse?.sync_id !== "string") {
@@ -711,6 +693,11 @@ export function registerDeployCommand(program: Command) {
711
693
  throw new CliError(`Internal error: deploy set contains unknown service ${JSON.stringify(serviceId)}.`);
712
694
  })()),
713
695
  ])),
696
+ // The GitLab-style CI variables this deploy was invoked with. Request-
697
+ // scoped like the secret defaults: they describe THIS deploy, so
698
+ // storing them on the definition would leave a stale commit sha on
699
+ // every service the next deploy doesn't ship.
700
+ ci_env: collectCiEnv(process.env),
714
701
  triggered_by: "cli",
715
702
  },
716
703
  });
@@ -720,6 +707,20 @@ export function registerDeployCommand(program: Command) {
720
707
  const deploymentId = deploymentResponse.id;
721
708
  console.error(`Deployment #${deploymentResponse.number} started. ${buildsFromSource ? "Waiting for the remote build..." : "Nothing to build — waiting for the services to come up..."}`);
722
709
 
710
+ // Anything the API wants the author to know about this deploy that is not
711
+ // a failure — today, that a Free-plan deployment stops after the plan's
712
+ // window. Printed HERE, at the start, rather than with the URLs at the end:
713
+ // a limit is only fair if it is read before it bites, and the end of a
714
+ // deploy is where the reader has already stopped paying attention.
715
+ //
716
+ // Tolerated as missing: an older API returns no such field, and a deploy
717
+ // must not fail over a message.
718
+ for (const notice of Array.isArray(deploymentResponse.notices) ? deploymentResponse.notices : []) {
719
+ if (typeof notice !== "string") continue;
720
+ console.error("");
721
+ console.error(notice);
722
+ }
723
+
723
724
  // Stream the remote build's output into this terminal while it runs. A
724
725
  // deploy is mostly one long remote build, and until now the only way to
725
726
  // see what it was doing was to open the dashboard — which is no help at
@@ -102,7 +102,7 @@ function splitDevCommandArgs(commandArgs: string[]): ChildCommand {
102
102
  }
103
103
 
104
104
  /**
105
- * Wraps a devCommand string (from the config's `services` export) for
105
+ * Wraps a devCommand string (from the deploy file's `deploy` export) for
106
106
  * execution through the platform shell — unlike the `-- <command> [args...]`
107
107
  * form, a devCommand is a single command LINE (e.g. "pnpm dev --port 3000")
108
108
  * and may use shell syntax. Exported for unit tests.
@@ -952,8 +952,8 @@ export function registerDevCommand(program: Command) {
952
952
 
953
953
  const configFilePath = resolveConfigFilePathOption(opts.configFile, { mustExist: opts.serviceId != null });
954
954
 
955
- // Evaluate the services export BEFORE starting the dashboard so config
956
- // mistakes fail fast (and without a half-started session).
955
+ // Evaluate the deploy file's services BEFORE starting the dashboard so a
956
+ // mistake there fails fast (and without a half-started session).
957
957
  let devService: EvaluatedService | undefined;
958
958
  if (opts.serviceId != null) {
959
959
  // Services live in the DEPLOY file, not the config file: --config-file
@@ -967,6 +967,7 @@ export function registerDevCommand(program: Command) {
967
967
  deploymentGroupIdExport: deployModule.deploymentGroupId,
968
968
  legacyIdExport: deployModule.legacyId,
969
969
  deployExport: deployModule.deploy,
970
+ versionExport: deployModule.version,
970
971
  mode: "dev",
971
972
  });
972
973
  devService = services.get(opts.serviceId);
@@ -992,7 +993,7 @@ export function registerDevCommand(program: Command) {
992
993
  }
993
994
  })();
994
995
  if (rootDirectoryStats == null || !rootDirectoryStats.isDirectory()) {
995
- 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.`);
996
+ 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.`);
996
997
  }
997
998
  }
998
999
  // The selection guard above makes these modes mutually exclusive.