@jango-blockchained/hoox-cli 0.5.0 → 0.5.2

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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/commands/check/check-command.ts +8 -65
  3. package/src/commands/check/prerequisites-command.ts +7 -6
  4. package/src/commands/clone/clone-command.test.ts +8 -10
  5. package/src/commands/config/config-command.test.ts +4 -4
  6. package/src/commands/config/config-command.ts +10 -11
  7. package/src/commands/config/env-command.test.ts +2 -2
  8. package/src/commands/config/env-command.ts +40 -43
  9. package/src/commands/config/kv-command.ts +60 -60
  10. package/src/commands/dashboard/dashboard-command.ts +32 -30
  11. package/src/commands/db/db-command.ts +79 -80
  12. package/src/commands/deploy/deploy-command.test.ts +54 -34
  13. package/src/commands/deploy/deploy-command.ts +3 -84
  14. package/src/commands/dev/dev-command.test.ts +84 -62
  15. package/src/commands/disclaimer/disclaimer-command.ts +21 -0
  16. package/src/commands/disclaimer/index.ts +1 -0
  17. package/src/commands/init/init-command.test.ts +69 -91
  18. package/src/commands/init/init-command.ts +19 -15
  19. package/src/commands/logs/logs-command.test.ts +0 -1
  20. package/src/commands/monitor/monitor-command.test.ts +37 -29
  21. package/src/commands/repair/repair-command.test.ts +60 -41
  22. package/src/commands/repair/repair-service.ts +26 -0
  23. package/src/commands/schema/index.ts +1 -0
  24. package/src/commands/schema/schema-command.ts +137 -0
  25. package/src/commands/test/test-command.test.ts +2 -3
  26. package/src/commands/update/update-command.ts +6 -65
  27. package/src/commands/waf/waf-command.ts +56 -59
  28. package/src/index.ts +5 -13
  29. package/src/services/config/config-service.ts +1 -6
  30. package/src/services/db/db-service.test.ts +13 -1
  31. package/src/services/env/env-service.test.ts +41 -18
  32. package/src/services/env/env-service.ts +45 -59
  33. package/src/services/kv/kv-sync-service.ts +14 -5
  34. package/src/services/schema/index.ts +1 -0
  35. package/src/services/schema/schema-service.ts +99 -0
  36. package/src/services/secrets/secrets-service.test.ts +42 -35
  37. package/src/services/secrets/types.ts +1 -1
  38. package/src/ui/menu.ts +1 -1
  39. package/src/utils/error-handler.ts +62 -0
  40. package/src/utils/errors.ts +1 -0
  41. package/src/utils/git.ts +134 -0
  42. package/src/utils/theme.ts +0 -2
