@muthuishere/vsync 0.3.0

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/bin/sync.ts ADDED
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync sync <env> <gh|gcp|all> [--gh-repo=<owner/name>] [--gcp-project=<id>]
3
+ //
4
+ // Reads <vaultFolder>/.env.<env> and pushes each variable to the named
5
+ // secret backend, in parallel (6 workers, 10-min overall timeout).
6
+ //
7
+ // Routing config lives in the per-repo vsync file (cfg.sync.gh.repo /
8
+ // cfg.sync.gcp.project), NOT in the .env file. First run prompts for
9
+ // missing routing and saves it; subsequent runs are zero-prompt.
10
+ //
11
+ // Path-expansion + skip rules in src/envfile.ts:
12
+ // - GCP_SA_KEY_FILE_PATH → GCP_SA_KEY (file content)
13
+ // - SSH_KEY_PATH → SSH_PRIVATE_KEY (file content)
14
+ // - GITHUB_TOKEN, GOOGLE_APPLICATION_CREDENTIALS skipped (local-only)
15
+
16
+ import { join } from "node:path";
17
+ import { parseArgs } from "../src/argv";
18
+ import { parseEnvFile, type SecretTask } from "../src/envfile";
19
+ import { runPool } from "../src/syncpool";
20
+ import { getRepoName, getRepoRoot } from "../src/repo";
21
+ import {
22
+ loadConfigFile,
23
+ saveConfigFile,
24
+ type ConfigFile,
25
+ } from "../src/repoconfig";
26
+ import { resolveVaultFolder } from "../src/envconfig";
27
+ import { askText, isTty } from "../src/prompt";
28
+
29
+ const TARGETS = ["gh", "gcp", "all"] as const;
30
+ type Target = (typeof TARGETS)[number];
31
+
32
+ const WORKERS = 6;
33
+ const TIMEOUT_MS = 10 * 60 * 1000;
34
+
35
+ export async function main(argv: string[]): Promise<void> {
36
+ const { positional, flags } = parseArgs(argv);
37
+ const env = positional[0];
38
+ const target = positional[1] as Target | undefined;
39
+
40
+ if (!env || !target || !TARGETS.includes(target)) {
41
+ console.error("usage: vsync sync <env> <gh|gcp|all>");
42
+ console.error("");
43
+ console.error(" env environment name; reads <vaultFolder>/.env.<env>");
44
+ console.error(" gh push to GitHub repo secrets (env = <env>)");
45
+ console.error(" gcp push to GCP Secret Manager (project from cfg.sync.gcp.project)");
46
+ console.error(" all push to every configured target");
47
+ console.error("");
48
+ console.error("Flags: --gh-repo=<owner/name>, --gcp-project=<id>, --repo=<name>");
49
+ process.exit(1);
50
+ }
51
+
52
+ const repo = await getRepoName({ override: flags.repo });
53
+ const root = await getRepoRoot();
54
+
55
+ const cfg = await loadConfigFile(repo, env);
56
+ if (!cfg) {
57
+ console.error(
58
+ `no config file for ${repo}/${env}. Run 'vsync init ${env}' first.`,
59
+ );
60
+ process.exit(1);
61
+ }
62
+
63
+ const vaultFolder = resolveVaultFolder(cfg, env);
64
+ const envFilePath = join(root, vaultFolder, `.env.${env}`);
65
+
66
+ let parsed;
67
+ try {
68
+ parsed = parseEnvFile(envFilePath);
69
+ } catch (e) {
70
+ console.error((e as Error).message);
71
+ process.exit(1);
72
+ }
73
+
74
+ const { tasks } = parsed;
75
+ if (tasks.length === 0) {
76
+ console.error(`no secrets to sync from ${envFilePath}`);
77
+ process.exit(1);
78
+ }
79
+
80
+ const targets: Array<"gh" | "gcp"> =
81
+ target === "all" ? ["gh", "gcp"] : [target];
82
+
83
+ // Resolve + persist routing for every target we're about to run.
84
+ let cfgMutated = false;
85
+ const ghRepo = targets.includes("gh")
86
+ ? await resolveGhRepo(cfg, flags, () => {
87
+ cfgMutated = true;
88
+ })
89
+ : undefined;
90
+ const gcpProject = targets.includes("gcp")
91
+ ? await resolveGcpProject(cfg, flags, () => {
92
+ cfgMutated = true;
93
+ })
94
+ : undefined;
95
+
96
+ if (cfgMutated) {
97
+ await saveConfigFile(repo, env, cfg);
98
+ }
99
+
100
+ let totalOk = 0;
101
+ const totalFailed: string[] = [];
102
+
103
+ for (const t of targets) {
104
+ const start = Date.now();
105
+ let result;
106
+ if (t === "gh") {
107
+ await ensureBinary("gh");
108
+ console.log(
109
+ `\nSyncing ${tasks.length} secrets to GitHub: repo=${ghRepo}, environment=${env}`,
110
+ );
111
+ result = await runPool(tasks, WORKERS, TIMEOUT_MS, (task, signal) =>
112
+ setGhSecret(task, ghRepo!, env, signal),
113
+ );
114
+ } else {
115
+ await ensureBinary("gcloud");
116
+ console.log(
117
+ `\nSyncing ${tasks.length} secrets to GCP Secret Manager: project=${gcpProject}`,
118
+ );
119
+ result = await runPool(tasks, WORKERS, TIMEOUT_MS, (task, signal) =>
120
+ setGcpSecret(task, gcpProject!, signal),
121
+ );
122
+ }
123
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
124
+ if (result.failed.length > 0) {
125
+ console.log(
126
+ ` ${t}: ${result.ok} ok, ${result.failed.length} failed (${result.failed.join(", ")}) in ${elapsed}s`,
127
+ );
128
+ } else {
129
+ console.log(` ${t}: all ${result.ok} synced in ${elapsed}s`);
130
+ }
131
+ totalOk += result.ok;
132
+ totalFailed.push(...result.failed.map((k) => `${t}:${k}`));
133
+ }
134
+
135
+ if (totalFailed.length > 0) {
136
+ console.log(`\nDone — ${totalOk} ok, ${totalFailed.length} failed.`);
137
+ process.exit(1);
138
+ }
139
+ console.log(`\n✅ All ${totalOk} secrets synced across ${targets.length} target(s).`);
140
+ }
141
+
142
+ async function resolveGhRepo(
143
+ cfg: ConfigFile,
144
+ flags: Record<string, string>,
145
+ markMutated: () => void,
146
+ ): Promise<string> {
147
+ if (flags["gh-repo"]) {
148
+ setSync(cfg, "gh", { repo: flags["gh-repo"] }, markMutated);
149
+ return flags["gh-repo"];
150
+ }
151
+ if (cfg.sync?.gh?.repo) return cfg.sync.gh.repo;
152
+ if (!isTty()) {
153
+ throw new Error(
154
+ "sync.gh.repo not configured for this (repo, env) and no --gh-repo flag passed.",
155
+ );
156
+ }
157
+ const value = askText("GitHub repo for sync (owner/name)");
158
+ if (!value) throw new Error("aborted (empty gh repo)");
159
+ setSync(cfg, "gh", { repo: value }, markMutated);
160
+ return value;
161
+ }
162
+
163
+ async function resolveGcpProject(
164
+ cfg: ConfigFile,
165
+ flags: Record<string, string>,
166
+ markMutated: () => void,
167
+ ): Promise<string> {
168
+ if (flags["gcp-project"]) {
169
+ setSync(cfg, "gcp", { project: flags["gcp-project"] }, markMutated);
170
+ return flags["gcp-project"];
171
+ }
172
+ if (cfg.sync?.gcp?.project) return cfg.sync.gcp.project;
173
+ if (!isTty()) {
174
+ throw new Error(
175
+ "sync.gcp.project not configured for this (repo, env) and no --gcp-project flag passed.",
176
+ );
177
+ }
178
+ const value = askText("GCP project ID for sync");
179
+ if (!value) throw new Error("aborted (empty gcp project)");
180
+ setSync(cfg, "gcp", { project: value }, markMutated);
181
+ return value;
182
+ }
183
+
184
+ function setSync(
185
+ cfg: ConfigFile,
186
+ target: "gh" | "gcp",
187
+ block: ConfigFile["sync"] extends infer S ? S extends undefined ? never : NonNullable<S>[typeof target] : never,
188
+ markMutated: () => void,
189
+ ): void {
190
+ cfg.sync = cfg.sync ?? {};
191
+ // @ts-expect-error structural assignment narrowed by target
192
+ cfg.sync[target] = block;
193
+ markMutated();
194
+ }
195
+
196
+ async function setGhSecret(
197
+ t: SecretTask,
198
+ repo: string,
199
+ environment: string,
200
+ signal: AbortSignal,
201
+ ): Promise<void> {
202
+ console.log(`Setting secret: ${t.key}`);
203
+ const proc = Bun.spawn({
204
+ cmd: ["gh", "secret", "set", t.key, "--env", environment, "--repo", repo],
205
+ stdin: new TextEncoder().encode(t.value),
206
+ stdout: "pipe",
207
+ stderr: "pipe",
208
+ signal,
209
+ });
210
+ const code = await proc.exited;
211
+ if (code !== 0) {
212
+ const stderr = (await new Response(proc.stderr).text()).trim();
213
+ throw new Error(`gh secret set: ${stderr || `exit ${code}`}`);
214
+ }
215
+ console.log(`✓ ${t.key}`);
216
+ }
217
+
218
+ async function setGcpSecret(
219
+ t: SecretTask,
220
+ project: string,
221
+ signal: AbortSignal,
222
+ ): Promise<void> {
223
+ console.log(`Setting secret: ${t.key}`);
224
+
225
+ const exists = await secretExists(t.key, project, signal);
226
+
227
+ const cmd = exists
228
+ ? ["gcloud", "secrets", "versions", "add", t.key, "--data-file=-", `--project=${project}`]
229
+ : [
230
+ "gcloud", "secrets", "create", t.key,
231
+ "--replication-policy=automatic",
232
+ "--data-file=-",
233
+ `--project=${project}`,
234
+ ];
235
+
236
+ const proc = Bun.spawn({
237
+ cmd,
238
+ stdin: new TextEncoder().encode(t.value),
239
+ stdout: "pipe",
240
+ stderr: "pipe",
241
+ signal,
242
+ });
243
+ const code = await proc.exited;
244
+ if (code !== 0) {
245
+ const stderr = (await new Response(proc.stderr).text()).trim();
246
+ throw new Error(`${cmd[1]} ${cmd[2]}: ${stderr || `exit ${code}`}`);
247
+ }
248
+ console.log(`✓ ${t.key}`);
249
+ }
250
+
251
+ async function secretExists(
252
+ name: string,
253
+ project: string,
254
+ signal: AbortSignal,
255
+ ): Promise<boolean> {
256
+ const proc = Bun.spawn({
257
+ cmd: ["gcloud", "secrets", "describe", name, `--project=${project}`],
258
+ stdout: "pipe",
259
+ stderr: "pipe",
260
+ signal,
261
+ });
262
+ return (await proc.exited) === 0;
263
+ }
264
+
265
+ async function ensureBinary(name: string): Promise<void> {
266
+ const proc = Bun.spawn({
267
+ cmd: ["which", name],
268
+ stdout: "pipe",
269
+ stderr: "pipe",
270
+ });
271
+ if ((await proc.exited) !== 0) {
272
+ console.error(`${name} not found on PATH — install it before running vsync sync.`);
273
+ process.exit(1);
274
+ }
275
+ }
276
+
277
+ if (import.meta.main) {
278
+ await main(process.argv.slice(2));
279
+ }
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync versions <env> [--repo=<name>]
3
+ //
4
+ // Lists s3://<bucket>/<env>/versions/ — one line per <ts>.enc with size
5
+ // and age, with a `* latest` marker on the version <env>/latest currently
6
+ // points at. Read-only; doesn't decrypt anything (so no keychain key is
7
+ // needed — just the per-repo file with S3 creds).
8
+
9
+ import { parseArgs } from "../src/argv";
10
+ import { getRepoName } from "../src/repo";
11
+ import { loadConfigFile, configFilePath } from "../src/repoconfig";
12
+ import { makeClient } from "../src/s3";
13
+
14
+ export async function main(argv: string[]): Promise<void> {
15
+ const { positional, flags } = parseArgs(argv);
16
+ const env = positional[0];
17
+ if (!env) {
18
+ console.error("usage: vsync versions <env> [--repo=<name>]");
19
+ process.exit(1);
20
+ }
21
+ const repo = await getRepoName({ override: flags.repo });
22
+
23
+ const cfg = await loadConfigFile(repo, env);
24
+ if (!cfg) {
25
+ console.error(
26
+ `no config file for ${repo}/${env} at ${configFilePath(repo, env)}.\n` +
27
+ `Run 'vsync init ${env}' first, or 'vsync import ${env} <share-file>' if a teammate sent you one.`,
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ const client = makeClient(cfg.s3);
33
+ const prefix = `${repo}/${env.toLowerCase()}/versions/`;
34
+
35
+ let listing;
36
+ try {
37
+ listing = await client.list({ prefix });
38
+ } catch (e) {
39
+ console.error(`failed to list s3://${cfg.s3.bucket}/${prefix}: ${(e as Error).message}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ // Read the latest pointer in parallel with the listing — empty/missing
44
+ // is OK (means no successful push yet).
45
+ let latestTs = "";
46
+ try {
47
+ latestTs = (await client.file(`${repo}/${env.toLowerCase()}/latest`).text()).trim();
48
+ } catch {
49
+ // No pointer yet.
50
+ }
51
+
52
+ const objects = (listing?.contents ?? [])
53
+ .filter((o: any) => typeof o?.key === "string" && o.key.endsWith(".enc"))
54
+ .map((o: any) => {
55
+ const fname = o.key.slice(prefix.length); // <ts>.enc
56
+ const ts = fname.replace(/\.enc$/, "");
57
+ return {
58
+ ts,
59
+ key: o.key as string,
60
+ size: typeof o.size === "number" ? o.size : 0,
61
+ lastModified:
62
+ o.lastModified instanceof Date
63
+ ? o.lastModified
64
+ : o.lastModified
65
+ ? new Date(o.lastModified)
66
+ : null,
67
+ };
68
+ })
69
+ .sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0)); // newest first
70
+
71
+ if (objects.length === 0) {
72
+ console.log(`(no versions yet at s3://${cfg.s3.bucket}/${prefix})`);
73
+ return;
74
+ }
75
+
76
+ console.log(`s3://${cfg.s3.bucket}/${prefix} (${objects.length} version${objects.length === 1 ? "" : "s"})`);
77
+ for (const o of objects) {
78
+ const marker = o.ts === latestTs ? " *" : " ";
79
+ const size = formatSize(o.size);
80
+ const age = o.lastModified ? formatAge(o.lastModified) : "?";
81
+ console.log(`${marker} ${o.ts} ${size.padStart(8)} ${age}`);
82
+ }
83
+ if (latestTs && !objects.some((o) => o.ts === latestTs)) {
84
+ console.log(`\n⚠ pointer claims ${latestTs} but no matching version object found.`);
85
+ }
86
+ }
87
+
88
+ function formatSize(bytes: number): string {
89
+ if (bytes < 1024) return `${bytes} B`;
90
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
91
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
92
+ }
93
+
94
+ function formatAge(d: Date): string {
95
+ const ms = Date.now() - d.getTime();
96
+ const sec = Math.max(0, Math.floor(ms / 1000));
97
+ if (sec < 60) return `${sec}s ago`;
98
+ const min = Math.floor(sec / 60);
99
+ if (min < 60) return `${min}m ago`;
100
+ const hr = Math.floor(min / 60);
101
+ if (hr < 24) return `${hr}h ago`;
102
+ const day = Math.floor(hr / 24);
103
+ return `${day}d ago`;
104
+ }
105
+
106
+ if (import.meta.main) {
107
+ await main(process.argv.slice(2));
108
+ }
package/bin/vsync.ts ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bun
2
+ // Single CLI entry point. Dispatches to the per-subcommand modules.
3
+ //
4
+ // All subcommands accept (and many require):
5
+ // <env> positional — lowercase: dev, local, production, …
6
+ // --repo=<name> override the auto-detected repo name
7
+ // (env SECRETS_SYNC_REPO → package.json::name → git basename → cwd)
8
+ // --interactive force interactive prompts even when flags provided
9
+ //
10
+ // Designed for `bunx @muthuishere/vsync <subcommand> ...`.
11
+
12
+ const SUBCOMMANDS = [
13
+ "init",
14
+ "export",
15
+ "import",
16
+ "push",
17
+ "pull",
18
+ "versions",
19
+ "sync",
20
+ "docs",
21
+ ] as const;
22
+ type Subcommand = (typeof SUBCOMMANDS)[number];
23
+
24
+ function usage(code = 0): never {
25
+ const out = code === 0 ? console.log : console.error;
26
+ out("usage: vsync <subcommand> [args...]");
27
+ out("");
28
+ out("setup");
29
+ out(" init <env> [flags] create per-(repo, env) config + AES key");
30
+ out("");
31
+ out("sharing");
32
+ out(" export <env> [--out=path] write a passphrase-encrypted .share file");
33
+ out(" import <env> <share-file> restore a .share file into local config + keychain");
34
+ out("");
35
+ out("day-to-day");
36
+ out(" push <env> encrypt + upload local vault folder to s3://<bucket>/<repo>/<env>/");
37
+ out(" pull <env> download from s3://<bucket>/<repo>/<env>/ + decrypt + unpack");
38
+ out(" versions <env> list versions on S3 for this (repo, env) (read-only)");
39
+ out("");
40
+ out("external fanout");
41
+ out(" sync <env> <gh|gcp|all> push <vaultFolder>/.env.<env> KVs to GH/GCP secret stores");
42
+ out("");
43
+ out("docs");
44
+ out(" docs print the onboarding reference to stdout");
45
+ out("");
46
+ out("All commands accept --repo=<name> (defaults: $SECRETS_SYNC_REPO →");
47
+ out("package.json::name → git basename → cwd basename) and --interactive.");
48
+ process.exit(code);
49
+ }
50
+
51
+ const subcommand = process.argv[2];
52
+ const subArgv = process.argv.slice(3);
53
+
54
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") usage(0);
55
+
56
+ if (!SUBCOMMANDS.includes(subcommand as Subcommand)) {
57
+ console.error(`unknown subcommand: ${subcommand}`);
58
+ usage(1);
59
+ }
60
+
61
+ switch (subcommand as Subcommand) {
62
+ case "init": {
63
+ const { main } = await import("./init");
64
+ await main(subArgv);
65
+ break;
66
+ }
67
+ case "export": {
68
+ const { main } = await import("./export");
69
+ await main(subArgv);
70
+ break;
71
+ }
72
+ case "import": {
73
+ const { main } = await import("./import");
74
+ await main(subArgv);
75
+ break;
76
+ }
77
+ case "push": {
78
+ const { main } = await import("./push");
79
+ await main(subArgv);
80
+ break;
81
+ }
82
+ case "pull": {
83
+ const { main } = await import("./pull");
84
+ await main(subArgv);
85
+ break;
86
+ }
87
+ case "versions": {
88
+ const { main } = await import("./versions");
89
+ await main(subArgv);
90
+ break;
91
+ }
92
+ case "sync": {
93
+ const { main } = await import("./sync");
94
+ await main(subArgv);
95
+ break;
96
+ }
97
+ case "docs": {
98
+ const { main } = await import("./docs");
99
+ await main(subArgv);
100
+ break;
101
+ }
102
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@muthuishere/vsync",
3
+ "version": "0.3.0",
4
+ "description": "Encrypted secret-sync CLI for small teams. Self-contained per-(repo, env) config + OS keychain key + AES-GCM-on-S3 + share-file onboarding + fanout to GitHub/GCP. Bun-native, run via bunx.",
5
+ "type": "module",
6
+ "bin": {
7
+ "vsync": "./bin/vsync.ts"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "bun test"
16
+ },
17
+ "keywords": [
18
+ "secrets",
19
+ "env",
20
+ "dotenv",
21
+ "vault",
22
+ "s3",
23
+ "keychain",
24
+ "encryption",
25
+ "bun",
26
+ "cli"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/muthuishere/vsync.git"
31
+ },
32
+ "homepage": "https://github.com/muthuishere/vsync#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/muthuishere/vsync/issues"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "engines": {
40
+ "bun": ">=1.2.21"
41
+ },
42
+ "license": "MIT",
43
+ "devDependencies": {
44
+ "@types/bun": "latest"
45
+ }
46
+ }
package/src/archive.ts ADDED
@@ -0,0 +1,89 @@
1
+ // Folder ⇄ zip helpers. Shells out to system `zip` / `unzip`.
2
+
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { mkdirSync } from "node:fs";
6
+
7
+ // Zip a folder's contents into a temp .zip file. Returns the path to the zip.
8
+ // The folder's children are placed at the zip root (preserves subdirectory
9
+ // structure inside the folder, but not the folder name itself).
10
+ export async function zipFolder(folderPath: string): Promise<string> {
11
+ const out = join(
12
+ tmpdir(),
13
+ `pkg-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
14
+ );
15
+ const proc = Bun.spawn(["zip", "-r", "-q", out, "."], {
16
+ cwd: folderPath,
17
+ stderr: "pipe",
18
+ });
19
+ const code = await proc.exited;
20
+ if (code !== 0) {
21
+ const err = await new Response(proc.stderr).text();
22
+ throw new Error(`zip exited ${code}: ${err.trim()}`);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ // Zip a list of paths (files and/or folders) relative to baseDir into one
28
+ // temp zip. Paths are stored at their relative locations, so unzipping back
29
+ // at baseDir restores them exactly. Returns the zip's path.
30
+ export async function zipPaths(
31
+ baseDir: string,
32
+ paths: string[],
33
+ ): Promise<string> {
34
+ if (paths.length === 0) {
35
+ throw new Error("zipPaths: no paths supplied");
36
+ }
37
+ const out = join(
38
+ tmpdir(),
39
+ `bundle-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
40
+ );
41
+ const proc = Bun.spawn(["zip", "-r", "-q", out, ...paths], {
42
+ cwd: baseDir,
43
+ stderr: "pipe",
44
+ });
45
+ const code = await proc.exited;
46
+ if (code !== 0) {
47
+ const err = await new Response(proc.stderr).text();
48
+ throw new Error(`zip exited ${code}: ${err.trim()}`);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Zip a list of paths (files and/or folders) relative to baseDir into a zip
54
+ // at outPath. Paths are stored at their relative locations, so unzipping at
55
+ // baseDir restores them exactly.
56
+ export async function zipPaths(
57
+ baseDir: string,
58
+ paths: string[],
59
+ outPath: string,
60
+ ): Promise<void> {
61
+ if (paths.length === 0) {
62
+ throw new Error("zipPaths: no paths supplied");
63
+ }
64
+ const proc = Bun.spawn(["zip", "-r", "-q", outPath, ...paths], {
65
+ cwd: baseDir,
66
+ stderr: "pipe",
67
+ });
68
+ const code = await proc.exited;
69
+ if (code !== 0) {
70
+ const err = await new Response(proc.stderr).text();
71
+ throw new Error(`zip exited ${code}: ${err.trim()}`);
72
+ }
73
+ }
74
+
75
+ // Extract zipPath into targetFolder, creating it if missing. Overwrites.
76
+ export async function unzipTo(
77
+ zipPath: string,
78
+ targetFolder: string,
79
+ ): Promise<void> {
80
+ mkdirSync(targetFolder, { recursive: true });
81
+ const proc = Bun.spawn(["unzip", "-q", "-o", zipPath, "-d", targetFolder], {
82
+ stderr: "pipe",
83
+ });
84
+ const code = await proc.exited;
85
+ if (code !== 0) {
86
+ const err = await new Response(proc.stderr).text();
87
+ throw new Error(`unzip exited ${code}: ${err.trim()}`);
88
+ }
89
+ }
package/src/argv.ts ADDED
@@ -0,0 +1,34 @@
1
+ // Minimal argv parser: positional args + --key=value (or --key) flags.
2
+ // Stops parsing at `--` (everything after is positional).
3
+
4
+ export type ParsedArgs = {
5
+ positional: string[];
6
+ flags: Record<string, string>;
7
+ };
8
+
9
+ export function parseArgs(argv: string[]): ParsedArgs {
10
+ const positional: string[] = [];
11
+ const flags: Record<string, string> = {};
12
+ let passthrough = false;
13
+ for (const arg of argv) {
14
+ if (passthrough) {
15
+ positional.push(arg);
16
+ continue;
17
+ }
18
+ if (arg === "--") {
19
+ passthrough = true;
20
+ continue;
21
+ }
22
+ if (arg.startsWith("--")) {
23
+ const eq = arg.indexOf("=");
24
+ if (eq === -1) {
25
+ flags[arg.slice(2)] = "true";
26
+ } else {
27
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
28
+ }
29
+ } else {
30
+ positional.push(arg);
31
+ }
32
+ }
33
+ return { positional, flags };
34
+ }