@0xordek/git-me 0.3.0 → 0.4.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.
Files changed (4) hide show
  1. package/README.md +150 -112
  2. package/dist/cli.js +606 -26
  3. package/dist/worker.js +635 -0
  4. package/package.json +28 -23
package/dist/cli.js CHANGED
@@ -3,6 +3,389 @@
3
3
  // src/cli.ts
4
4
  import { pathToFileURL } from "node:url";
5
5
 
6
+ // src/credentials.ts
7
+ import { execFile } from "node:child_process";
8
+ import { platform } from "node:os";
9
+ var SERVICE = "git-me";
10
+ function createCredentialStore() {
11
+ return new SystemCredentialStore();
12
+ }
13
+ var SystemCredentialStore = class {
14
+ async get(key) {
15
+ if (platform() === "darwin") return await macGet(key);
16
+ if (platform() === "linux") return await linuxGet(key);
17
+ return await windowsGet(key);
18
+ }
19
+ async set(key, value) {
20
+ if (!value) throw new Error("credential cannot be empty");
21
+ if (platform() === "darwin") return await macSet(key, value);
22
+ if (platform() === "linux") return await linuxSet(key, value);
23
+ return await windowsSet(key, value);
24
+ }
25
+ async delete(key) {
26
+ if (platform() === "darwin") return await macDelete(key);
27
+ if (platform() === "linux") return await linuxDelete(key);
28
+ return await windowsDelete(key);
29
+ }
30
+ };
31
+ async function macGet(key) {
32
+ const result = await run("security", ["find-generic-password", "-a", SERVICE, "-s", key, "-w"], void 0, true);
33
+ return result?.trim() || null;
34
+ }
35
+ async function macSet(key, value) {
36
+ await run("security", ["add-generic-password", "-U", "-a", SERVICE, "-s", key], value);
37
+ }
38
+ async function macDelete(key) {
39
+ await run("security", ["delete-generic-password", "-a", SERVICE, "-s", key], void 0, true);
40
+ }
41
+ async function linuxGet(key) {
42
+ const result = await run("secret-tool", ["lookup", "service", SERVICE, "profile", key], void 0, true);
43
+ return result?.trim() || null;
44
+ }
45
+ async function linuxSet(key, value) {
46
+ await run("secret-tool", ["store", "--label=git-me credential", "service", SERVICE, "profile", key], value);
47
+ }
48
+ async function linuxDelete(key) {
49
+ await run("secret-tool", ["clear", "service", SERVICE, "profile", key], void 0, true);
50
+ }
51
+ async function windowsGet(key) {
52
+ const script = `${windowsCredentialApi()}
53
+ $ptr = [IntPtr]::Zero
54
+ if ([WinCred]::CredRead($args[0], 1, 0, [ref]$ptr)) {
55
+ try {
56
+ $credential = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][WinCred+CREDENTIAL])
57
+ $bytes = New-Object byte[] $credential.CredentialBlobSize
58
+ [Runtime.InteropServices.Marshal]::Copy($credential.CredentialBlob, $bytes, 0, $bytes.Length)
59
+ [Console]::Out.Write([Text.Encoding]::UTF8.GetString($bytes))
60
+ } finally { [WinCred]::CredFree($ptr) }
61
+ }`;
62
+ const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, key], void 0, true);
63
+ return result?.trim() || null;
64
+ }
65
+ async function windowsSet(key, value) {
66
+ const script = `${windowsCredentialApi()}
67
+ $value = [Console]::In.ReadToEnd()
68
+ $bytes = [Text.Encoding]::UTF8.GetBytes($value)
69
+ $ptr = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
70
+ try {
71
+ [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $ptr, $bytes.Length)
72
+ $credential = New-Object WinCred+CREDENTIAL
73
+ $credential.Type = 1
74
+ $credential.TargetName = $args[0]
75
+ $credential.UserName = 'git-me'
76
+ $credential.CredentialBlobSize = $bytes.Length
77
+ $credential.CredentialBlob = $ptr
78
+ $credential.Persist = 2
79
+ if (-not [WinCred]::CredWrite([ref]$credential, 0)) { throw 'CredWrite failed' }
80
+ } finally { [Runtime.InteropServices.Marshal]::FreeHGlobal($ptr) }`;
81
+ await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, key], value);
82
+ }
83
+ async function windowsDelete(key) {
84
+ const script = `${windowsCredentialApi()}
85
+ [WinCred]::CredDelete($args[0], 1, 0) | Out-Null`;
86
+ await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, key], void 0, true);
87
+ }
88
+ function windowsCredentialApi() {
89
+ return `Add-Type @'
90
+ using System;
91
+ using System.Runtime.InteropServices;
92
+ public static class WinCred {
93
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
94
+ public struct CREDENTIAL {
95
+ public UInt32 Flags; public UInt32 Type; public string TargetName; public string Comment;
96
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
97
+ public UInt32 CredentialBlobSize; public IntPtr CredentialBlob; public UInt32 Persist;
98
+ public UInt32 AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName;
99
+ }
100
+ [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
101
+ public static extern bool CredWrite(ref CREDENTIAL credential, UInt32 flags);
102
+ [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
103
+ public static extern bool CredRead(string target, UInt32 type, UInt32 flags, out IntPtr credential);
104
+ [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
105
+ public static extern bool CredDelete(string target, UInt32 type, UInt32 flags);
106
+ [DllImport("advapi32.dll", EntryPoint = "CredFree")]
107
+ public static extern void CredFree(IntPtr credential);
108
+ }
109
+ '@`;
110
+ }
111
+ function run(command, args, input, ignoreFailure = false) {
112
+ return new Promise((resolve2, reject) => {
113
+ const child = execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => {
114
+ if (error) {
115
+ if (ignoreFailure) return resolve2(null);
116
+ reject(new Error(`credential store unavailable: ${stderr.trim() || error.message}`));
117
+ return;
118
+ }
119
+ resolve2(stdout);
120
+ });
121
+ if (input !== void 0) {
122
+ child.stdin?.write(input);
123
+ child.stdin?.end();
124
+ }
125
+ });
126
+ }
127
+
128
+ // src/deploy.ts
129
+ import { randomBytes } from "node:crypto";
130
+ import { appendFile, mkdtemp, rm, writeFile as writeFile2 } from "node:fs/promises";
131
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
132
+ import { fileURLToPath } from "node:url";
133
+ import { createRequire } from "node:module";
134
+ import { spawn } from "node:child_process";
135
+ import { tmpdir } from "node:os";
136
+
137
+ // src/profile.ts
138
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
139
+ import { homedir, platform as platform2 } from "node:os";
140
+ import { dirname, join } from "node:path";
141
+ function createProfileStore(env = process.env) {
142
+ return new FileProfileStore(profileFilePath(env));
143
+ }
144
+ var FileProfileStore = class {
145
+ constructor(filePath) {
146
+ this.filePath = filePath;
147
+ }
148
+ filePath;
149
+ async get(name) {
150
+ const file = await readProfileFile(this.filePath);
151
+ const profile = file.profiles[name];
152
+ return profile && profile.name === name ? profile : null;
153
+ }
154
+ async save(profile) {
155
+ const file = await readProfileFile(this.filePath);
156
+ file.profiles[profile.name] = profile;
157
+ file.current = profile.name;
158
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 448 });
159
+ const temporaryPath = `${this.filePath}.tmp`;
160
+ await writeFile(temporaryPath, `${JSON.stringify(file, null, 2)}
161
+ `, { mode: 384 });
162
+ await rename(temporaryPath, this.filePath);
163
+ await chmod(this.filePath, 384).catch(() => void 0);
164
+ }
165
+ };
166
+ async function readProfileFile(filePath) {
167
+ try {
168
+ const parsed = JSON.parse(await readFile(filePath, "utf8"));
169
+ if (parsed.version !== 1 || !parsed.profiles || typeof parsed.profiles !== "object") throw new Error(`invalid profile file: ${filePath}`);
170
+ return {
171
+ version: 1,
172
+ current: typeof parsed.current === "string" ? parsed.current : "default",
173
+ profiles: parsed.profiles
174
+ };
175
+ } catch (error) {
176
+ if (error.code === "ENOENT") return { version: 1, current: "default", profiles: {} };
177
+ if (error instanceof SyntaxError) throw new Error(`invalid profile file: ${filePath}`);
178
+ throw error;
179
+ }
180
+ }
181
+ function profileFilePath(env) {
182
+ if (env.GITME_CONFIG_DIR) return join(env.GITME_CONFIG_DIR, "profiles.json");
183
+ if (platform2() === "win32" && env.APPDATA) return join(env.APPDATA, "git-me", "profiles.json");
184
+ if (platform2() === "darwin") return join(homedir(), "Library", "Application Support", "git-me", "profiles.json");
185
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "git-me", "profiles.json");
186
+ }
187
+
188
+ // src/deploy.ts
189
+ var COMPATIBILITY_DATE = "2026-07-07";
190
+ var require2 = createRequire(import.meta.url);
191
+ async function deployWorker(options, deps = {}) {
192
+ const runCommand = deps.runCommand ?? runWrangler;
193
+ const profileStore = deps.profileStore ?? createProfileStore();
194
+ const credentialStore = deps.credentialStore ?? createCredentialStore();
195
+ const createTempDirectory = deps.createTempDirectory ?? (() => mkdtemp(join2(tmpdir(), "git-me-deploy-")));
196
+ const generateSecret = deps.generateSecret ?? (() => randomBytes(32).toString("base64url"));
197
+ const fetchImpl = deps.fetch ?? fetch;
198
+ const workerName = options.workerName || defaultWorkerName();
199
+ const workerNameGenerated = !options.workerName;
200
+ const bucketName = `${workerName}-objects`;
201
+ const kvName = `${workerName}-metadata`;
202
+ const adminSecret = options.adminSecret ?? generateSecret();
203
+ if (await profileStore.get(options.profile)) throw new Error(`profile already exists: ${options.profile}`);
204
+ let storedCredential = false;
205
+ if (!options.adminSecret) {
206
+ try {
207
+ await credentialStore.set(credentialKey(options.profile), adminSecret);
208
+ storedCredential = true;
209
+ } catch (error) {
210
+ const message = error instanceof Error ? error.message : String(error);
211
+ throw new Error(`${message}; re-run with --token-stdin if the OS credential store is unavailable`);
212
+ }
213
+ }
214
+ const tempDirectory = await createTempDirectory();
215
+ const configPath = join2(tempDirectory, "wrangler.toml");
216
+ const workerBundle = deps.workerBundle ?? defaultWorkerBundle();
217
+ let bucketCreated = false;
218
+ let kvCreated = false;
219
+ let workerDeployed = false;
220
+ let kvNamespaceId;
221
+ try {
222
+ await writeFile2(configPath, initialConfig(workerName, workerBundle));
223
+ await runCommand(["login"], { interactive: true });
224
+ const whoami = await runCommand(["whoami"]);
225
+ const accountId = options.accountId ?? accountIdFromOutput(`${whoami.stdout}
226
+ ${whoami.stderr}`);
227
+ if (!accountId) throw new Error("Cloudflare did not report an account ID");
228
+ await writeFile2(configPath, initialConfig(workerName, workerBundle, accountId));
229
+ await runCommand(["r2", "bucket", "create", bucketName, "--config", configPath]);
230
+ bucketCreated = true;
231
+ await appendFile(configPath, r2Binding(bucketName));
232
+ const kvOutput = await runCommand(["kv", "namespace", "create", kvName, "--config", configPath]);
233
+ kvCreated = true;
234
+ kvNamespaceId = kvNamespaceIdFromOutput(`${kvOutput.stdout}
235
+ ${kvOutput.stderr}`);
236
+ if (!kvNamespaceId) throw new Error("Cloudflare did not report a KV namespace ID");
237
+ await appendFile(configPath, kvBinding(kvNamespaceId));
238
+ const deployOutput = await runCommand(["deploy", "--config", configPath, "--no-bundle"]);
239
+ workerDeployed = true;
240
+ const endpoint = endpointFromOutput(`${deployOutput.stdout}
241
+ ${deployOutput.stderr}`);
242
+ if (!endpoint) throw new Error("Cloudflare did not report a Workers URL after deploy");
243
+ await runCommand(["secret", "put", "GITME_AUTH_TOKEN", "--config", configPath], { input: adminSecret });
244
+ await waitForHealth(endpoint, fetchImpl);
245
+ let warning;
246
+ if (options.adminSecret) {
247
+ try {
248
+ await credentialStore.set(credentialKey(options.profile), adminSecret);
249
+ } catch {
250
+ warning = "Admin credential was not saved to the OS credential store; use --token-stdin for user commands.";
251
+ }
252
+ }
253
+ const result = {
254
+ profile: options.profile,
255
+ endpoint,
256
+ workerName,
257
+ accountId,
258
+ bucketName,
259
+ kvNamespaceId,
260
+ warning
261
+ };
262
+ const profile = {
263
+ name: options.profile,
264
+ endpoint,
265
+ workerName,
266
+ accountId: result.accountId,
267
+ bucketName,
268
+ kvNamespaceId,
269
+ createdAt: (deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))()
270
+ };
271
+ await profileStore.save(profile);
272
+ return result;
273
+ } catch (error) {
274
+ if (storedCredential) await credentialStore.delete(credentialKey(options.profile)).catch(() => void 0);
275
+ const cleanup = [];
276
+ if (workerDeployed && workerNameGenerated) await runCommand(["delete", workerName, "--config", configPath, "--force"]).catch(() => cleanup.push(`Worker ${workerName}`));
277
+ else if (workerDeployed) cleanup.push(`Worker ${workerName}`);
278
+ if (kvCreated && kvNamespaceId) await runCommand(["kv", "namespace", "delete", kvNamespaceId, "--config", configPath]).catch(() => cleanup.push(`KV namespace ${kvName}`));
279
+ else if (kvCreated) cleanup.push(`KV namespace ${kvName}`);
280
+ if (bucketCreated) await runCommand(["r2", "bucket", "delete", bucketName, "--config", configPath]).catch(() => cleanup.push(`R2 bucket ${bucketName}`));
281
+ const message = error instanceof Error ? error.message : String(error);
282
+ throw new Error(cleanup.length > 0 ? `${message}. Resources to verify: ${cleanup.join(", ")}` : message);
283
+ } finally {
284
+ await rm(tempDirectory, { recursive: true, force: true }).catch(() => void 0);
285
+ }
286
+ }
287
+ function credentialKey(profile) {
288
+ return `git-me:${profile}:admin`;
289
+ }
290
+ function defaultWorkerName() {
291
+ return `git-me-${randomBytes(4).toString("hex")}`;
292
+ }
293
+ function defaultWorkerBundle() {
294
+ return resolve(dirname2(fileURLToPath(import.meta.url)), "worker.js");
295
+ }
296
+ function initialConfig(workerName, workerBundle, accountId) {
297
+ return [
298
+ `name = ${tomlString(workerName)}`,
299
+ `main = ${tomlString(workerBundle)}`,
300
+ ...accountId ? [`account_id = ${tomlString(accountId)}`] : [],
301
+ `compatibility_date = ${tomlString(COMPATIBILITY_DATE)}`,
302
+ "workers_dev = true",
303
+ "",
304
+ "[[durable_objects.bindings]]",
305
+ 'name = "GITME_AUTH"',
306
+ 'class_name = "AuthUser"',
307
+ "",
308
+ "[[migrations]]",
309
+ 'tag = "v1"',
310
+ 'new_sqlite_classes = ["AuthUser"]',
311
+ ""
312
+ ].join("\n");
313
+ }
314
+ function tomlString(value) {
315
+ return `"${value.replaceAll("\\", "/").replaceAll('"', '\\"')}"`;
316
+ }
317
+ function r2Binding(bucketName) {
318
+ return `
319
+ [[r2_buckets]]
320
+ binding = "GITME_R2"
321
+ bucket_name = ${tomlString(bucketName)}
322
+ `;
323
+ }
324
+ function kvBinding(namespaceId) {
325
+ return `
326
+ [[kv_namespaces]]
327
+ binding = "GITME_KV"
328
+ id = ${tomlString(namespaceId)}
329
+ `;
330
+ }
331
+ function kvNamespaceIdFromOutput(output) {
332
+ return output.match(/\bid\s*=\s*"([0-9a-f]{32})"/i)?.[1];
333
+ }
334
+ function endpointFromOutput(output) {
335
+ const field = output.match(/"(?:url|workers_dev_url)"\s*:\s*"(https:\/\/[^"\\]+)"/i)?.[1];
336
+ const match = field ? [field] : output.match(/https:\/\/[a-z0-9][a-z0-9.-]*\.workers\.dev(?:\/[^\s]*)?/i);
337
+ if (!match) return void 0;
338
+ return match[1] ? match[1].replace(/[),.]+$/, "") : match[0].replace(/[),.]+$/, "");
339
+ }
340
+ function accountIdFromOutput(output) {
341
+ return output.match(/\b[0-9a-f]{32}\b/i)?.[0];
342
+ }
343
+ async function waitForHealth(endpoint, fetchImpl) {
344
+ let lastError = "health check failed";
345
+ for (let attempt = 0; attempt < 6; attempt += 1) {
346
+ try {
347
+ const response = await fetchImpl(new URL("/health", endpoint));
348
+ if (response.ok) {
349
+ const body = await response.json();
350
+ if (body.ok === true) return;
351
+ }
352
+ lastError = `health check returned ${response.status}`;
353
+ } catch (error) {
354
+ lastError = error instanceof Error ? error.message : String(error);
355
+ }
356
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 1e3));
357
+ }
358
+ throw new Error(lastError);
359
+ }
360
+ async function runWrangler(args, options) {
361
+ const executable = process.env.GITME_WRANGLER_BIN || require2.resolve("wrangler");
362
+ return await spawnCommand(process.execPath, [executable, ...args], options);
363
+ }
364
+ async function spawnCommand(command, args, options = {}) {
365
+ return await new Promise((resolvePromise, reject) => {
366
+ const child = spawn(command, args, { stdio: options.interactive ? "inherit" : "pipe" });
367
+ let stdout = "";
368
+ let stderr = "";
369
+ if (!options.interactive) {
370
+ child.stdout?.on("data", (chunk) => {
371
+ stdout += String(chunk);
372
+ });
373
+ child.stderr?.on("data", (chunk) => {
374
+ stderr += String(chunk);
375
+ });
376
+ }
377
+ if (options.input !== void 0) {
378
+ child.stdin?.write(options.input);
379
+ child.stdin?.end();
380
+ }
381
+ child.once("error", reject);
382
+ child.once("close", (code) => {
383
+ if (code === 0) resolvePromise({ stdout, stderr });
384
+ else reject(new Error(stderr.trim() || `Cloudflare command failed with exit code ${code ?? "unknown"}`));
385
+ });
386
+ });
387
+ }
388
+
6
389
  // src/lfs-client.ts
