@hexclave/cli 1.0.118 → 1.0.119
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexclave/cli",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
31
|
-
"@hexclave/js": "1.0.
|
|
32
|
-
"@hexclave/shared": "1.0.
|
|
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
|
-
|
|
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
|
|
180
|
-
* `hexclave config push
|
|
181
|
-
*
|
|
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
|
-
|
|
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,
|
|
6
|
-
import { collectPublicUrls, collectRequiredSecretKeys, deploymentDashboardUrl, firstFailedService, packageAndUploadSource,
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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", () => {
|
package/src/commands/deploy.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
|
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
|
|
498
|
-
//
|
|
499
|
-
|
|
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
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
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
|
package/src/commands/dev.ts
CHANGED
|
@@ -102,7 +102,7 @@ function splitDevCommandArgs(commandArgs: string[]): ChildCommand {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Wraps a devCommand string (from the
|
|
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
|
|
956
|
-
//
|
|
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
|
|
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.
|