@hexclave/cli 1.0.61 → 1.0.63

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.61",
3
+ "version": "1.0.63",
4
4
  "repository": "https://github.com/hexclave/hexclave",
5
5
  "description": "The CLI for Hexclave. https://hexclave.com",
6
6
  "main": "dist/index.js",
@@ -25,9 +25,9 @@
25
25
  "commander": "^13.1.0",
26
26
  "extract-zip": "^2.0.1",
27
27
  "jiti": "^2.4.2",
28
- "@hexclave/shared-backend": "1.0.61",
29
- "@hexclave/shared": "1.0.61",
30
- "@hexclave/js": "1.0.61"
28
+ "@hexclave/js": "1.0.63",
29
+ "@hexclave/shared-backend": "1.0.63",
30
+ "@hexclave/shared": "1.0.63"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "20.17.6",
@@ -10,6 +10,7 @@ import { getAdminProject } from "../lib/app.js";
10
10
  import { isProjectAuthWithRefreshToken, isProjectAuthWithSecretServerKey, resolveAuth, resolveProjectId, type ProjectAuthWithSecretServerKey } from "../lib/auth.js";
11
11
  import { resolveConfigFilePathOption } from "../lib/config-file-path.js";
12
12
  import { CliError } from "../lib/errors.js";
13
+ import { startProgress, withProgress } from "../lib/progress.js";
13
14
 
14
15
  const SHOW_ONBOARDING_STACK_CONFIG_VALUE = "show-onboarding";
15
16
 
@@ -258,13 +259,14 @@ export function registerConfigCommand(program: Command) {
258
259
  }
259
260
  assertConfigPullTarget(filePath, opts);
260
261
 
261
- const project = await getAdminProject(auth);
262
-
263
- const configOverride = await project.getConfigOverride("branch");
264
- if (!isValidConfig(configOverride)) {
265
- throw new CliError("Pulled branch config is not a valid local config object.");
266
- }
267
- await replaceConfigObject(filePath, configOverride);
262
+ await withProgress("Pulling config", async () => {
263
+ const project = await getAdminProject(auth);
264
+ const configOverride = await project.getConfigOverride("branch");
265
+ if (!isValidConfig(configOverride)) {
266
+ throw new CliError("Pulled branch config is not a valid local config object.");
267
+ }
268
+ await replaceConfigObject(filePath, configOverride);
269
+ });
268
270
  console.log(`Config written to ${filePath}`);
269
271
  });
270
272
 
@@ -287,19 +289,6 @@ export function registerConfigCommand(program: Command) {
287
289
  throw new CliError("Config file must have a .js or .ts extension.");
288
290
  }
289
291
 
290
- // The generated GitHub sync workflow installs the repo's dependencies
291
- // before running the CLI, so jiti resolves the config's SDK import (e.g.
292
- // `@hexclave/js`) from the project's own node_modules.
293
- const { createJiti } = await import("jiti");
294
- const jiti = createJiti(import.meta.url);
295
- const configModule: { config?: unknown } = await jiti.import(filePath);
296
-
297
- const config = parseConfigOverride(configModule.config);
298
- if (config == null) {
299
- const exampleImport = detectImportPackageFromDir(path.dirname(filePath)) ?? "@hexclave/js";
300
- throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { HexclaveConfig } from "${exampleImport}"; export const config: HexclaveConfig = { ... };`);
301
- }
302
-
303
292
  const source = buildConfigPushSource(opts.configFile, {
304
293
  source: opts.source,
305
294
  sourceRepo: opts.sourceRepo,
@@ -307,16 +296,35 @@ export function registerConfigCommand(program: Command) {
307
296
  sourceWorkflowPath: opts.sourceWorkflowPath,
308
297
  });
309
298
 
310
- if (isProjectAuthWithSecretServerKey(auth)) {
311
- await pushConfigWithSecretServerKey(auth, config, source);
312
- } else {
313
- if (!isProjectAuthWithRefreshToken(auth)) {
314
- throw new CliError("`hexclave config push` requires either STACK_SECRET_SERVER_KEY or `hexclave login`.");
299
+ const progress = startProgress("Loading config");
300
+ try {
301
+ // The generated GitHub sync workflow installs the repo's dependencies
302
+ // before running the CLI, so jiti resolves the config's SDK import (e.g.
303
+ // `@hexclave/js`) from the project's own node_modules.
304
+ const { createJiti } = await import("jiti");
305
+ const jiti = createJiti(import.meta.url);
306
+ const configModule: { config?: unknown } = await jiti.import(filePath);
307
+
308
+ const config = parseConfigOverride(configModule.config);
309
+ if (config == null) {
310
+ const exampleImport = detectImportPackageFromDir(path.dirname(filePath)) ?? "@hexclave/js";
311
+ throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { HexclaveConfig } from "${exampleImport}"; export const config: HexclaveConfig = { ... };`);
315
312
  }