7
390
  var LFS_JSON = "application/vnd.git-lfs+json";
8
391
  var ERROR_SNIPPET_BYTES = 200;
@@ -102,14 +485,14 @@ async function scanPointers(repoPath) {
102
485
  }
103
486
  async function listTrackedFiles(repoPath) {
104
487
  const childProcess = await nodeImport2("node:child_process");
105
- const stdout = await new Promise((resolve, reject) => {
488
+ const stdout = await new Promise((resolve2, reject) => {
106
489
  childProcess.execFile("git", ["-C", repoPath, "ls-files", "-z"], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, (error, output, stderr) => {
107
490
  if (error) {
108
491
  reject(new Error(`${error.message}
109
492
  ${stderr}`));
110
493
  return;
111
494
  }
112
- resolve(output);
495
+ resolve2(output);
113
496
  });
114
497
  });
115
498
  return stdout.split("\0").filter((trackedPath) => trackedPath.length > 0);
@@ -214,10 +597,10 @@ async function sha256File(path) {
214
597
  const [fs, cryptoModule] = await Promise.all([nodeImport3("node:fs"), nodeImport3("node:crypto")]);
215
598
  const hash = cryptoModule.createHash("sha256");
216
599
  const stream = fs.createReadStream(path);
217
- return await new Promise((resolve, reject) => {
600
+ return await new Promise((resolve2, reject) => {
218
601
  stream.on("data", (chunk) => hash.update(chunk));
219
602
  stream.on("error", reject);
220
- stream.on("end", () => resolve(hash.digest("hex")));
603
+ stream.on("end", () => resolve2(hash.digest("hex")));
221
604
  });
222
605
  }
223
606
  async function defaultCreateTempPath() {
@@ -236,14 +619,14 @@ async function setGitConfigValue(repoPath, key, value) {
236
619
  }
237
620
  async function execGit(repoPath, args) {
238
621
  const childProcess = await nodeImport3("node:child_process");
239
- return await new Promise((resolve, reject) => {
622
+ return await new Promise((resolve2, reject) => {
240
623
  childProcess.execFile("git", ["-C", repoPath, ...args], { encoding: "utf8" }, (error, stdout, stderr) => {
241
624
  if (error) {
242
625
  reject(new Error(`${error.message}
243
626
  ${stderr}`));
244
627
  return;
245
628
  }
246
- resolve(stdout);
629
+ resolve2(stdout);
247
630
  });
248
631
  });
249
632
  }
@@ -262,6 +645,7 @@ async function runCli(argv, io = {}) {
262
645
  const [command, ...args] = argv;
263
646
  if (command === "migrate") return await runMigrate(args, { ...io, stdout: out, stderr: err });
264
647
  if (command === "user") return await runUser(args, { ...io, stdout: out, stderr: err });
648
+ if (command === "worker") return await runWorker(args, { ...io, stdout: out, stderr: err });
265
649
  err(`unknown command: ${command}
266
650
 
267
651
  ${topLevelUsage()}`);
@@ -272,16 +656,57 @@ async function runUser(args, io) {
272
656
  io.stdout(userUsage());
273
657
  return 0;
274
658
  }
275
- const parsed = await parseUserArgs(args, io);
659
+ let parsed;
660
+ try {
661
+ parsed = await parseUserArgs(args, io);
662
+ } catch (error) {
663
+ io.stderr(`${error instanceof Error ? error.message : String(error)}
664
+
665
+ ${userUsage()}`);
666
+ return 2;
667
+ }
276
668
  if ("error" in parsed) {
277
669
  io.stderr(`${parsed.error}
278
670
 
279
671
  ${userUsage()}`);
280
672
  return 2;
281
673
  }
674
+ if (parsed.options.action === "delete" && !parsed.options.yes && (io.confirm || !io.userRequest)) {
675
+ const confirmed = await (io.confirm ?? confirmPrompt)(`Delete user "${parsed.options.username}"? [y/N] `);
676
+ if (!confirmed) {
677
+ io.stdout("Cancelled.\n");
678
+ return 0;
679
+ }
680
+ }
282
681
  try {
283
682
  const result = await (io.userRequest ?? requestUser)(parsed.options);
284
- io.stdout(formatUserResult(result));
683
+ io.stdout(formatUserResult(result, parsed.options.json === true));
684
+ return 0;
685
+ } catch (error) {
686
+ io.stderr(`${error instanceof Error ? error.message : String(error)}
687
+ `);
688
+ return 1;
689
+ }
690
+ }
691
+ async function runWorker(args, io) {
692
+ if (args.includes("--help") || args.includes("-h")) {
693
+ io.stdout(workerUsage());
694
+ return 0;
695
+ }
696
+ const parsed = await parseWorkerArgs(args, io);
697
+ if ("error" in parsed) {
698
+ io.stderr(`${parsed.error}
699
+
700
+ ${workerUsage()}`);
701
+ return 2;
702
+ }
703
+ try {
704
+ const result = await (io.workerDeploy ?? deployWorker)(parsed.options);
705
+ io.stdout(`Deployed: ${result.endpoint}
706
+ Profile: ${result.profile}
707
+ LFS URL: ${result.endpoint}
708
+ ${result.warning ? `Warning: ${result.warning}
709
+ ` : ""}`);
285
710
  return 0;
286
711
  } catch (error) {
287
712
  io.stderr(`${error instanceof Error ? error.message : String(error)}
@@ -313,14 +738,22 @@ ${migrateUsage()}`);
313
738
  }
314
739
  async function parseUserArgs(args, io) {
315
740
  const action = args[0];
316
- if (action !== "add" && action !== "delete") return { error: "missing user action: add or delete" };
741
+ if (action !== "add" && action !== "delete" && action !== "list") return { error: "missing user action: add, list, or delete" };
317
742
  let targetUrl = "";
318
743
  let token;
319
744
  let username = "";
320
745
  let password;
321
- let access = "";
746
+ let access = action === "add" ? "read" : "";
747
+ let profileName = "default";
748
+ let yes = false;
749
+ let jsonOutput = false;
322
750
  for (let index = 1; index < args.length; index += 1) {
323
751
  const arg = args[index];
752
+ if (!arg.startsWith("--")) {
753
+ if (username) return { error: "duplicate username" };
754
+ username = arg;
755
+ continue;
756
+ }
324
757
  if (arg === "--token-stdin") {
325
758
  if (token) return { error: "duplicate token source" };
326
759
  token = { stdin: true };
@@ -331,6 +764,14 @@ async function parseUserArgs(args, io) {
331
764
  password = { stdin: true };
332
765
  continue;
333
766
  }
767
+ if (arg === "--yes") {
768
+ yes = true;
769
+ continue;
770
+ }
771
+ if (arg === "--json") {
772
+ jsonOutput = true;
773
+ continue;
774
+ }
334
775
  const value = args[index + 1];
335
776
  if (!value || value.startsWith("--")) return { error: `missing value for ${arg}` };
336
777
  index += 1;
@@ -346,24 +787,41 @@ async function parseUserArgs(args, io) {
346
787
  const source = envSecret(value);
347
788
  if (!source) return { error: `invalid environment variable name: ${value}` };
348
789
  password = source;
349
- } else if (arg === "--access") access = value;
790
+ } else if (arg === "--access") {
791
+ access = value;
792
+ } else if (arg === "--profile") profileName = value;
350
793
  else return { error: `unknown option: ${arg}` };
351
794
  }
352
- if (!targetUrl) return { error: "missing required option: --target" };
353
- if (!token) return { error: "missing required option: --token-env or --token-stdin" };
354
- if (!username) return { error: "missing required option: --username" };
355
- if (action === "add" && !password) return { error: "missing required option: --password-env or --password-stdin" };
356
- if (action === "add" && access !== "read" && access !== "write") return { error: "missing required option: --access read|write" };
795
+ const explicitTarget = Boolean(targetUrl);
796
+ const profileStore = io.profileStore ?? createProfileStore(io.env ?? process.env);
797
+ const profile = explicitTarget ? null : await profileStore.get(profileName);
798
+ if (!targetUrl) targetUrl = profile?.endpoint || "";
799
+ if (!token && profile) {
800
+ const credentialStore = io.credentialStore ?? createCredentialStore();
801
+ const storedToken = await credentialStore.get(credentialKey(profileName));
802
+ if (storedToken) token = { value: storedToken };
803
+ }
804
+ if (!targetUrl) return { error: "missing profile; deploy a worker first or provide --target" };
805
+ if (!token) return { error: "missing admin credential; provide --token-env or --token-stdin" };
806
+ if (action !== "list" && !username) return { error: "missing username" };
807
+ if (action === "add" && access !== "read" && access !== "write") return { error: "invalid --access read|write" };
357
808
  if (isStdinSecret(token) && password && isStdinSecret(password)) return { error: "only one secret may use standard input" };
358
809
  try {
810
+ let resolvedPassword;
811
+ if (action === "add") {
812
+ resolvedPassword = password ? await readSecret(password, io) : await (io.readPassword ?? readPassword)("Password: ");
813
+ if (!resolvedPassword) return { error: "password is empty" };
814
+ }
359
815
  return {
360
816
  options: {
361
817
  action,
362
818
  targetUrl,
363
819
  token: await readSecret(token, io),
364
- username,
365
- password: password ? await readSecret(password, io) : void 0,
366
- access: access || void 0
820
+ username: username || void 0,
821
+ password: resolvedPassword,
822
+ access: access || void 0,
823
+ ...yes ? { yes: true } : {},
824
+ ...jsonOutput ? { json: true } : {}
367
825
  }
368
826
  };
369
827
  } catch (error) {
@@ -371,9 +829,10 @@ async function parseUserArgs(args, io) {
371
829
  }
372
830
  }
373
831
  async function requestUser(options) {
374
- const url = new URL(`/admin/users/${encodeURIComponent(options.username)}`, options.targetUrl.endsWith("/") ? options.targetUrl : `${options.targetUrl}/`);
832
+ const path = options.action === "list" ? "/admin/users" : `/admin/users/${encodeURIComponent(options.username || "")}`;
833
+ const url = new URL(path, options.targetUrl.endsWith("/") ? options.targetUrl : `${options.targetUrl}/`);
375
834
  const res = await fetch(url, {
376
- method: options.action === "add" ? "PUT" : "DELETE",
835
+ method: options.action === "add" ? "PUT" : options.action === "delete" ? "DELETE" : "GET",
377
836
  headers: {
378
837
  Authorization: `Bearer ${options.token}`,
379
838
  ...options.action === "add" ? { "Content-Type": "application/json" } : {}
@@ -382,7 +841,45 @@ async function requestUser(options) {
382
841
  });
383
842
  const text = await res.text();
384
843
  if (!res.ok) throw new Error(text.trim() || `request failed: ${res.status}`);
385
- return JSON.parse(text);
844
+ const result = JSON.parse(text);
845
+ if (options.action === "list" && !Array.isArray(result.users)) throw new Error("invalid user list response");
846
+ return result;
847
+ }
848
+ async function parseWorkerArgs(args, io) {
849
+ if (args[0] !== "deploy") return { error: "missing worker action: deploy" };
850
+ let profile = "default";
851
+ let workerName;
852
+ let accountId;
853
+ let adminToken;
854
+ for (let index = 1; index < args.length; index += 1) {
855
+ const arg = args[index];
856
+ if (arg === "--token-stdin") {
857
+ if (adminToken) return { error: "duplicate admin credential source" };
858
+ adminToken = { stdin: true };
859
+ continue;
860
+ }
861
+ const value = args[index + 1];
862
+ if (!value || value.startsWith("--")) return { error: `missing value for ${arg}` };
863
+ index += 1;
864
+ if (arg === "--profile") profile = value;
865
+ else if (arg === "--name") workerName = value;
866
+ else if (arg === "--account-id") {
867
+ if (!/^[0-9a-f]{32}$/i.test(value)) return { error: "invalid --account-id" };
868
+ accountId = value;
869
+ } else if (arg === "--token-env") {
870
+ if (adminToken) return { error: "duplicate admin credential source" };
871
+ const source = envSecret(value);
872
+ if (!source) return { error: `invalid environment variable name: ${value}` };
873
+ adminToken = source;
874
+ } else return { error: `unknown option: ${arg}` };
875
+ }
876
+ let adminSecret;
877
+ try {
878
+ if (adminToken) adminSecret = await readSecret(adminToken, io);
879
+ } catch (error) {
880
+ return { error: error instanceof Error ? error.message : String(error) };
881
+ }
882
+ return { action: "deploy", options: { profile, workerName, accountId, adminSecret } };
386
883
  }
387
884
  async function parseMigrateArgs(args, defaultRepoPath, io) {
388
885
  const sourceHeaderSources = [];
@@ -449,6 +946,7 @@ function isStdinSecret(source) {
449
946
  return "stdin" in source;
450
947
  }
451
948
  async function readSecret(source, io) {
949
+ if ("value" in source) return source.value;
452
950
  if ("env" in source) {
453
951
  const value2 = (io.env ?? process.env)[source.env];
454
952
  if (!value2) throw new Error(`missing environment variable: ${source.env}`);
@@ -475,8 +973,9 @@ function topLevelUsage() {
475
973
  return `Usage: git-me <command>
476
974
 
477
975
  Commands:
478
- migrate migrate Git LFS objects to git-me
976
+ worker deploy a zero-config git-me Worker
479
977
  user manage git-me LFS users
978
+ migrate migrate Git LFS objects to git-me
480
979
  `;
481
980
  }
482
981
  function migrateUsage() {
@@ -492,12 +991,30 @@ Options:
492
991
  `;
493
992
  }
494
993
  function userUsage() {
495
- return `Usage: git-me user <add|delete> --target <url> (--token-env <name>|--token-stdin) --username <name> [options]
994
+ return `Usage: git-me user <add|delete> [username] [options]
995
+ git-me user list [options]
496
996
 
497
997
  Options:
998
+ --profile <name> local profile (default: default)
999
+ --target <url> Worker URL (default: saved profile)
1000
+ --token-env <name> env var containing admin token
1001
+ --token-stdin read admin token from standard input
498
1002
  --password-env <name> env var containing password for add
499
1003
  --password-stdin read password for add from standard input
500
- --access <read|write> access for add
1004
+ --access <read|write> access for add (default: read)
1005
+ --yes skip delete confirmation
1006
+ --json output user list as JSON
1007
+ `;
1008
+ }
1009
+ function workerUsage() {
1010
+ return `Usage: git-me worker deploy [options]
1011
+
1012
+ Options:
1013
+ --profile <name> local profile name (default: default)
1014
+ --name <name> Worker name (default: generated)
1015
+ --account-id <id> Cloudflare account ID
1016
+ --token-stdin use an admin secret from standard input
1017
+ --token-env <name> use an admin secret from an environment variable
501
1018
  `;
502
1019
  }
503
1020
  function formatResult(result) {
@@ -506,12 +1023,75 @@ function formatResult(result) {
506
1023
  return `${lines.join("\n")}
507
1024
  `;
508
1025
  }
509
- function formatUserResult(result) {
1026
+ function formatUserResult(result, jsonOutput = false) {
1027
+ if (result.users) {
1028
+ if (jsonOutput) return `${JSON.stringify(result.users)}
1029
+ `;
1030
+ if (result.users.length === 0) return "USERNAME ACCESS\n";
1031
+ const width = Math.max("USERNAME".length, ...result.users.map((user) => user.username.length));
1032
+ return [`${"USERNAME".padEnd(width)} ACCESS`, ...result.users.map((user) => `${user.username.padEnd(width)} ${user.access}`)].join("\n") + "\n";
1033
+ }
510
1034
  if (result.deleted) return `username=${result.username} deleted=true
511
1035
  `;
512
1036
  return `username=${result.username} access=${result.access}
513
1037
  `;
514
1038
  }
1039
+ async function confirmPrompt(prompt) {
1040
+ const value = await ioReadLine(prompt);
1041
+ return /^y(?:es)?$/i.test(value.trim());
1042
+ }
1043
+ async function ioReadLine(prompt) {
1044
+ const readline = await import("node:readline/promises");
1045
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1046
+ try {
1047
+ return await rl.question(prompt);
1048
+ } finally {
1049
+ rl.close();
1050
+ }
1051
+ }
1052
+ async function readPassword(prompt) {
1053
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("password prompt unavailable; use --password-stdin");
1054
+ const stdin = process.stdin;
1055
+ const stdout = process.stdout;
1056
+ stdout.write(prompt);
1057
+ stdin.setRawMode(true);
1058
+ stdin.resume();
1059
+ return await new Promise((resolve2, reject) => {
1060
+ const bytes = [];
1061
+ const onData = (chunk) => {
1062
+ for (const byte of chunk) {
1063
+ if (byte === 3) {
1064
+ cleanup();
1065
+ reject(new Error("password prompt cancelled"));
1066
+ return;
1067
+ }
1068
+ if (byte === 13 || byte === 10) {
1069
+ cleanup();
1070
+ stdout.write("\n");
1071
+ resolve2(Buffer.from(bytes).toString("utf8"));
1072
+ return;
1073
+ }
1074
+ if (byte === 127 || byte === 8) {
1075
+ removeLastUtf8CodePoint(bytes);
1076
+ continue;
1077
+ }
1078
+ bytes.push(byte);
1079
+ }
1080
+ };
1081
+ const cleanup = () => {
1082
+ stdin.off("data", onData);
1083
+ stdin.setRawMode(false);
1084
+ stdin.pause();
1085
+ };
1086
+ stdin.on("data", onData);
1087
+ });
1088
+ }
1089
+ function removeLastUtf8CodePoint(bytes) {
1090
+ if (bytes.length === 0) return;
1091
+ let index = bytes.length - 1;
1092
+ while (index > 0 && (bytes[index] & 192) === 128) index -= 1;
1093
+ bytes.splice(index);
1094
+ }
515
1095
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
516
1096
  runCli(process.argv.slice(2)).then((code) => {
517
1097
  process.exitCode = code;