@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.
@@ -15,6 +15,17 @@ function evaluate(servicesExport: unknown, mode: "deploy" | "dev" = "deploy") {
15
15
  return evaluateDeploymentConfig({ deployFilePath: DEPLOY_FILE_PATH, deploymentGroupIdExport: "test-source", deployExport, mode });
16
16
  }
17
17
 
18
+ // The same deploy file opted into a runtime with the internal `version` export.
19
+ function evaluateWithVersion(servicesExport: unknown, version: unknown, mode: "deploy" | "dev" = "deploy") {
20
+ const deployExport = (context: ServicesFunctionContext) => ({
21
+ services: typeof servicesExport === "function" ? (servicesExport as (ctx: ServicesFunctionContext) => unknown)(context) : servicesExport,
22
+ });
23
+ return evaluateDeploymentConfig({ deployFilePath: DEPLOY_FILE_PATH, deploymentGroupIdExport: "test-source", deployExport, versionExport: version, mode });
24
+ }
25
+ function evaluateOnGcp(servicesExport: unknown, mode: "deploy" | "dev" = "deploy") {
26
+ return evaluateWithVersion(servicesExport, "gcp-beta-1", mode);
27
+ }
28
+
18
29
  describe("evaluateDeploymentConfig (deploy mode)", () => {
19
30
  it("preserves __proto__ as an environment variable instead of mutating the result prototype", () => {
20
31
  const { services } = evaluate(() => ({
@@ -96,6 +107,44 @@ describe("evaluateDeploymentConfig (deploy mode)", () => {
96
107
  });
97
108
  });
98
109
 
110
+ it("reads memory from the ladder its service type actually has machines for", () => {
111
+ const memoryOf = (service: unknown) => evaluate(() => ({ web: service })).services.get("web")?.definition.memory;
112
+ expect(memoryOf({ type: "serverless", ports: {}, memory: "8GB" })).toBe("8GB");
113
+ expect(memoryOf({ type: "server", ports: {}, memory: "4GB" })).toBe("4GB");
114
+ // Absent stays absent rather than being written out as the default: the
115
+ // backend normalizes an explicit default back out anyway, and a definition
116
+ // that states what it already runs must not read differently from one that
117
+ // says nothing.
118
+ expect(memoryOf({ type: "serverless", ports: {} })).toBe(undefined);
119
+ // The ladder is the RUNTIME's. On the default runtime a server may be 512MB (every Fly
120
+ // service ran on that shape before sizes existed); on GCP no 512MB machine shape exists,
121
+ // so the rung a serverless may use is not one a server may — and the error names the
122
+ // ladder for THIS service on THIS runtime.
123
+ expect(memoryOf({ type: "server", ports: {}, memory: "512MB" })).toBe("512MB");
124
+ const badGcpMemory = (service: unknown) => () => evaluateOnGcp(() => ({ web: service }));
125
+ expect(badGcpMemory({ type: "server", ports: {}, memory: "512MB" }))
126
+ .toThrow(/deploy\.services\.web\.memory must be one of "1GB", "2GB", "4GB", "8GB"/);
127
+ expect(evaluateOnGcp(() => ({ web: { type: "server", ports: {}, memory: "4GB" } })).services.get("web")?.definition.memory).toBe("4GB");
128
+ // Sizes only the builder may ask for are not service sizes.
129
+ const badMemory = (service: unknown) => () => evaluate(() => ({ web: service }));
130
+ expect(badMemory({ type: "serverless", ports: {}, memory: "32GB" })).toThrow(/must be one of/);
131
+ });
132
+
133
+ it("refuses non-canonical memory spellings but says which one to write", () => {
134
+ const memory = (value: unknown) => () => evaluate(() => ({ web: { type: "serverless", ports: {}, memory: value } }));
135
+ // Recognising a spelling is not accepting it: one canonical token per size,
136
+ // for the same reason a port key may not have a leading zero.
137
+ expect(memory("4gb")).toThrow(/Write it as "4GB"/);
138
+ expect(memory("4 GB")).toThrow(/Write it as "4GB"/);
139
+ expect(memory("4Gi")).toThrow(/Write it as "4GB"/);
140
+ expect(memory("4096MB")).toThrow(/Write it as "4GB"/);
141
+ // "Mb" is megabits, so it is called out rather than silently accepted.
142
+ expect(memory("512Mb")).toThrow(/megabits/);
143
+ // Unrecognisable values fall back to naming the ladder.
144
+ expect(memory("3GB")).toThrow(/must be one of/);
145
+ expect(memory(4096)).toThrow(/must be one of/);
146
+ });
147
+
99
148
  it("rejects port lists it could not serve", () => {
100
149
  const evaluatePorts = (ports: unknown) => () => evaluate(() => ({ web: { type: "serverless", ports } }));
101
150
  // The FIELD is still required — an omitted `ports` is a forgotten line far
@@ -189,37 +238,41 @@ describe("evaluateDeploymentConfig (deploy mode)", () => {
189
238
  }))).not.toThrow();
190
239
  });
191
240
 
192
- it("only makes a deploy dependency of references that need the target deployed", () => {
193
- // internalHost and internalUrl(<port>) are deterministic — they resolve
194
- // before the target exists — so they must not order or serialize deploys.
195
- const deterministicallyWired = () => evaluate(({ service }: ServicesFunctionContext) => ({
241
+ it("makes a deploy dependency of every reference the runtime cannot answer up front", () => {
242
+ // Which references wait depends on the RUNTIME (see connectionRequiresTargetDeployed).
243
+ // On the default runtime a private url(<port>) and hostname() are name-derived and
244
+ // known before anything runs, so they produce NO edge; on GCP both are the target's
245
+ // runtime address, so a consumer put first would fail the deploy on an unresolved
246
+ // ref, and every service output orders the deploy.
247
+ const deployFile = ({ service }: ServicesFunctionContext) => ({
196
248
  web: {
197
249
  type: "serverless", public: true, ports: { 3000: { protocol: "http" } },
198
250
  env: { API: (service("api") as any).url(8080), DB_HOST: (service("db") as any).hostname() },
199
251
  },
200
252
  api: { type: "serverless", ports: { 8080: { protocol: "http" }, 9090: { protocol: "http" } } },
201
253
  db: { type: "server", ports: { 5432: { protocol: "tcp" } } },
202
- }));
203
- expect(deterministicallyWired).not.toThrow();
204
- // One level: nothing waits on anything.
205
- expect(computeDeploymentLevels(deterministicallyWired().services)).toEqual([["web", "api", "db"]]);
206
-
207
- // Mutual wiring through deterministic references is legal, and used to be
208
- // rejected as a false circular dependency.
209
- const mutual = () => evaluate(({ service }: ServicesFunctionContext) => ({
210
- web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { API: (service("api") as any).url() } },
254
+ });
255
+ const onFly = evaluate(deployFile);
256
+ expect(computeDeploymentLevels(onFly.services, onFly.runtime)).toEqual([["web", "api", "db"]]);
257
+ const onGcp = evaluateOnGcp(deployFile);
258
+ expect(computeDeploymentLevels(onGcp.services, onGcp.runtime)).toEqual([["api", "db"], ["web"]]);
259
+
260
+ // The accepted cost on GCP: two services that each read the other's address are a
261
+ // real cycle there, because neither can go first. Reported as one, with the way out.
262
+ // On the default runtime the same file deploys in one level.
263
+ const mutual = ({ service }: ServicesFunctionContext) => ({
264
+ web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { API: (service("api") as any).url(8080) } },
211
265
  api: { type: "serverless", ports: { 8080: { protocol: "http" } }, env: { WEB_HOST: (service("web") as any).hostname() } },
212
- }));
213
- expect(mutual).not.toThrow();
214
- // `web` still waits on `api`: a bare internalUrl() reads the target's ports.
215
- expect(computeDeploymentLevels(mutual().services)).toEqual([["api"], ["web"]]);
266
+ });
267
+ expect(computeDeploymentLevels(evaluate(mutual).services, "fly")).toEqual([["web", "api"]]);
268
+ expect(() => computeDeploymentLevels(evaluateOnGcp(mutual).services, "gcp")).toThrow(/circular connection dependency/);
216
269
 
217
- // A `url` reference is still a real dependency.
270
+ // A public `url` was always a dependency, and still is.
218
271
  const publicUrl = evaluate(({ service }: ServicesFunctionContext) => ({
219
272
  web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { API: (service("api") as any).url() } },
220
273
  api: { type: "serverless", public: true, ports: { 8080: { protocol: "http" } } },
221
274
  }));
222
- expect(computeDeploymentLevels(publicUrl.services)).toEqual([["api"], ["web"]]);
275
+ expect(computeDeploymentLevels(publicUrl.services, publicUrl.runtime)).toEqual([["api"], ["web"]]);
223
276
  });
224
277
 
225
278
  it("leaves references to services of other deployment sources to the backend", () => {
@@ -290,17 +343,34 @@ describe("evaluateDeploymentConfig (deploy mode)", () => {
290
343
  }))).toThrow("use the `hexclave` context object instead");
291
344
  });
