@hexclave/cli 1.0.51 → 1.0.52

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.51",
3
+ "version": "1.0.52",
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.51",
29
- "@hexclave/js": "1.0.51",
30
- "@hexclave/shared": "1.0.51"
28
+ "@hexclave/shared-backend": "1.0.52",
29
+ "@hexclave/js": "1.0.52",
30
+ "@hexclave/shared": "1.0.52"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "20.17.6",
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
4
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
- import { buildConfigPushSource, resolveConfigFilePathForPull, shouldReplaceConfigFileForPull } from "./config-file.js";
5
+ import { assertConfigPullTarget, buildConfigPushSource, resolveConfigFilePathForPull } from "./config-file.js";
6
6
 
7
7
  describe("resolveConfigFilePathForPull", () => {
8
8
  let tmpDir: string;
@@ -46,7 +46,7 @@ describe("resolveConfigFilePathForPull", () => {
46
46
  });
47
47
  });
48
48
 
49
- describe("shouldReplaceConfigFileForPull", () => {
49
+ describe("assertConfigPullTarget", () => {
50
50
  let tmpDir: string;
51
51
 
52
52
  beforeEach(() => {
@@ -57,16 +57,26 @@ describe("shouldReplaceConfigFileForPull", () => {
57
57
  fs.rmSync(tmpDir, { recursive: true, force: true });
58
58
  });
59
59
 
60
- it("updates an existing config in place unless overwrite is explicit", () => {
60
+ it("does not throw when the target file does not exist", () => {
61
+ expect(() => assertConfigPullTarget(path.join(tmpDir, "hexclave.config.ts"), {})).not.toThrow();
62
+ });
63
+
64
+ it("throws when the target file already exists and --overwrite is not set", () => {
61
65
  const configPath = path.join(tmpDir, "hexclave.config.ts");
62
66
  fs.writeFileSync(configPath, "export const config = {};\n");
67
+ expect(() => assertConfigPullTarget(configPath, {})).toThrow(/already exists.*--overwrite/s);
68
+ });
63
69
 
64
- expect(shouldReplaceConfigFileForPull(configPath, {})).toBe(false);
65
- expect(shouldReplaceConfigFileForPull(configPath, { overwrite: true })).toBe(true);
70
+ it("throws for an existing placeholder/comment-only file without --overwrite", () => {
71
+ const configPath = path.join(tmpDir, "stack.config.ts");
72
+ fs.writeFileSync(configPath, "// placeholder so the file exists\n");
73
+ expect(() => assertConfigPullTarget(configPath, {})).toThrow(/already exists/);
66
74
  });
67
75
 
68
- it("replaces when pull needs to create a new config file", () => {
69
- expect(shouldReplaceConfigFileForPull(path.join(tmpDir, "hexclave.config.ts"), {})).toBe(true);
76
+ it("does not throw when --overwrite is set, even if the file exists", () => {
77
+ const configPath = path.join(tmpDir, "hexclave.config.ts");
78
+ fs.writeFileSync(configPath, "export const config = {};\n");
79
+ expect(() => assertConfigPullTarget(configPath, { overwrite: true })).not.toThrow();
70
80
  });
71
81
  });
72
82
 
@@ -1,4 +1,4 @@
1
- import { replaceConfigObject, updateConfigObject } from "@hexclave/shared-backend";
1
+ import { replaceConfigObject } from "@hexclave/shared-backend";
2
2
  import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
3
3
  import { isValidConfig } from "@hexclave/shared/dist/config/format";
4
4
  import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
@@ -217,8 +217,20 @@ export function resolveConfigFilePathForPull(opts: { configFile?: string }, cwd:
217
217
  return candidate;
218
218
  }
219
219
 
220
- export function shouldReplaceConfigFileForPull(filePath: string, opts: { overwrite?: boolean }): boolean {
221
- return opts.overwrite === true || !fs.existsSync(filePath);
220
+ // `config pull` means "download the entire branch config into a fresh local file". It always writes
221
+ // the whole file (via replaceConfigObject) and never edits an existing file in place. In-place,
222
+ // hand-authored-preserving edits are the job of the config *update* flow (e.g. from the RDE), which
223
+ // routes through updateConfigObject's agent-assisted rewrite — that path is intentionally not
224
+ // reachable from `pull`.
225
+ //
226
+ // Because pull writes the whole file, it would clobber whatever is already at the target path. To
227
+ // avoid silently destroying a hand-authored config, we refuse to write over an existing file and
228
+ // require the user to opt in explicitly with --overwrite.
229
+ export function assertConfigPullTarget(filePath: string, opts: { overwrite?: boolean }): void {
230
+ if (opts.overwrite === true) return;
231
+ if (fs.existsSync(filePath)) {
232
+ throw new CliError(`A config file already exists at ${filePath}. Pass --overwrite to replace it with the pulled config, or remove the file first.`);
233
+ }
222
234
  }
223
235
 
224
236
  export function registerConfigCommand(program: Command) {
@@ -231,30 +243,28 @@ export function registerConfigCommand(program: Command) {
231
243
  .description("Pull branch config to a local file")
232
244
  .option("--cloud-project-id <id>", "Cloud project ID to pull config from (defaults to the STACK_PROJECT_ID env var)")
233
245
  .option("--config-file <path>", "Path to write config file (.ts); defaults to ./hexclave.config.ts in the current directory")
234
- .option("--overwrite", "Overwrite an existing config file instead of updating it in place")
246
+ .option("--overwrite", "Replace the config file if one already exists at the target path")
235
247
  .action(async (opts) => {
236
248
  const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
237
249
  if (!isProjectAuthWithRefreshToken(auth)) {
238
250
  throw new CliError("`hexclave config pull` requires `hexclave login`. Remove STACK_SECRET_SERVER_KEY and try again.");
239
251
  }
240
- const project = await getAdminProject(auth);
241
-
242
- const configOverride = await project.getConfigOverride("branch");
243
- if (!isValidConfig(configOverride)) {
244
- throw new CliError("Pulled branch config is not a valid local config object.");
245
- }
252
+ // Resolve and validate the target file before any network work so we fail fast (e.g. when the
253
+ // target already exists without --overwrite) instead of paying for a wasted round-trip.
246
254
  const filePath = resolveConfigFilePathForPull(opts, process.cwd());
247
255
  const ext = path.extname(filePath);
248
-
249
256
  if (ext !== ".ts") {
250
257
  throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
251
258
  }
259
+ assertConfigPullTarget(filePath, opts);
252
260
 
253
- if (shouldReplaceConfigFileForPull(filePath, opts)) {
254
- await replaceConfigObject(filePath, configOverride);
255
- } else {
256
- await updateConfigObject(filePath, configOverride);
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.");
257
266
  }
267
+ await replaceConfigObject(filePath, configOverride);
258
268
  console.log(`Config written to ${filePath}`);
259
269
  });
260
270