@@ -0,0 +1,99 @@
1
+ import { readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import {
4
+ WORKER_MANIFESTS,
5
+ WORKER_NAMES,
6
+ validateWranglerJsonc,
7
+ validateRootSecrets,
8
+ validateDevVars,
9
+ type WorkerManifest,
10
+ type ValidationError,
11
+ } from "@jango-blockchained/hoox-shared";
12
+
13
+ export interface SchemaCheckResult {
14
+ worker: string;
15
+ passed: boolean;
16
+ errors: ValidationError[];
17
+ }
18
+
19
+ export class SchemaService {
20
+ constructor(private projectRoot: string = process.cwd()) {}
21
+
22
+ getWorkerNames(): string[] {
23
+ return WORKER_NAMES;
24
+ }
25
+
26
+ getManifest(name: string): WorkerManifest | undefined {
27
+ return WORKER_MANIFESTS[name];
28
+ }
29
+
30
+ validateWorker(name: string): SchemaCheckResult {
31
+ const manifest = WORKER_MANIFESTS[name];
32
+ if (!manifest) {
33
+ return {
34
+ worker: name,
35
+ passed: false,
36
+ errors: [
37
+ {
38
+ worker: name,
39
+ severity: "error",
40
+ message: `Unknown worker "${name}"`,
41
+ },
42
+ ],
43
+ };
44
+ }
45
+
46
+ const workersDir = resolve(this.projectRoot, "workers");
47
+ const rootWranglerPath = resolve(this.projectRoot, "wrangler.jsonc");
48
+ const workerWranglerPath = resolve(workersDir, name, "wrangler.jsonc");
49
+ const devVarsPath = resolve(workersDir, name, ".dev.vars");
50
+
51
+ const errors: ValidationError[] = [];
52
+
53
+ // Read and validate per-worker wrangler.jsonc
54
+ try {
55
+ const wranglerContent = readFileSync(workerWranglerPath, "utf-8");
56
+ errors.push(...validateWranglerJsonc(name, manifest, wranglerContent));
57
+ } catch (e: any) {
58
+ errors.push({
59
+ worker: name,
60
+ severity: "error",
61
+ message: `Cannot read ${workerWranglerPath}: ${e.message}`,
62
+ });
63
+ }
64
+
65
+ // Read and validate root wrangler.jsonc (if exists)
66
+ try {
67
+ const rootContent = readFileSync(rootWranglerPath, "utf-8");
68
+ errors.push(...validateRootSecrets(name, manifest, rootContent));
69
+ } catch (e: any) {
70
+ errors.push({
71
+ worker: name,
72
+ severity: "warning",
73
+ message: `Cannot read root wrangler.jsonc: ${e.message}`,
74
+ });
75
+ }
76
+
77
+ // Read and validate .dev.vars (if exists)
78
+ try {
79
+ const devVarsContent = readFileSync(devVarsPath, "utf-8");
80
+ errors.push(...validateDevVars(name, manifest, devVarsContent));
81
+ } catch (e: any) {
82
+ errors.push({
83
+ worker: name,
84
+ severity: "warning",
85
+ message: `Cannot read ${devVarsPath}: ${e.message}`,
86
+ });
87
+ }
88
+
89
+ return {
90
+ worker: name,
91
+ passed: errors.filter((e) => e.severity === "error").length === 0,
92
+ errors,
93
+ };
94
+ }
95
+
96
+ validateAll(): SchemaCheckResult[] {
97
+ return WORKER_NAMES.map((name) => this.validateWorker(name));
98
+ }
99
+ }
@@ -33,12 +33,16 @@ const WORKERS_JSONC = JSON.stringify({
33
33
  "telegram-worker": {
34
34
  enabled: true,
35
35
  path: "workers/telegram-worker",
36
- secrets: ["TELEGRAM_BOT_TOKEN"],
36
+ secrets: ["TG_BOT_TOKEN_BINDING"],
37
37
  },
38
38
  "trade-worker": {
39
39
  enabled: true,
40
40
  path: "workers/trade-worker",
41
- secrets: ["API_SERVICE_KEY", "BINANCE_API_KEY", "BINANCE_API_SECRET"],
41
+ secrets: [
42
+ "API_SERVICE_KEY_BINDING",
43
+ "BINANCE_KEY_BINDING",
44
+ "BINANCE_SECRET_BINDING",
45
+ ],
42
46
  },
43
47
  "d1-worker": {
44
48
  enabled: true,
@@ -106,16 +110,16 @@ describe("SecretsService", () => {
106
110
  it("returns correct secret names for a worker that has secrets", async () => {
107
111
  const svc = await createService();
108
112
  expect(svc.listSecrets("trade-worker")).toEqual([
109
- "API_SERVICE_KEY",
110
- "BINANCE_API_KEY",
111
- "BINANCE_API_SECRET",
113
+ "API_SERVICE_KEY_BINDING",
114
+ "BINANCE_KEY_BINDING",
115
+ "BINANCE_SECRET_BINDING",
112
116
  ]);
113
117
  });
114
118
 
115
119
  it("returns single secret for telegram-worker", async () => {
116
120
  const svc = await createService();
117
121
  expect(svc.listSecrets("telegram-worker")).toEqual([
118
- "TELEGRAM_BOT_TOKEN",
122
+ "TG_BOT_TOKEN_BINDING",
119
123
  ]);
120
124
  });
121
125
 
@@ -143,11 +147,11 @@ describe("SecretsService", () => {
143
147
  const all = svc.listAllSecrets();
144
148
 
145
149
  expect(Object.keys(all)).toHaveLength(2);
146
- expect(all["telegram-worker"]).toEqual(["TELEGRAM_BOT_TOKEN"]);
150
+ expect(all["telegram-worker"]).toEqual(["TG_BOT_TOKEN_BINDING"]);
147
151
  expect(all["trade-worker"]).toEqual([
148
- "API_SERVICE_KEY",
149
- "BINANCE_API_KEY",
150
- "BINANCE_API_SECRET",
152
+ "API_SERVICE_KEY_BINDING",
153
+ "BINANCE_KEY_BINDING",
154
+ "BINANCE_SECRET_BINDING",
151
155
  ]);
152
156
  });
153
157
 
@@ -175,9 +179,9 @@ describe("SecretsService", () => {
175
179
  expect(result.worker).toBe("trade-worker");
176
180
  expect(result.allSet).toBe(false);
177
181
  expect(result.missing).toEqual([
178
- "API_SERVICE_KEY",
179
- "BINANCE_API_KEY",
180
- "BINANCE_API_SECRET",
182
+ "API_SERVICE_KEY_BINDING",
183
+ "BINANCE_KEY_BINDING",
184
+ "BINANCE_SECRET_BINDING",
181
185
  ]);
182
186
  expect(result.secrets).toHaveLength(3);
183
187
  for (const s of result.secrets) {
@@ -193,7 +197,7 @@ describe("SecretsService", () => {
193
197
  try {
194
198
  writeFileSync(
195
199
  join(dir, ".dev.vars"),
196
- "API_SERVICE_KEY=abc123\nBINANCE_API_KEY=xyz789\nBINANCE_API_SECRET=sec456\n"
200
+ "API_SERVICE_KEY_BINDING=abc123\nBINANCE_KEY_BINDING=xyz789\nBINANCE_SECRET_BINDING=sec456\n"
197
201
  );
198
202
 
199
203
  const svc = await createService();
@@ -217,7 +221,7 @@ describe("SecretsService", () => {
217
221
  try {
218
222
  writeFileSync(
219
223
  join(dir, ".dev.vars"),
220
- "API_SERVICE_KEY=placeholder_api_service_key\nBINANCE_API_KEY=binance-real-key\nBINANCE_API_SECRET=your_secret\n"
224
+ "API_SERVICE_KEY_BINDING=placeholder_api_service_key\nBINANCE_KEY_BINDING=binance-real-key\nBINANCE_SECRET_BINDING=your_secret\n"
221
225
  );
222
226
 
223
227
  const svc = await createService();
@@ -226,10 +230,10 @@ describe("SecretsService", () => {
226
230
 
227
231
  const result = await svc.checkLocalSecrets("trade-worker");
228
232
  expect(result.allSet).toBe(false);
229
- // Only BINANCE_API_KEY should be set (not placeholder)
230
- expect(result.missing).toContain("API_SERVICE_KEY");
231
- expect(result.missing).toContain("BINANCE_API_SECRET");
232
- expect(result.missing).not.toContain("BINANCE_API_KEY");
233
+ // Only BINANCE_KEY_BINDING should be set (not placeholder)
234
+ expect(result.missing).toContain("API_SERVICE_KEY_BINDING");
235
+ expect(result.missing).toContain("BINANCE_SECRET_BINDING");
236
+ expect(result.missing).not.toContain("BINANCE_KEY_BINDING");
233
237
  } finally {
234
238
  rmSync(dir, { recursive: true, force: true });
235
239
  }
@@ -240,7 +244,7 @@ describe("SecretsService", () => {
240
244
  try {
241
245
  writeFileSync(
242
246
  join(dir, ".dev.vars"),
243
- "# This is a comment\nAPI_SERVICE_KEY=real-key\n\n# Another comment\nBINANCE_API_KEY=another-key\nBINANCE_API_SECRET=third-key\n"
247
+ "# This is a comment\nAPI_SERVICE_KEY_BINDING=real-key\n\n# Another comment\nBINANCE_KEY_BINDING=another-key\nBINANCE_SECRET_BINDING=third-key\n"
244
248
  );
245
249
 
246
250
  const svc = await createService();
@@ -280,7 +284,7 @@ describe("SecretsService", () => {
280
284
  // Verify file content
281
285
  const content = await Bun.file(join(dir, ".dev.vars")).text();
282
286
  expect(content).toContain(
283
- "TELEGRAM_BOT_TOKEN=placeholder_telegram_bot_token"
287
+ "TG_BOT_TOKEN_BINDING=placeholder_tg_bot_token_binding"
284
288
  );
285
289
  } finally {
286
290
  rmSync(dir, { recursive: true, force: true });
@@ -323,7 +327,7 @@ describe("SecretsService", () => {
323
327
 
324
328
  const content = await Bun.file(join(dir, ".dev.vars")).text();
325
329
  expect(content).toContain(
326
- "TELEGRAM_BOT_TOKEN=placeholder_telegram_bot_token"
330
+ "TG_BOT_TOKEN_BINDING=placeholder_tg_bot_token_binding"
327
331
  );
328
332
  expect(content).not.toContain("OLD_KEY");
329
333
  } finally {
@@ -340,7 +344,7 @@ describe("SecretsService", () => {
340
344
  try {
341
345
  writeFileSync(
342
346
  join(dir, ".dev.vars"),
343
- "TELEGRAM_BOT_TOKEN=my-real-token\n"
347
+ "TG_BOT_TOKEN_BINDING=my-real-token\n"
344
348
  );
345
349
 
346
350
  const svc = await createService();
@@ -357,8 +361,8 @@ describe("SecretsService", () => {
357
361
  );
358
362
 
359
363
  const result = await svc.syncToCloudflare("telegram-worker");
360
- expect(expectOk(result)).toEqual(["TELEGRAM_BOT_TOKEN"]);
361
- expect(called).toEqual([["TELEGRAM_BOT_TOKEN", "my-real-token"]]);
364
+ expect(expectOk(result)).toEqual(["TG_BOT_TOKEN_BINDING"]);
365
+ expect(called).toEqual([["TG_BOT_TOKEN_BINDING", "my-real-token"]]);
362
366
  } finally {
363
367
  rmSync(dir, { recursive: true, force: true });
364
368
  }
@@ -369,7 +373,7 @@ describe("SecretsService", () => {
369
373
  try {
370
374
  writeFileSync(
371
375
  join(dir, ".dev.vars"),
372
- "API_SERVICE_KEY=placeholder_api_service_key\nBINANCE_API_KEY=real-binance-key\nBINANCE_API_SECRET=generate_something\n"
376
+ "API_SERVICE_KEY_BINDING=placeholder_api_service_key\nBINANCE_KEY_BINDING=real-binance-key\nBINANCE_SECRET_BINDING=generate_something\n"
373
377
  );
374
378
 
375
379
  const svc = await createService();
@@ -386,10 +390,10 @@ describe("SecretsService", () => {
386
390
 
387
391
  const result = await svc.syncToCloudflare("trade-worker");
388
392
  const errMsg = expectErr(result);
389
- // Only BINANCE_API_KEY was synced — but errors exist, so overall result is error
390
- expect(errMsg).toContain("API_SERVICE_KEY");
391
- expect(errMsg).toContain("BINANCE_API_SECRET");
392
- expect(called).toEqual([["BINANCE_API_KEY", "real-binance-key"]]);
393
+ // Only BINANCE_KEY_BINDING was synced — but errors exist, so overall result is error
394
+ expect(errMsg).toContain("API_SERVICE_KEY_BINDING");
395
+ expect(errMsg).toContain("BINANCE_SECRET_BINDING");
396
+ expect(called).toEqual([["BINANCE_KEY_BINDING", "real-binance-key"]]);
393
397
  } finally {
394
398
  rmSync(dir, { recursive: true, force: true });
395
399
  }
@@ -412,9 +416,9 @@ describe("SecretsService", () => {
412
416
 
413
417
  const result = await svc.syncToCloudflare("trade-worker");
414
418
  const errMsg = expectErr(result);
415
- expect(errMsg).toContain("API_SERVICE_KEY");
416
- expect(errMsg).toContain("BINANCE_API_KEY");
417
- expect(errMsg).toContain("BINANCE_API_SECRET");
419
+ expect(errMsg).toContain("API_SERVICE_KEY_BINDING");
420
+ expect(errMsg).toContain("BINANCE_KEY_BINDING");
421
+ expect(errMsg).toContain("BINANCE_SECRET_BINDING");
418
422
  expect(called).toHaveLength(0);
419
423
  } finally {
420
424
  rmSync(dir, { recursive: true, force: true });
@@ -430,7 +434,10 @@ describe("SecretsService", () => {
430
434
  it("handles wrangler failures gracefully", async () => {
431
435
  const dir = tmpDir();
432
436
  try {
433
- writeFileSync(join(dir, ".dev.vars"), "TELEGRAM_BOT_TOKEN=my-token\n");
437
+ writeFileSync(
438
+ join(dir, ".dev.vars"),
439
+ "TG_BOT_TOKEN_BINDING=my-token\n"
440
+ );
434
441
 
435
442
  const svc = await createService();
436
443
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -478,7 +485,7 @@ describe("SecretsService", () => {
478
485
  try {
479
486
  writeFileSync(
480
487
  join(dir, ".dev.vars"),
481
- " TELEGRAM_BOT_TOKEN = my-token \n"
488
+ " TG_BOT_TOKEN_BINDING = my-token \n"
482
489
  );
483
490
 
484
491
  const svc = await createService();
@@ -16,7 +16,7 @@ export type { Result };
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
18
  export interface SecretStatus {
19
- /** Name of the secret (e.g. "TELEGRAM_BOT_TOKEN"). */
19
+ /** Name of the secret (e.g. "TG_BOT_TOKEN_BINDING"). */
20
20
  name: string;
21
21
  /** Whether the secret has a real (non-placeholder) value set. */
22
22
  set: boolean;
package/src/ui/menu.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  isCancel,
18
18
  cancel,
19
19
  } from "@clack/prompts";
20
- import { renderBanner, renderCompactBanner, DISCLAIMER } from "./banner.js";
20
+ import { renderBanner, DISCLAIMER } from "./banner.js";
21
21
  import { CLIError } from "../utils/errors.js";
22
22
  import { theme } from "../utils/theme.js";
23
23
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared CLI error-handling wrapper.
3
+ *
4
+ * Provides a `withErrorHandling()` higher-order function that wraps command
5
+ * handlers with standardized error formatting and exit-code management,
6
+ * eliminating ~23 duplicated try/catch blocks across command files.
7
+ *
8
+ * Inner/recoverable try/catch blocks (e.g. JSON parse, per-key KV errors,
9
+ * API network errors) are left in place — only the outermost handler-level
10
+ * catch-all is replaced.
11
+ */
12
+
13
+ import { formatError } from "./formatters.js";
14
+ import { CLIError, ExitCode } from "./errors.js";
15
+ import type { FormatOptions } from "./formatters.js";
16
+
17
+ export type CommandResult = Promise<void>;
18
+
19
+ /**
20
+ * Wraps a command handler with standardized error handling.
21
+ *
22
+ * @param handler - The command action function to wrap
23
+ * @param options.service - Service name for error prefix (e.g. "db", "kv")
24
+ * @param options.opts - Optional FormatOptions for JSON/quiet-aware output
25
+ *
26
+ * @returns A wrapped function that catches errors, formats them, and exits
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * .action(withErrorHandling(async (cmd) => {
31
+ * const opts = getFormatOptions(cmd);
32
+ * // …command logic…
33
+ * }, { service: "db" }))
34
+ * ```
35
+ */
36
+ export function withErrorHandling<T extends unknown[]>(
37
+ handler: (...args: T) => Promise<void>,
38
+ options?: { service?: string; opts?: FormatOptions }
39
+ ): (...args: T) => CommandResult {
40
+ return async (...args: T) => {
41
+ try {
42
+ await handler(...args);
43
+ } catch (error) {
44
+ const service = options?.service ?? "cli";
45
+
46
+ if (error instanceof CLIError) {
47
+ // CLIError is already structured — pass through for proper formatting
48
+ formatError(error, options?.opts);
49
+ process.exit(error.code);
50
+ } else if (error instanceof Error) {
51
+ formatError(`[${service}] ${error.message}`, options?.opts);
52
+ process.exit(ExitCode.ERROR);
53
+ } else {
54
+ formatError(
55
+ `[${service}] Unknown error: ${String(error)}`,
56
+ options?.opts
57
+ );
58
+ process.exit(ExitCode.CommandFailed);
59
+ }
60
+ }
61
+ };
62
+ }
@@ -8,6 +8,7 @@ export const enum ExitCode {
8
8
  ERROR = 1,
9
9
  INVALID_USAGE = 2,
10
10
  INFRA_UNAVAILABLE = 3,
11
+ CommandFailed = -1,
11
12
  }
12
13
 
13
14
  export class CLIError extends Error {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared git utilities for Hoox CLI.
3
+ *
4
+ * All functions use Bun.spawn for asynchronous git operations.
5
+ * Bun is a global in the Bun runtime — no import needed.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Repository and working tree checks
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Check if `cwd` is inside a git work-tree.
14
+ */
15
+ export async function isGitRepo(cwd: string): Promise<boolean> {
16
+ const proc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
17
+ cwd,
18
+ stdout: "pipe",
19
+ stderr: "pipe",
20
+ });
21
+ const exitCode = await proc.exited;
22
+ return exitCode === 0;
23
+ }
24
+
25
+ /**
26
+ * Check whether a path inside `cwd` is a registered git submodule.
27
+ */
28
+ export async function isSubmodule(
29
+ cwd: string,
30
+ submodulePath: string
31
+ ): Promise<boolean> {
32
+ const proc = Bun.spawn(["git", "submodule", "status", "--", submodulePath], {
33
+ cwd,
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ });
37
+ const exitCode = await proc.exited;
38
+ if (exitCode !== 0) return false;
39
+ const out = (await new Response(proc.stdout).text()).trim();
40
+ return out.length > 0;
41
+ }
42
+
43
+ /**
44
+ * Check if a specific file inside `dir` is tracked by git.
45
+ */
46
+ export async function isGitTracked(
47
+ dir: string,
48
+ filename: string
49
+ ): Promise<boolean> {
50
+ try {
51
+ const proc = Bun.spawn(["git", "ls-files", "--error-unmatch", filename], {
52
+ cwd: dir,
53
+ stdout: "pipe",
54
+ stderr: "pipe",
55
+ });
56
+ const exitCode = await proc.exited;
57
+ return exitCode === 0;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Git operations
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Run `git pull --ff-only` inside `cwd`.
69
+ */
70
+ export async function gitPull(cwd: string): Promise<string> {
71
+ const proc = Bun.spawn(["git", "pull", "--ff-only"], {
72
+ cwd,
73
+ stdout: "pipe",
74
+ stderr: "pipe",
75
+ });
76
+ const exitCode = await proc.exited;
77
+ const stdout = await new Response(proc.stdout).text();
78
+ const stderr = await new Response(proc.stderr).text();
79
+
80
+ if (exitCode !== 0) {
81
+ throw new Error(stderr.trim() || `git pull failed (exit ${exitCode})`);
82
+ }
83
+ return stdout.trim();
84
+ }
85
+
86
+ /**
87
+ * Run `git submodule update --remote --init` for a specific submodule path.
88
+ */
89
+ export async function gitSubmoduleUpdate(
90
+ cwd: string,
91
+ submodulePath: string
92
+ ): Promise<string> {
93
+ const proc = Bun.spawn(
94
+ ["git", "submodule", "update", "--remote", "--init", "--", submodulePath],
95
+ {
96
+ cwd,
97
+ stdout: "pipe",
98
+ stderr: "pipe",
99
+ }
100
+ );
101
+ const exitCode = await proc.exited;
102
+ const stdout = await new Response(proc.stdout).text();
103
+ const stderr = await new Response(proc.stderr).text();
104
+
105
+ if (exitCode !== 0) {
106
+ throw new Error(
107
+ stderr.trim() || `git submodule update failed (exit ${exitCode})`
108
+ );
109
+ }
110
+ return stdout.trim();
111
+ }
112
+
113
+ /**
114
+ * Run `git rm --cached` to remove a file from git tracking while keeping it
115
+ * on disk.
116
+ */
117
+ export async function gitUntrackFile(
118
+ dir: string,
119
+ filename: string
120
+ ): Promise<void> {
121
+ await new Promise<void>((resolve, reject) => {
122
+ const proc = Bun.spawn(["git", "rm", "--cached", filename], {
123
+ cwd: dir,
124
+ stdout: "pipe",
125
+ stderr: "pipe",
126
+ });
127
+ proc.exited
128
+ .then((code: number) => {
129
+ if (code === 0) resolve();
130
+ else reject(new Error(`git rm --cached failed with code ${code}`));
131
+ })
132
+ .catch(reject);
133
+ });
134
+ }
@@ -67,8 +67,6 @@ export const icons = {
67
67
  arrow: "->",
68
68
  spinner: ["-", "\\", "|", "/"],
69
69
  dot: "●",
70
- check: "v",
71
- cross: "x",
72
70
  };
73
71
 
74
72
  // ── Display helpers ───────────────────────────────────────────────