292
345
 
293
- it("rejects a self-referential PUBLIC url but allows a private one", () => {
294
- // The public URL only exists once the service is up, which its own first
295
- // deploy cannot provide.
296
- expect(() => evaluate(({ service }: ServicesFunctionContext) => ({
346
+ it("rejects a self-referential address the runtime cannot answer up front", () => {
347
+ // A public URL is produced by the service's own rollout on either runtime, so no
348
+ // self-reference to it can resolve before the deploy that creates it finishes.
349
+ const publicSelf = ({ service }: ServicesFunctionContext) => ({
297
350
  web: { type: "serverless", public: true, ports: { 3000: { protocol: "http" } }, env: { SELF: (service("web") as any).url(3000) } },
298
- }))).toThrow("cannot exist before the service does");
299
- // A private port's URL is deterministic, so a service may reference its own.
300
- const { services } = evaluate(({ service }: ServicesFunctionContext) => ({
301
- web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { SELF: (service("web") as any).url(3000) } },
302
- }));
303
- expect(services.get("web")?.definition.env.SELF).toEqual({ type: "connection", value: "web.url:3000" });
351
+ });
352
+ expect(() => evaluate(publicSelf)).toThrow("cannot exist before the service is deployed");
353
+ expect(() => evaluateOnGcp(publicSelf)).toThrow("cannot exist before the service is deployed");
354
+ // A private address is name-derived on the default runtime and known in advance, so a
355
+ // service may name its own; on GCP nothing publishes such a record, so it blocks on the
356
+ // real address like any other output — and would reach the deploy as an unresolvable ref.
357
+ const privateSelf = ({ service }: ServicesFunctionContext) => ({
358
+ web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { SELF: (service("web") as any).url(3000), HOST: (service("web") as any).hostname() } },
359
+ });
360
+ expect(() => evaluate(privateSelf)).not.toThrow();
361
+ expect(() => evaluateOnGcp(privateSelf)).toThrow("cannot exist before the service is deployed");
362
+ });
363
+
364
+ it("reads the internal `version` export, and refuses one it does not know", () => {
365
+ const deployFile = () => ({ web: { type: "serverless", ports: { 3000: { protocol: "http" } } } });
366
+ expect(evaluate(deployFile)).toMatchObject({ runtime: "fly", version: undefined });
367
+ expect(evaluateOnGcp(deployFile)).toMatchObject({ runtime: "gcp", version: "gcp-beta-1" });
368
+ // Refused rather than ignored: a typo in our own token must not silently deploy to the
369
+ // default, and a stray export in someone's file must not silently mean anything.
370
+ const withVersion = (version: unknown) => () => evaluateWithVersion(deployFile, version);
371
+ expect(withVersion("gcp")).toThrow(/unknown `version` export "gcp"/);
372
+ expect(withVersion("1.0.0")).toThrow(/gcp-beta-1/);
373
+ expect(withVersion(1)).toThrow(/must be a string/);
304
374
  });