316
- const project = await getAdminProject(auth);
317
- await project.pushConfig(config, {
318
- source: sourceToSdkSource(source),
319
- });
313
+
314
+ progress.update("Pushing config");
315
+ if (isProjectAuthWithSecretServerKey(auth)) {
316
+ await pushConfigWithSecretServerKey(auth, config, source);
317
+ } else {
318
+ if (!isProjectAuthWithRefreshToken(auth)) {
319
+ throw new CliError("`hexclave config push` requires either STACK_SECRET_SERVER_KEY or `hexclave login`.");
320
+ }
321
+ const project = await getAdminProject(auth);
322
+ await project.pushConfig(config, {
323
+ source: sourceToSdkSource(source),
324
+ });
325
+ }
326
+ } finally {
327
+ progress.stop();
320
328
  }
321
329
 
322
330
  console.log("Config pushed successfully.");
@@ -0,0 +1,221 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { assertSecretsMatchEnv, extractServiceDefinition, parseSecretOptions, resolveDeployConfigPath, type ServiceEnvVarConfig } from "./deploy.js";
6
+
7
+ describe("parseSecretOptions", () => {
8
+ it("parses KEY=VALUE pairs", () => {
9
+ expect(parseSecretOptions(["a=1", "db_connection=two"])).toEqual(new Map([
10
+ ["a", "1"],
11
+ ["db_connection", "two"],
12
+ ]));
13
+ });
14
+
15
+ it("keeps everything after the first = as the value", () => {
16
+ expect(parseSecretOptions(["db_connection=postgres://user:pass@host/db?a=b"])).toEqual(new Map([
17
+ ["db_connection", "postgres://user:pass@host/db?a=b"],
18
+ ]));
19
+ });
20
+
21
+ it("allows empty values", () => {
22
+ expect(parseSecretOptions(["empty="])).toEqual(new Map([["empty", ""]]));
23
+ });
24
+
25
+ it("rejects entries without =", () => {
26
+ expect(() => parseSecretOptions(["novalue"])).toThrow("KEY=VALUE");
27
+ });
28
+
29
+ it("rejects entries with an empty key", () => {
30
+ expect(() => parseSecretOptions(["=value"])).toThrow("KEY=VALUE");
31
+ });
32
+
33
+ it("rejects invalid keys", () => {
34
+ expect(() => parseSecretOptions(["bad key=x"])).toThrow("Invalid --secret key");
35
+ expect(() => parseSecretOptions(["bad.key=x"])).toThrow("Invalid --secret key");
36
+ });
37
+
38
+ it("rejects duplicate keys", () => {
39
+ expect(() => parseSecretOptions(["a=1", "a=2"])).toThrow("Duplicate --secret key");
40
+ });
41
+ });
42
+
43
+ describe("extractServiceDefinition", () => {
44
+ const config = {
45
+ "deployments-alpha": {
46
+ services: {
47
+ api: {
48
+ type: "vercel",
49
+ framework: "nextjs",
50
+ installCommand: "pnpm install",
51
+ buildCommand: "pnpm build",
52
+ outputDirectory: ".next",
53
+ rootDirectory: "./api",
54
+ env: {
55
+ MY_ENV_VAR: { value: "true" },
56
+ DATABASE_CONNECTION_STRING: { type: "secret", key: "db_connection" },
57
+ NEXT_PUBLIC_HEXCLAVE_PROJECT_ID: { type: "connection", value: "hexclave.projectId" },
58
+ },
59
+ },
60
+ minimal: { type: "vercel" },
61
+ },
62
+ },
63
+ };
64
+
65
+ it("extracts a fully specified service", () => {
66
+ expect(extractServiceDefinition(config, "api")).toEqual({
67
+ framework: "nextjs",
68
+ installCommand: "pnpm install",
69
+ buildCommand: "pnpm build",
70
+ outputDirectory: ".next",
71
+ rootDirectory: "./api",
72
+ env: {
73
+ MY_ENV_VAR: { value: "true" },
74
+ DATABASE_CONNECTION_STRING: { type: "secret", key: "db_connection" },
75
+ NEXT_PUBLIC_HEXCLAVE_PROJECT_ID: { type: "connection", value: "hexclave.projectId" },
76
+ },
77
+ });
78
+ });
79
+
80
+ it("extracts a minimal service", () => {
81
+ expect(extractServiceDefinition(config, "minimal")).toEqual({
82
+ framework: undefined,
83
+ installCommand: undefined,
84
+ buildCommand: undefined,
85
+ outputDirectory: undefined,
86
+ rootDirectory: undefined,
87
+ env: {},
88
+ });
89
+ });
90
+
91
+ it("lists available services when the requested one is missing", () => {
92
+ expect(() => extractServiceDefinition(config, "web")).toThrow("Available services: api, minimal");
93
+ });
94
+
95
+ it("rejects configs without a deployments-alpha.services section", () => {
96
+ expect(() => extractServiceDefinition({}, "api")).toThrow("deployments-alpha.services");
97
+ });
98
+
99
+ it("rejects services without a type", () => {
100
+ expect(() => extractServiceDefinition({
101
+ "deployments-alpha": { services: { api: { framework: "nextjs" } } },
102
+ }, "api")).toThrow('Add `type: "vercel"`');
103
+ });
104
+
105
+ it("rejects services with an unknown type", () => {
106
+ expect(() => extractServiceDefinition({
107
+ "deployments-alpha": { services: { api: { type: "netlify" } } },
108
+ }, "api")).toThrow('must be "vercel"');
109
+ });
110
+
111
+ it("rejects non-string build fields", () => {
112
+ expect(() => extractServiceDefinition({
113
+ "deployments-alpha": { services: { api: { type: "vercel", buildCommand: 42 } } },
114
+ }, "api")).toThrow("must be a string");
115
+ });
116
+
117
+ it("rejects invalid env var keys", () => {
118
+ expect(() => extractServiceDefinition({
119
+ "deployments-alpha": { services: { api: { type: "vercel", env: { "1BAD": { value: "x" } } } } },
120
+ }, "api")).toThrow("invalid key");
121
+ });
122
+
123
+ it("rejects secret env vars with an inline value", () => {
124
+ expect(() => extractServiceDefinition({
125
+ "deployments-alpha": { services: { api: { type: "vercel", env: { A: { type: "secret", key: "a", value: "leaked" } } } } },
126
+ }, "api")).toThrow("must not have a `value`");
127
+ });
128
+
129
+ it("rejects secret env vars without a key", () => {
130
+ expect(() => extractServiceDefinition({
131
+ "deployments-alpha": { services: { api: { type: "vercel", env: { A: { type: "secret" } } } } },
132
+ }, "api")).toThrow("must have a `key`");
133
+ });
134
+
135
+ it("rejects plain env vars with a secret key", () => {
136
+ expect(() => extractServiceDefinition({
137
+ "deployments-alpha": { services: { api: { type: "vercel", env: { A: { value: "x", key: "a" } } } } },
138
+ }, "api")).toThrow("must not have a `key`");
139
+ });
140
+
141
+ it("rejects the legacy {service.output} interpolation syntax in connections", () => {
142
+ expect(() => extractServiceDefinition({
143
+ "deployments-alpha": { services: { api: { type: "vercel", env: { A: { type: "connection", value: "{hexclave.projectId}" } } } } },
144
+ }, "api")).toThrow("service output");
145
+ });
146
+
147
+ it("rejects unknown env var types", () => {
148
+ expect(() => extractServiceDefinition({
149
+ "deployments-alpha": { services: { api: { type: "vercel", env: { A: { type: "literal", value: "x" } } } } },
150
+ }, "api")).toThrow("unknown `type`");
151
+ });
152
+ });
153
+
154
+ describe("assertSecretsMatchEnv", () => {
155
+ const env: Record<string, ServiceEnvVarConfig> = {
156
+ PLAIN: { value: "x" },
157
+ DB: { type: "secret", key: "db_connection" },
158
+ API: { type: "secret", key: "api-key" },
159
+ PROJECT: { type: "connection", value: "hexclave.projectId" },
160
+ };
161
+
162
+ it("accepts exactly matching secrets", () => {
163
+ expect(() => assertSecretsMatchEnv(env, new Map([["db_connection", "a"], ["api-key", "b"]]))).not.toThrow();
164
+ });
165
+
166
+ it("rejects missing secrets", () => {
167
+ expect(() => assertSecretsMatchEnv(env, new Map([["db_connection", "a"]]))).toThrow("Missing secret values for: api-key");
168
+ });
169
+
170
+ it("rejects unknown secrets", () => {
171
+ expect(() => assertSecretsMatchEnv(env, new Map([["db_connection", "a"], ["api-key", "b"], ["typo", "c"]]))).toThrow("Unknown --secret key(s): typo");
172
+ });
173
+
174
+ it("accepts no secrets when none are referenced", () => {
175
+ expect(() => assertSecretsMatchEnv({ PLAIN: { value: "x" } }, new Map())).not.toThrow();
176
+ });
177
+ });
178
+
179
+ describe("resolveDeployConfigPath", () => {
180
+ const tempDirs: string[] = [];
181
+ const makeTempDir = () => {
182
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hexclave-deploy-test-"));
183
+ tempDirs.push(dir);
184
+ return dir;
185
+ };
186
+ afterEach(() => {
187
+ for (const dir of tempDirs.splice(0)) {
188
+ fs.rmSync(dir, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ it("prefers an explicit --config path", () => {
193
+ const dir = makeTempDir();
194
+ const configPath = path.join(dir, "custom.config.ts");
195
+ fs.writeFileSync(configPath, "export const config = {};");
196
+ expect(resolveDeployConfigPath("custom.config.ts", dir)).toBe(configPath);
197
+ });
198
+
199
+ it("errors when the explicit path doesn't exist", () => {
200
+ const dir = makeTempDir();
201
+ expect(() => resolveDeployConfigPath("missing.config.ts", dir)).toThrow("Config file not found");
202
+ });
203
+
204
+ it("auto-discovers hexclave.config.ts before stack.config.ts", () => {
205
+ const dir = makeTempDir();
206
+ fs.writeFileSync(path.join(dir, "stack.config.ts"), "export const config = {};");
207
+ fs.writeFileSync(path.join(dir, "hexclave.config.ts"), "export const config = {};");
208
+ expect(resolveDeployConfigPath(undefined, dir)).toBe(path.join(dir, "hexclave.config.ts"));
209
+ });
210
+
211
+ it("falls back to stack.config.ts", () => {
212
+ const dir = makeTempDir();
213
+ fs.writeFileSync(path.join(dir, "stack.config.ts"), "export const config = {};");
214
+ expect(resolveDeployConfigPath(undefined, dir)).toBe(path.join(dir, "stack.config.ts"));
215
+ });
216
+
217
+ it("returns undefined when nothing is found (dashboard mode)", () => {
218
+ const dir = makeTempDir();
219
+ expect(resolveDeployConfigPath(undefined, dir)).toBeUndefined();
220
+ });
221
+ });