@jango-blockchained/hoox-cli 0.5.1 → 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.
- package/package.json +1 -1
- package/src/commands/check/check-command.ts +8 -65
- package/src/commands/check/prerequisites-command.ts +7 -6
- package/src/commands/clone/clone-command.test.ts +8 -10
- package/src/commands/config/config-command.test.ts +4 -4
- package/src/commands/config/config-command.ts +10 -11
- package/src/commands/config/env-command.test.ts +2 -2
- package/src/commands/config/env-command.ts +40 -43
- package/src/commands/config/kv-command.ts +60 -60
- package/src/commands/dashboard/dashboard-command.ts +32 -30
- package/src/commands/db/db-command.ts +79 -80
- package/src/commands/deploy/deploy-command.test.ts +54 -34
- package/src/commands/deploy/deploy-command.ts +3 -84
- package/src/commands/dev/dev-command.test.ts +84 -62
- package/src/commands/disclaimer/disclaimer-command.ts +21 -0
- package/src/commands/disclaimer/index.ts +1 -0
- package/src/commands/init/init-command.test.ts +69 -91
- package/src/commands/init/init-command.ts +19 -15
- package/src/commands/logs/logs-command.test.ts +0 -1
- package/src/commands/monitor/monitor-command.test.ts +37 -29
- package/src/commands/repair/repair-command.test.ts +60 -41
- package/src/commands/repair/repair-service.ts +26 -0
- package/src/commands/schema/index.ts +1 -0
- package/src/commands/schema/schema-command.ts +137 -0
- package/src/commands/test/test-command.test.ts +2 -3
- package/src/commands/update/update-command.ts +6 -65
- package/src/commands/waf/waf-command.ts +56 -59
- package/src/index.ts +5 -13
- package/src/services/config/config-service.ts +1 -6
- package/src/services/db/db-service.test.ts +13 -1
- package/src/services/env/env-service.test.ts +41 -18
- package/src/services/env/env-service.ts +45 -59
- package/src/services/kv/kv-sync-service.ts +14 -5
- package/src/services/schema/index.ts +1 -0
- package/src/services/schema/schema-service.ts +99 -0
- package/src/services/secrets/secrets-service.test.ts +42 -35
- package/src/services/secrets/types.ts +1 -1
- package/src/ui/menu.ts +1 -1
- package/src/utils/error-handler.ts +62 -0
- package/src/utils/errors.ts +1 -0
- package/src/utils/git.ts +134 -0
- 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: ["
|
|
36
|
+
secrets: ["TG_BOT_TOKEN_BINDING"],
|
|
37
37
|
},
|
|
38
38
|
"trade-worker": {
|
|
39
39
|
enabled: true,
|
|
40
40
|
path: "workers/trade-worker",
|
|
41
|
-
secrets: [
|
|
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
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
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
|
-
"
|
|
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(["
|
|
150
|
+
expect(all["telegram-worker"]).toEqual(["TG_BOT_TOKEN_BINDING"]);
|
|
147
151
|
expect(all["trade-worker"]).toEqual([
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
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
|
-
"
|
|
179
|
-
"
|
|
180
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
230
|
-
expect(result.missing).toContain("
|
|
231
|
-
expect(result.missing).toContain("
|
|
232
|
-
expect(result.missing).not.toContain("
|
|
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\
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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(["
|
|
361
|
-
expect(called).toEqual([["
|
|
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
|
-
"
|
|
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
|
|
390
|
-
expect(errMsg).toContain("
|
|
391
|
-
expect(errMsg).toContain("
|
|
392
|
-
expect(called).toEqual([["
|
|
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("
|
|
416
|
-
expect(errMsg).toContain("
|
|
417
|
-
expect(errMsg).toContain("
|
|
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(
|
|
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
|
-
"
|
|
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. "
|
|
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,
|
|
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
|
+
}
|
package/src/utils/errors.ts
CHANGED
package/src/utils/git.ts
ADDED
|
@@ -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
|
+
}
|