305
375
 
306
376
  it("rejects assigning the whole service() return instead of an output", () => {
@@ -572,8 +642,9 @@ describe("the deployment envelope", () => {
572
642
  expect(evaluateExports(7, deployExport)).toThrow("must be a string");
573
643
  expect(evaluateExports("-nope", deployExport)).toThrow("Invalid deployment group id");
574
644
  expect(evaluateExports("backend", deployExport)().sourceId).toBe("backend");
575
- // Dots are legal: deployments declared in hexclave.config.ts belong to a
576
- // group named after the file.
645
+ // Dots are legal: a group id appears in no reference, so nothing has to
646
+ // parse one — and projects that predate the move of services out of
647
+ // hexclave.config.ts still have a stored group named after that file.
577
648
  expect(evaluateExports("hexclave.config.ts", deployExport)().sourceId).toBe("hexclave.config.ts");
578
649
  });
579
650
 
@@ -762,11 +833,12 @@ describe("computeDeploymentLevels", () => {
762
833
  expect(computeDeploymentLevels(services)).toEqual([["web"]]);
763
834
  });
764
835
 
765
- it("does not treat a self internalUrl reference as a cycle", () => {
766
- // A self `internalUrl` is deterministic (see evaluateDeploymentConfig), so it must not
767
- // create a self-edge that computeDeploymentLevels would report as a false cycle.
768
- const services = build(({ service }) => ({
769
- web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { SELF: (service("web") as any).url(3000) } },
836
+ it("does not treat a hexclave output reference as a cycle", () => {
837
+ // `hexclave.*` comes from the managed service, which always exists, so it must not become
838
+ // an edge. Self-references to a service's own address never reach here at all — they are
839
+ // rejected during evaluation, since no rollout can produce an address it needs first.
840
+ const services = build(({ hexclave }) => ({
841
+ web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: { PROJECT_ID: (hexclave as any).projectId } },
770
842
  }));
771
843
  expect(computeDeploymentLevels(services)).toEqual([["web"]]);
772
844
  });
@@ -819,3 +891,39 @@ describe("build and start commands", () => {
819
891
  expect(() => evaluate(web({ runCommand: "node server.js" }))).toThrow(/unknown field "runCommand"/);
820
892
  });
821
893
  });
894
+
895
+ describe("the deploy export's builder", () => {
896
+ const evaluateDeploy = (deployRaw: unknown) => evaluateDeploymentConfig({
897
+ deployFilePath: DEPLOY_FILE_PATH,
898
+ deploymentGroupIdExport: "test-source",
899
+ deployExport: () => deployRaw,
900
+ mode: "deploy",
901
+ });
902
+ const withServices = (extra: Record<string, unknown>) => ({
903
+ services: { web: { type: "serverless", ports: { 3000: { protocol: "http" } }, env: {} } },
904
+ ...extra,
905
+ });
906
+
907
+ it("is a sibling of services, because one machine builds them all", () => {
908
+ expect(evaluateDeploy(withServices({ builder: { memory: "32GB" } })).builder).toEqual({ memory: "32GB" });
909
+ // Its ladder starts where the service ladders stop: a builder is a transient
910
+ // machine sized for a build, not for an idling service.
911
+ expect(evaluateDeploy(withServices({ builder: { memory: "8GB" } })).builder).toEqual({ memory: "8GB" });
912
+ expect(() => evaluateDeploy(withServices({ builder: { memory: "512MB" } }))).toThrow(/deploy\.builder\.memory of .* must be one of "8GB", "16GB", "32GB"/);
913
+ expect(() => evaluateDeploy(withServices({ builder: { memory: "32gb" } }))).toThrow(/Write it as "32GB"/);
914
+ });
915
+
916
+ it("collapses an empty builder to no opinion at all", () => {
917
+ // `{}` and absent both mean "let the deployment decide"; storing one as a
918
+ // declaration would make them read differently downstream.
919
+ expect(evaluateDeploy(withServices({})).builder).toBe(undefined);
920
+ expect(evaluateDeploy(withServices({ builder: {} })).builder).toBe(undefined);
921
+ });
922
+
923
+ it("rejects fields neither half of the deploy export knows", () => {
924
+ expect(() => evaluateDeploy(withServices({ builder: { cpu: 4 } }))).toThrow(/unknown field "cpu"/);
925
+ expect(() => evaluateDeploy(withServices({ builder: "32GB" }))).toThrow(/must be an object/);
926
+ // The top-level check now names both supported fields rather than just one.
927
+ expect(() => evaluateDeploy(withServices({ builders: {} }))).toThrow(/unknown field "builders". Supported fields: services, builder/);
928
+ });
929
+ });
@@ -1,9 +1,10 @@
1
1
  // Evaluates the `deploy` export of hexclave.deploy.ts.
2
2
  //
3
- // Deployments live in their OWN file, separate from hexclave.config.ts: one
4
- // Hexclave project can be deployed from several repositories, each shipping the
5
- // services it owns, and each of those repositories has a deploy file of its own
6
- // while only one of them (if any) owns the project's configuration.
3
+ // Deployments live in their OWN file, separate from hexclave.config.ts, which
4
+ // holds the project's configuration and nothing else: one Hexclave project can
5
+ // be deployed from several repositories, each shipping the services it owns,
6
+ // and each of those repositories has a deploy file of its own while only one of
7
+ // them (if any) owns the project's configuration.
7
8
  //
8
9
  // The export is a FUNCTION of the deployment context returning `{ services }`.
9
10
  // The context is where `secret()`, `service()` and `hexclave.*` come from.
@@ -67,16 +68,18 @@ import {
67
68
  MAX_VOLUME_ID_LENGTH,
68
69
  MAX_VOLUME_SIZE_GB,
69
70
  MIN_VOLUME_SIZE_GB,
71
+ BUILDER_MEMORY_SIZES,
70
72
  SERVICE_OUTPUT_KEYS,
71
73
  connectionRequiresTargetDeployed,
74
+ deploymentMemorySizesForType,
72
75
  deploymentServiceIsBuilt,
76
+ suggestDeploymentMemorySize,
73
77
  formatConnectionValue,
74
78
  isValidDeploymentCommand,
75
79
  parseConnectionValue,
76
80
  parseDeploymentImageRef,
77
81
  DEPLOYMENT_PORT_KEY_REGEX,
78
82
  deploymentPortEntries,
79
- deploymentPortEntry,
80
83
  reservedStandardPortConflicts,
81
84
  standardPortsHolderPort,
82
85
  type DeploymentEnvVarDefinition,
@@ -85,6 +88,13 @@ import {
85
88
  type DeploymentServiceType,
86
89
  type DeploymentVolumeDefinition,
87
90
  type HexclaveOutputKey,
91
+ type DeploymentBuilderDefinition,
92
+ type DeploymentMemorySize,
93
+ DEFAULT_DEPLOYMENT_RUNTIME,
94
+ deploymentPortEntry,
95
+ DEPLOYMENT_VERSION_TOKENS,
96
+ deploymentRuntimeForVersion,
97
+ type DeploymentRuntime,
88
98
  } from "@hexclave/shared/dist/deployments";
89
99
  import { PROJECT_SECRET_KEY_REGEX } from "@hexclave/shared/dist/project-secrets";
90
100
  import fs from "node:fs";
@@ -94,14 +104,6 @@ import { CliError, errorMessage } from "./errors.js";
94
104
  // The names checked (in order) when --deploy-file is not passed.
95
105
  export const DEPLOY_FILE_CANDIDATES = ["hexclave.deploy.ts", "hexclave.deploy.js"];
96
106
 
97
- /** Whether `cwd` contains a deploy file at all. */
98
- export function hasDeployFile(cwd: string): boolean {
99
- return DEPLOY_FILE_CANDIDATES.some((candidate) => {
100
- const resolved = path.resolve(cwd, candidate);
101
- return fs.existsSync(resolved) && fs.statSync(resolved).isFile();
102
- });
103
- }
104
-
105
107
  /**
106
108
  * Resolves the deploy file path: --deploy-file wins (and must exist); otherwise
107
109
  * the first existing candidate in `cwd`. A deploy file is REQUIRED — service
@@ -329,13 +331,55 @@ export type EvaluatedServices = {
329
331
  // but previously owned by this source as removed.
330
332
  sourceId: string,
331
333
  services: Map<string, EvaluatedService>,
334
+ // The machine that builds them. One per deployment, so it sits here rather
335
+ // than on any service. Undefined = the deployment picks the size its build
336
+ // shape needs.
337
+ builder: DeploymentBuilderDefinition | undefined,
338
+ // The infrastructure runtime the file's `version` export selects, and the
339
+ // token itself (undefined when absent). Internal: the export is undocumented,
340
+ // and absent means the default runtime — see DEPLOYMENT_VERSIONS.
341
+ runtime: DeploymentRuntime,
342
+ version: string | undefined,
332
343
  };
333
344
 
334
345
  const KNOWN_SERVICE_FIELDS = new Set([
335
- "type", "public", "ports", "minInstances", "maxInstances",
346
+ "type", "public", "ports", "minInstances", "maxInstances", "memory",
336
347
  "rootDirectory", "dockerfilePath", "image", "devCommand", "buildCommand", "startCommand", "persistentVolumes", "env",
337
348
  ]);
338
349
  const KNOWN_VOLUME_FIELDS = new Set(["path", "sizeGb"]);
350
+ // `services` and `builder` are the two halves of a deploy file: what to run, and
351
+ // what to build it on.
352
+ const KNOWN_DEPLOY_FIELDS = new Set(["services", "builder"]);
353
+ const KNOWN_BUILDER_FIELDS = new Set(["memory"]);
354
+
355
+ /**
356
+ * Reads a memory size token, refusing anything but the canonical spelling.
357
+ *
358
+ * `available` is the ladder for this particular slot — a "server" has no 512MB
359
+ * shape and the builder starts where the services stop — so the message names
360
+ * what this thing can be rather than every token that exists.
361
+ *
362
+ * Near-misses get a "did you mean": `"4gb"`, `"4 GB"` and `"4Gi"` are all
363
+ * refused, but they are what somebody types first, and a bare list of seven
364
+ * tokens leaves the reader to spot the difference by eye.
365
+ */
366
+ function readMemoryField(
367
+ raw: unknown,
368
+ at: string,
369
+ available: readonly DeploymentMemorySize[],
370
+ ): DeploymentMemorySize | undefined {
371
+ if (raw === undefined) return undefined;
372
+ const options = available.map((size) => JSON.stringify(size)).join(", ");
373
+ if (typeof raw !== "string") {
374
+ throw new CliError(`${at} must be one of ${options} (got ${JSON.stringify(raw)}). Write the size with its unit, e.g. \`memory: "4GB"\`.`);
375
+ }
376
+ if ((available as readonly string[]).includes(raw)) return raw as DeploymentMemorySize;
377
+ const suggestion = suggestDeploymentMemorySize(raw);
378
+ if (suggestion !== null && (available as readonly string[]).includes(suggestion)) {
379
+ 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).`);
380
+ }
381
+ throw new CliError(`${at} must be one of ${options} (got ${JSON.stringify(raw)}).`);
382
+ }
339
383
 
340
384
  function readOptionalIntegerField(record: Record<string, unknown>, serviceId: string, field: string): number | undefined {
341
385
  const value = record[field];
@@ -434,11 +478,11 @@ function evaluatePorts(serviceId: string, isPublic: boolean, portsRaw: unknown):
434
478
 
435
479
  const entries = deploymentPortEntries(ports);
436
480
 
437
- // FLY.IO PLATFORM LIMITATION, VERIFIED against real Fly: a public service is
438
- // all-HTTP. Fly's shared public IPv4 tells apps apart by SNI (TLS) or Host
439
- // (HTTP); a raw TCP stream carries neither, so the edge accepts the connection
440
- // and then drops it. Lifting this needs a dedicated IPv4 per service, which is
441
- // a billing decision rather than a code change.
481
+ // PLATFORM LIMITATION: a public service is all-HTTP. Public traffic arrives
482
+ // through one shared Application Load Balancer that tells services apart by SNI
483
+ // (TLS) or Host (HTTP); a raw TCP stream carries neither, so the edge accepts
484
+ // the connection and then drops it. Lifting this needs a dedicated public
485
+ // address per service, which is a cost decision rather than a code change.
442
486
  const tcpPorts = entries.filter((entry) => entry.protocol === "tcp");
443
487
  if (isPublic && tcpPorts.length > 0) {
444
488
  throw new CliError(`deploy.services.${serviceId} is \`public: true\` but declares the "tcp" port${tcpPorts.length === 1 ? "" : "s"} ${tcpPorts.map((entry) => entry.port).join(", ")}. Raw TCP carries no SNI or Host header, so a shared public address cannot tell which service a connection is for. Keep the service private and reach it with service(${JSON.stringify(serviceId)}).hostname() and the port number, or move the TCP ports to their own service.`);
@@ -469,6 +513,28 @@ function evaluatePorts(serviceId: string, isPublic: boolean, portsRaw: unknown):
469
513
  return ports;
470
514
  }
471
515
 
516
+ /**
517
+ * Reads the deploy export's `builder`.
518
+ *
519
+ * Returns undefined when the file says nothing OR when it declares an empty
520
+ * object: both mean "no opinion", and storing `{}` would make an empty
521
+ * declaration look different from an absent one to every reader downstream.
522
+ */
523
+ function evaluateBuilder(deployFilePath: string, builderRaw: unknown): DeploymentBuilderDefinition | undefined {
524
+ if (builderRaw === undefined) return undefined;
525
+ if (builderRaw === null || typeof builderRaw !== "object" || Array.isArray(builderRaw)) {
526
+ throw new CliError(`deploy.builder of ${deployFilePath} must be an object, e.g. \`builder: { memory: "32GB" }\`.`);
527
+ }
528
+ const record = builderRaw as Record<string, unknown>;
529
+ for (const field of Object.keys(record)) {
530
+ if (!KNOWN_BUILDER_FIELDS.has(field)) {
531
+ throw new CliError(`deploy.builder of ${deployFilePath} has an unknown field ${JSON.stringify(field)}. Known fields: ${[...KNOWN_BUILDER_FIELDS].join(", ")}.`);
532
+ }
533
+ }
534
+ const memory = readMemoryField(record.memory, `deploy.builder.memory of ${deployFilePath}`, BUILDER_MEMORY_SIZES);
535
+ return memory === undefined ? undefined : { memory };
536
+ }
537
+
472
538
  function evaluatePersistentVolumes(serviceId: string, volumesRaw: unknown): Record<string, DeploymentVolumeDefinition> | undefined {
473
539
  if (volumesRaw === undefined || volumesRaw === null) return undefined;
474
540
  if (typeof volumesRaw !== "object" || Array.isArray(volumesRaw)) {
@@ -602,18 +668,19 @@ const EXAMPLE_DEPLOYMENT_EXPORT = ` export const deploymentGroupId = "my-app";
602
668
  */
603
669
  export function evaluateDeploymentConfig(options: {
604
670
  deployFilePath: string,
605
- // The file's `deploymentGroupId` export — the deployment group id. Required in
606
- // a deploy file; the caller passes CONFIG_FILE_DEPLOYMENT_SOURCE_ID for
607
- // deployments declared in hexclave.config.ts, which has no id of its own.
671
+ // The file's `deploymentGroupId` export — the deployment group id.
608
672
  deploymentGroupIdExport: unknown,
609
673
  // The file's `id` export, which is what this used to be called. Passed so a
610
674
  // file still using the old name fails with the rename instead of the
611
675
  // less helpful "no `deploymentGroupId` export".
612
676
  legacyIdExport?: unknown,
613
677
  deployExport: unknown,
678
+ // The file's `version` export, if any: an internal token selecting the
679
+ // infrastructure runtime. Absent is the default.
680
+ versionExport?: unknown,
614
681
  mode: "deploy" | "dev",
615
682
  }): EvaluatedServices {
616
- const { deployFilePath, deploymentGroupIdExport, legacyIdExport, deployExport, mode } = options;
683
+ const { deployFilePath, deploymentGroupIdExport, legacyIdExport, deployExport, versionExport, mode } = options;
617
684
  const deployFileDirectory = path.dirname(deployFilePath);
618
685
 
619
686
  // `id` was the old name for this export. Refuse the file outright rather than
@@ -639,6 +706,18 @@ export function evaluateDeploymentConfig(options: {
639
706
  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).`);
640
707
  }
641
708
 
709
+ // Refused loudly rather than ignored, in both directions: a typo in one of our
710
+ // own tokens must not silently deploy to the default runtime, and a stray
711
+ // `version` in someone's deploy file must not silently mean anything.
712
+ const version = versionExport === undefined ? undefined : versionExport;
713
+ if (version !== undefined && typeof version !== "string") {
714
+ 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.`);
715
+ }
716
+ const runtime = deploymentRuntimeForVersion(version);
717
+ if (runtime === null) {
718
+ 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(", ")}.`);
719
+ }
720
+
642
721
  if (deployExport === undefined) {
643
722
  throw new CliError(`The deploy file ${deployFilePath} has no \`deploy\` export. Add one, e.g.:\n${EXAMPLE_DEPLOYMENT_EXPORT}`);
644
723
  }
@@ -673,10 +752,13 @@ export function evaluateDeploymentConfig(options: {
673
752
  }
674
753
  const deployRecord = deployRaw as Record<string, unknown>;
675
754
  for (const field of Object.keys(deployRecord)) {
676
- if (field !== "services") {
677
- throw new CliError(`The \`deploy\` export of ${deployFilePath} returned an unknown field ${JSON.stringify(field)}. The only supported field is \`services\`.`);
755
+ if (!KNOWN_DEPLOY_FIELDS.has(field)) {
756
+ throw new CliError(`The \`deploy\` export of ${deployFilePath} returned an unknown field ${JSON.stringify(field)}. Supported fields: ${[...KNOWN_DEPLOY_FIELDS].join(", ")}.`);
678
757
  }
679
758
  }
759
+ // The builder is one machine per deployment, so it is read once here rather
760
+ // than per service.
761
+ const builder = evaluateBuilder(deployFilePath, deployRecord.builder);
680
762
  const servicesRaw = deployRecord.services;
681
763
  if (servicesRaw === undefined) {
682
764
  throw new CliError(`The \`deploy\` export of ${deployFilePath} returned no \`services\`. Add them, e.g.:\n${EXAMPLE_DEPLOYMENT_EXPORT}`);
@@ -704,7 +786,7 @@ export function evaluateDeploymentConfig(options: {
704
786
  }
705
787
  if (!(DEPLOYMENT_SERVICE_TYPES as readonly unknown[]).includes(record.type)) {
706
788
  throw new CliError(record.type === undefined
707
- ? `deploy.services.${serviceId} has no \`type\`. Add \`type: "server"\` (single suspending instance, may have persistentVolumes) or \`type: "serverless"\` (scales out, stops on scale-down).`
789
+ ? `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).`
708
790
  : `deploy.services.${serviceId}.type must be ${DEPLOYMENT_SERVICE_TYPES.map((knownType: string) => JSON.stringify(knownType)).join(" or ")} (got ${JSON.stringify(record.type)}).`);
709
791
  }
710
792
  const serviceType = record.type as DeploymentServiceType;
@@ -807,6 +889,10 @@ export function evaluateDeploymentConfig(options: {
807
889
  }
808
890
  }
809
891
 
892
+ // The ladder is the service type's own: a "server" runs on whole machines
893
+ // and has no 512MB shape to run on.
894
+ const memory = readMemoryField(record.memory, `deploy.services.${serviceId}.memory`, deploymentMemorySizesForType(serviceType, runtime));
895
+
810
896
  // A volume is local disk on a single host and attaches to at most one
811
897
  // instance, so only a "server" can hold one.
812
898
  const persistentVolumes = evaluatePersistentVolumes(serviceId, record.persistentVolumes);
@@ -839,6 +925,12 @@ export function evaluateDeploymentConfig(options: {
839
925
  // A "serverless" defaults to 0 (scale to zero) downstream.
840
926
  min_instances: serviceType === "server" ? minInstances ?? 1 : minInstances,
841
927
  max_instances: maxInstances,
928
+ // Left undefined when the file says nothing, rather than written out
929
+ // like `public` above: the default is applied at deploy time, and a
930
+ // definition that states the value it already runs at must hash the
931
+ // same as one that omits it — otherwise adding `memory: "1GB"` to a
932
+ // running server would replace the machine for no change at all.
933
+ memory,
842
934
  // Stored/displayed as a config-directory-relative posix path ("." for
843
935
  // the config directory itself) — an absolute local path would be
844
936
  // meaningless (and leak local filesystem layout) server-side.
@@ -955,30 +1047,27 @@ export function evaluateDeploymentConfig(options: {
955
1047
  }
956
1048
  }
957
1049
 
958
- // A service referencing its own PUBLIC url can never be satisfied: that URL
959
- // only exists once the service is up (or a domain verifies), which its own
960
- // first deploy cannot provide. Its private address is deterministic and fine
961
- // to self-reference. Mirrors the backend check, but fails before anything is
962
- // uploaded.
1050
+ // A service referencing an address of its OWN that its rollout produces can never be
1051
+ // satisfied: the reference would have to resolve before the deploy that creates it has
1052
+ // finished. Which addresses those are depends on the runtime (see
1053
+ // connectionRequiresTargetDeployed): on the default runtime a private address is
1054
+ // name-derived and known before anything runs, so only a public url is a cycle; on GCP
1055
+ // every service output is. Caught here rather than at deploy time, where it surfaced as
1056
+ // "blocked on unresolved refs" after the upload — and where no retry could ever clear it.
963
1057
  for (const [serviceId, service] of services) {
964
1058
  for (const [envVarKey, value] of Object.entries(service.env)) {
965
1059
  if (value.kind !== "connection") continue;
966
1060
  const parsed = parseConnectionValue(value.reference);
967
- if (parsed === null || parsed.serviceId !== serviceId || parsed.outputKey !== "url") continue;
968
- // Only a PUBLIC service's own url() is a cycle: it resolves to the platform
969
- // URL, which does not exist until the service is up. A private service's
970
- // resolves from its deterministic hostname, so it can name itself.
971
- if (service.definition.public !== true) continue;
972
- const ports = deploymentPortEntries(service.definition.ports);
973
- const port = parsed.port === null
974
- ? ports.filter((entry) => entry.protocol === "http")[0]
975
- : ports.find((entry) => entry.port === parsed.port);
976
- if (port === undefined) continue;
977
- 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.`);
1061
+ if (parsed === null || parsed.serviceId !== serviceId) continue;
1062
+ const targetIsPublic = parsed.port === null || deploymentPortEntry(service.definition.ports, parsed.port) === null
1063
+ ? null
1064
+ : service.definition.public === true;
1065
+ if (!connectionRequiresTargetDeployed(runtime, parsed.outputKey, parsed.port, targetIsPublic)) continue;
1066
+ 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."}`);
978
1067
  }
979
1068
  }
980
1069
 
981
- return { sourceId, services };
1070
+ return { sourceId, services, builder, runtime, version };
982
1071
  }
983
1072
 
984
1073
  /**
@@ -987,7 +1076,7 @@ export function evaluateDeploymentConfig(options: {
987
1076
  * services within one level are independent of each other. Throws on circular
988
1077
  * dependencies, naming the cycle.
989
1078
  */
990
- export function computeDeploymentLevels(services: Map<string, EvaluatedService>): string[][] {
1079
+ export function computeDeploymentLevels(services: Map<string, EvaluatedService>, runtime: DeploymentRuntime = DEFAULT_DEPLOYMENT_RUNTIME): string[][] {
991
1080
  const dependencies = new Map<string, Set<string>>();
992
1081
  for (const [serviceId, service] of services) {
993
1082
  const serviceDependencies = new Set<string>();
@@ -1000,19 +1089,16 @@ export function computeDeploymentLevels(services: Map<string, EvaluatedService>)
1000
1089
  // connections to services of ANOTHER deployment source are resolved
1001
1090
  // against already-deployed state, since that source deploys on its own
1002
1091
  // schedule and is not part of this deploy at all.
1003
- // Self-references never create a deploy edge: a self `url` is rejected outright above,
1004
- // and self internal outputs are deterministic from the synced definition (they don't depend on the
1005
- // service having deployed). Adding a self-edge here would make computeDeploymentLevels
1006
- // report a false circular-dependency error for a config that deploys fine.
1092
+ // Self-references never create a deploy edge: every self-reference to an address output
1093
+ // is rejected outright above, and the outputs that remain do not depend on the service
1094
+ // having deployed. Adding a self-edge here would make computeDeploymentLevels report a
1095
+ // circular-dependency error where the check above gives the specific reason.
1007
1096
  //
1008
- // The same reasoning excludes DETERMINISTIC cross-service references: only
1009
- // `url` and a bare `internalUrl()` need the target deployed. Counting the
1010
- // rest would serialize independent deploys and reject mutually-wired
1011
- // services as circular.
1012
- // Publicness decides whether a url() reference has to wait: a private
1013
- // service's URL is built from the deterministic hostname, so it resolves
1014
- // before the target exists. A target this file does not define is not part
1015
- // of this deploy either way, so it never becomes an edge.
1097
+ // The same reasoning excludes DETERMINISTIC cross-service references, which
1098
+ // the runtime decides (see connectionRequiresTargetDeployed): counting them
1099
+ // would serialize independent deploys and reject mutually-wired services as
1100
+ // circular. A target this file does not define is not part of this deploy
1101
+ // either way, so it never becomes an edge.
1016
1102
  const target = services.get(targetServiceId);
1017
1103
  if (target === undefined) continue;
1018
1104
  // Null when the named port is not one the target declares — the reference
@@ -1021,7 +1107,7 @@ export function computeDeploymentLevels(services: Map<string, EvaluatedService>)
1021
1107
  const targetIsPublic = parsed.port === null || deploymentPortEntry(target.definition.ports, parsed.port) === null
1022
1108
  ? null
1023
1109
  : target.definition.public === true;
1024
- if (!connectionRequiresTargetDeployed(parsed.outputKey, parsed.port, targetIsPublic)) continue;
1110
+ if (!connectionRequiresTargetDeployed(runtime, parsed.outputKey, parsed.port, targetIsPublic)) continue;
1025
1111
  if (targetServiceId !== HEXCLAVE_SERVICE_ID && targetServiceId !== serviceId) {
1026
1112
  serviceDependencies.add(targetServiceId);
1027
1113
  }
@@ -1154,9 +1240,9 @@ function resolveHexclaveOutputFromSessionEnv(envVarKey: string, outputKey: Hexcl
1154
1240
  }
1155
1241
 
1156
1242
  /**
1157
- * Loads a TypeScript/JavaScript module via jiti. Shared by the deploy-file and
1158
- * config-file loaders below so both fail with the same error on an unloadable
1159
- * file; `description` names which of the two it was.
1243
+ * Loads a TypeScript/JavaScript module via jiti, reporting an unloadable file as
1244
+ * a CliError rather than whatever jiti threw. `description` is how the file is
1245
+ * named in that error.
1160
1246
  */
1161
1247
  async function importModule(filePath: string, description: string): Promise<Record<string, unknown>> {
1162
1248
  const { createJiti } = await import("jiti");
@@ -1174,20 +1260,8 @@ async function importModule(filePath: string, description: string): Promise<Reco
1174
1260
  * the first (`id`) comes back too, so evaluation can name the rename instead of
1175
1261
  * reporting a missing export.
1176
1262
  */
1177
- export async function importDeployModule(deployFilePath: string): Promise<{ deploymentGroupId: unknown, legacyId: unknown, deploy: unknown }> {
1263
+ export async function importDeployModule(deployFilePath: string): Promise<{ deploymentGroupId: unknown, legacyId: unknown, deploy: unknown, version: unknown }> {
1178
1264
  const module = await importModule(deployFilePath, "deploy file");
1179
- return { deploymentGroupId: module.deploymentGroupId, legacyId: module.id, deploy: module.deploy };
1265
+ return { deploymentGroupId: module.deploymentGroupId, legacyId: module.id, deploy: module.deploy, version: module.version };
1180
1266
  }
1181
1267
 
1182
- /**
1183
- * Loads a config file (hexclave.config.ts) and returns its `config` export, plus
1184
- * a `deploy` export if it has one — a project small enough to keep its
1185
- * services in the config file may declare them there instead of in a deploy
1186
- * file. Those services belong to a deployment group named after the file (the
1187
- * config file has no `deploymentGroupId` export of its own), so they can coexist
1188
- * with the deploy files of other repositories deploying into the same project.
1189
- */
1190
- export async function importConfigModule(configPath: string): Promise<{ config: unknown, deploy: unknown }> {
1191
- const module = await importModule(configPath, "config file");
1192
- return { config: module.config, deploy: module.deploy };
1193
- }