@0xordek/git-me 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +248 -0
  3. package/dist/cli.js +526 -0
  4. package/package.json +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 git-me contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,248 @@
1
+ # git-me
2
+
3
+ A small self-hosted Git LFS utility for Cloudflare Workers, R2, Durable Objects, and KV-backed legacy-user migration.
4
+
5
+ Built as a TypeScript Cloudflare Worker.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 22+
10
+ - Wrangler CLI or project-local `wrangler`
11
+ - Cloudflare account with Workers, R2, KV, and Durable Objects enabled
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ git clone git@github.com:0xordek/git-me.git
17
+ cd git-me
18
+ npm ci
19
+ npm run check
20
+ ```
21
+
22
+ ## Cloudflare Setup
23
+
24
+ Create storage resources:
25
+
26
+ ```bash
27
+ wrangler r2 bucket create git-me-objects
28
+ wrangler kv namespace create git-me-metadata
29
+ ```
30
+
31
+ Wrangler prints the KV namespace id. Create private deployment config, then replace its placeholder ID:
32
+
33
+ ```bash
34
+ cp wrangler.example.toml wrangler.local.toml
35
+ ```
36
+
37
+ `wrangler.local.toml` is ignored by Git. Before a real deploy, replace its placeholder KV namespace ID with your namespace ID. `npm run deploy` creates the `AuthUser` Durable Object migration.
38
+
39
+ Set the admin token as a secret:
40
+
41
+ ```bash
42
+ wrangler secret put GITME_AUTH_TOKEN
43
+ ```
44
+
45
+ ## Transfer Modes
46
+
47
+ - `proxy`: default mode. The Worker handles Git LFS upload and download bytes through the `PUT /objects/{oid}` and `GET /objects/{oid}` endpoints.
48
+ - `direct`: opt-in download acceleration. Uploads still use the Worker so every object gets SHA-256 verification; downloads receive short-lived signed R2 `GET` URLs.
49
+
50
+ Leave `GITME_TRANSFER_MODE` unset for `proxy`, or set it to `direct` to opt in to signed R2 downloads. Give direct mode a bucket-scoped, read-only R2 S3 API token.
51
+
52
+ ## Configuration
53
+
54
+ | Name | Required | Purpose |
55
+ |------|----------|---------|
56
+ | `GITME_AUTH_TOKEN` | Yes | Admin bearer token for user management and emergency LFS access |
57
+ | `GITME_TRANSFER_MODE` | No | `proxy` by default; set `direct` for signed R2 downloads |
58
+ | `GITME_SIGNED_URL_TTL_SECONDS` | No | Signed URL TTL for `direct` mode; defaults to `900` |
59
+ | `GITME_R2_ACCOUNT_ID` | Direct mode | Cloudflare account id for R2 S3-compatible signing |
60
+ | `GITME_R2_ACCESS_KEY_ID` | Direct mode | Read-only R2 API access key id |
61
+ | `GITME_R2_SECRET_ACCESS_KEY` | Direct mode | Read-only R2 API secret access key |
62
+ | `GITME_R2_BUCKET_NAME` | Direct mode | R2 bucket used in signed download URLs |
63
+
64
+ Use Wrangler secrets for sensitive values:
65
+
66
+ ```bash
67
+ wrangler secret put GITME_R2_SECRET_ACCESS_KEY
68
+ ```
69
+
70
+ Deploy:
71
+
72
+ ```bash
73
+ npm run deploy:dry
74
+ npm run deploy
75
+ ```
76
+
77
+ ## Git LFS Client Setup
78
+
79
+ Create a user as the deploy admin. Pass secrets through environment variables or standard input, never command arguments:
80
+
81
+ ```bash
82
+ read -rsp 'Admin token: ' GITME_ADMIN_TOKEN; echo
83
+ export GITME_ADMIN_TOKEN
84
+ read -rsp 'LFS password: ' GITME_LFS_PASSWORD; echo
85
+ printf '%s' "$GITME_LFS_PASSWORD" | npx @0xordek/git-me user add \
86
+ --target https://your-worker.workers.dev \
87
+ --token-env GITME_ADMIN_TOKEN \
88
+ --username alice \
89
+ --password-stdin \
90
+ --access write
91
+ unset GITME_ADMIN_TOKEN GITME_LFS_PASSWORD
92
+ ```
93
+
94
+ Use `"read"` for pull-only users and `"write"` for pull/push users. Delete access with:
95
+
96
+ ```bash
97
+ read -rsp 'Admin token: ' GITME_ADMIN_TOKEN; echo
98
+ export GITME_ADMIN_TOKEN
99
+ npx @0xordek/git-me user delete \
100
+ --target https://your-worker.workers.dev \
101
+ --token-env GITME_ADMIN_TOKEN \
102
+ --username alice
103
+ unset GITME_ADMIN_TOKEN
104
+ ```
105
+
106
+ Configure a repo:
107
+
108
+ ```bash
109
+ git lfs track "*.psd" "*.zip" "*.bin"
110
+ git add .gitattributes
111
+
112
+ git config lfs.url https://your-worker.workers.dev
113
+ git config lfs.http.https://your-worker.workers.dev.locksverify false
114
+ ```
115
+
116
+ For shared repositories, commit `.lfsconfig` so fresh clones use the Worker too:
117
+
118
+ ```bash
119
+ git config -f .lfsconfig lfs.url https://your-worker.workers.dev
120
+ git add .lfsconfig
121
+ git commit -m "chore: configure git-me lfs"
122
+ ```
123
+
124
+ Then use `git push` and `git pull` as normal. On first LFS access, Git asks for username and password. Git Credential Manager stores it on the machine, so deleting and cloning the repo again usually does not ask while the worker host stays the same.
125
+
126
+ The admin bearer token also works for LFS as an emergency write credential, but normal users should use Basic auth users created through `/admin/users/{username}`.
127
+
128
+ If you do not want the CLI, the same operations are available through `PUT` and `DELETE /admin/users/{username}`.
129
+
130
+ ## Migrating Existing LFS Objects
131
+
132
+ Use the migration CLI from a local repository to copy objects from the current LFS server into `git-me`:
133
+
134
+ ```bash
135
+ read -rsp 'Target token: ' GITME_TARGET_TOKEN; echo
136
+ export GITME_TARGET_TOKEN
137
+ npx @0xordek/git-me migrate --target https://your-worker.workers.dev --token-env GITME_TARGET_TOKEN --dry-run
138
+ unset GITME_TARGET_TOKEN
139
+ ```
140
+
141
+ Run `--dry-run` first. It scans local Git LFS pointer files, deduplicates object IDs, and reports `scanned`, `unique`, `migrated`, `skipped`, and `failed` without transferring object bytes or writing Git config.
142
+
143
+ For a GitHub source, pass the source LFS URL explicitly when it is not already in `git config lfs.url`:
144
+
145
+ ```bash
146
+ read -rsp 'Target token: ' GITME_TARGET_TOKEN; echo
147
+ export GITME_TARGET_TOKEN
148
+ npx @0xordek/git-me migrate \
149
+ --source-url https://github.com/OWNER/REPO.git/info/lfs \
150
+ --target https://your-worker.workers.dev \
151
+ --token-env GITME_TARGET_TOKEN \
152
+ --dry-run
153
+ unset GITME_TARGET_TOKEN
154
+ ```
155
+
156
+ For another Git LFS server, use its Batch API base URL as `--source-url`:
157
+
158
+ ```bash
159
+ read -rsp 'Target token: ' GITME_TARGET_TOKEN; echo
160
+ export GITME_TARGET_TOKEN
161
+ npx @0xordek/git-me migrate \
162
+ --source-url https://source.example.com/repo.git/info/lfs \
163
+ --target https://your-worker.workers.dev \
164
+ --token-env GITME_TARGET_TOKEN \
165
+ --dry-run
166
+ unset GITME_TARGET_TOKEN
167
+ ```
168
+
169
+ Private source repositories usually require an extra source header. Repeat `--source-header-env` for every environment variable containing a `name: value` header:
170
+
171
+ ```bash
172
+ read -rsp 'Target token: ' GITME_TARGET_TOKEN; echo
173
+ export GITME_TARGET_TOKEN
174
+ read -rsp 'Source header: ' GITME_SOURCE_AUTH; echo
175
+ export GITME_SOURCE_AUTH
176
+ npx @0xordek/git-me migrate \
177
+ --source-url https://github.com/OWNER/PRIVATE-REPO.git/info/lfs \
178
+ --source-header-env GITME_SOURCE_AUTH \
179
+ --target https://your-worker.workers.dev \
180
+ --token-env GITME_TARGET_TOKEN \
181
+ --dry-run
182
+ unset GITME_TARGET_TOKEN GITME_SOURCE_AUTH
183
+ ```
184
+
185
+ After a successful real migration, add `--write-config` to update the repository's `lfs.url` to the target:
186
+
187
+ ```bash
188
+ read -rsp 'Target token: ' GITME_TARGET_TOKEN; echo
189
+ export GITME_TARGET_TOKEN
190
+ npx @0xordek/git-me migrate \
191
+ --target https://your-worker.workers.dev \
192
+ --token-env GITME_TARGET_TOKEN \
193
+ --write-config
194
+ unset GITME_TARGET_TOKEN
195
+ ```
196
+
197
+ Safety model: the CLI uses the generic Git LFS Batch API for source downloads and target uploads. Object bytes are streamed through temporary files named `git-me-migrate-*` in the OS temp directory, not buffered in memory, and each downloaded file must match the pointer SHA-256 OID before upload. Temporary files are removed after each object attempt, and `--write-config` only runs when the migration has no failures.
198
+
199
+ ## API Endpoints
200
+
201
+ | Method | Path | Purpose |
202
+ |--------|------|---------|
203
+ | POST | `/objects/batch` | Git LFS Batch API |
204
+ | PUT | `/objects/{oid}` | Upload object bytes |
205
+ | GET | `/objects/{oid}` | Download object bytes |
206
+ | PUT | `/admin/users/{username}` | Create or update LFS user |
207
+ | DELETE | `/admin/users/{username}` | Delete LFS user |
208
+ | GET | `/health` | Configuration health check |
209
+
210
+ Batch requests and error responses use `application/vnd.git-lfs+json`.
211
+ `GET /health` returns `application/json`.
212
+
213
+ ## Storage Layout
214
+
215
+ - R2 object key: `objects/<oid>`
216
+ - Temporary proxy-upload key prefix: `objects/.tmp/`
217
+ - Durable Object: one `AuthUser` instance per normalized username
218
+ - KV user key: `user:<username>` only during legacy SHA-256 credential upgrade
219
+
220
+ ## Development
221
+
222
+ ```bash
223
+ npm ci
224
+ npm run check
225
+ wrangler dev --config wrangler.local.toml
226
+ ```
227
+
228
+ ## Security
229
+
230
+ Do not commit auth tokens or R2 API secrets. Use `wrangler secret put GITME_AUTH_TOKEN` and `wrangler secret put GITME_R2_SECRET_ACCESS_KEY`.
231
+
232
+ `proxy` uploads stream through the Worker and must match their Git LFS SHA-256 OID before becoming readable. `direct` mode only signs R2 downloads; it never signs uploads. Use a bucket-scoped, read-only R2 credential for direct mode.
233
+
234
+ Existing deployments upgraded from a release that signed direct uploads must first set `GITME_TRANSFER_MODE=proxy`, audit every `objects/<oid>` object against its SHA-256 filename, and quarantine mismatches. Rotate the old R2 S3 API token before enabling direct downloads again. Objects without the `v0.3` Worker verification marker always fall back to proxy downloads, even when `direct` mode is enabled.
235
+
236
+ New passwords use salted PBKDF2-SHA-256 records in Durable Objects. Existing KV SHA-256 records upgrade after one successful login. Deleted users leave a Durable Object tombstone, so stale KV reads cannot restore access. Authentication locks one client source for one username for one minute after five failed attempts in one minute.
237
+
238
+ `GET /health` checks configuration only. It does not prove R2, KV, or Durable Object availability. Failed requests emit a request ID in `X-Request-Id`; logs never include credentials or request bodies.
239
+
240
+ Report vulnerabilities through GitHub Security Advisories. See `SECURITY.md`.
241
+
242
+ ## Contributing
243
+
244
+ Small fixes and focused issues are welcome. See `CONTRIBUTING.md`.
245
+
246
+ ## License
247
+
248
+ MIT — see `LICENSE`.
package/dist/cli.js ADDED
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ // src/lfs-client.ts
7
+ var LFS_JSON = "application/vnd.git-lfs+json";
8
+ var ERROR_SNIPPET_BYTES = 200;
9
+ var nodeImport = (specifier) => import(
10
+ /* @vite-ignore */
11
+ specifier
12
+ );
13
+ function mergeActionHeaders(base, actionHeaders) {
14
+ return { ...base, ...actionHeaders ?? {} };
15
+ }
16
+ var LfsClient = class {
17
+ baseUrl;
18
+ baseOrigin;
19
+ headers;
20
+ constructor(options) {
21
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
22
+ this.baseOrigin = new URL(this.baseUrl).origin;
23
+ this.headers = options.headers ?? {};
24
+ }
25
+ async batch(operation, objects) {
26
+ const response = await fetch(`${this.baseUrl}/objects/batch`, {
27
+ method: "POST",
28
+ headers: { ...this.headers, "Content-Type": LFS_JSON, Accept: LFS_JSON },
29
+ body: JSON.stringify({ operation, transfers: ["basic"], objects })
30
+ });
31
+ await throwIfFailed(response, "LFS batch");
32
+ return await response.json();
33
+ }
34
+ async downloadToFile(href, filePath, headers) {
35
+ const response = await fetch(href, { method: "GET", headers: this.actionHeaders(href, headers) });
36
+ await throwIfFailed(response, "LFS download");
37
+ if (!response.body) throw new Error("LFS download failed: response body missing");
38
+ const [fs, stream, streamPromises] = await Promise.all([
39
+ nodeImport("node:fs"),
40
+ nodeImport("node:stream"),
41
+ nodeImport("node:stream/promises")
42
+ ]);
43
+ await streamPromises.pipeline(stream.Readable.fromWeb(response.body), fs.createWriteStream(filePath));
44
+ }
45
+ async uploadFromFile(href, filePath, headers) {
46
+ const [fs, fsPromises] = await Promise.all([nodeImport("node:fs"), nodeImport("node:fs/promises")]);
47
+ const stat = await fsPromises.stat(filePath);
48
+ const init = {
49
+ method: "PUT",
50
+ headers: { ...this.actionHeaders(href, headers), "Content-Length": String(stat.size) },
51
+ body: fs.createReadStream(filePath),
52
+ duplex: "half"
53
+ };
54
+ const response = await fetch(href, init);
55
+ await throwIfFailed(response, "LFS upload");
56
+ }
57
+ actionHeaders(href, headers) {
58
+ const actionOrigin = new URL(href, this.baseUrl).origin;
59
+ if (actionOrigin !== this.baseOrigin) return headers ?? {};
60
+ return mergeActionHeaders(this.headers, headers);
61
+ }
62
+ };
63
+ async function throwIfFailed(response, label) {
64
+ if (response.ok) return;
65
+ const body = await response.text().catch(() => "");
66
+ const snippet = body.slice(0, ERROR_SNIPPET_BYTES);
67
+ throw new Error(`${label} failed with status ${response.status}: ${snippet}`);
68
+ }
69
+
70
+ // src/pointers.ts
71
+ var VERSION_LINE = "version https://git-lfs.github.com/spec/v1";
72
+ var OID_LINE = /^oid sha256:([0-9a-fA-F]{64})$/;
73
+ var SIZE_LINE = /^size ([0-9]+)$/;
74
+ var MAX_POINTER_BYTES = 1024;
75
+ var nodeImport2 = (specifier) => import(
76
+ /* @vite-ignore */
77
+ specifier
78
+ );
79
+ function parsePointer(text, path) {
80
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
81
+ if (lines.at(-1) === "") lines.pop();
82
+ if (lines.length !== 3 || lines[0] !== VERSION_LINE) return null;
83
+ const oid = OID_LINE.exec(lines[1])?.[1];
84
+ if (!oid) return null;
85
+ const sizeText = SIZE_LINE.exec(lines[2])?.[1];
86
+ if (!sizeText) return null;
87
+ const size = Number(sizeText);
88
+ if (!Number.isSafeInteger(size)) return null;
89
+ return { path, oid: oid.toLowerCase(), size };
90
+ }
91
+ async function scanPointers(repoPath) {
92
+ const trackedPaths = await listTrackedFiles(repoPath);
93
+ const [fs, path] = await Promise.all([nodeImport2("node:fs/promises"), nodeImport2("node:path")]);
94
+ const pointers = [];
95
+ for (const trackedPath of trackedPaths) {
96
+ const text = await readSmallUtf8File(fs, path.join(repoPath, trackedPath));
97
+ if (text === null) continue;
98
+ const pointer = parsePointer(text, trackedPath);
99
+ if (pointer) pointers.push(pointer);
100
+ }
101
+ return pointers;
102
+ }
103
+ async function listTrackedFiles(repoPath) {
104
+ const childProcess = await nodeImport2("node:child_process");
105
+ const stdout = await new Promise((resolve, reject) => {
106
+ childProcess.execFile("git", ["-C", repoPath, "ls-files", "-z"], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, (error, output, stderr) => {
107
+ if (error) {
108
+ reject(new Error(`${error.message}
109
+ ${stderr}`));
110
+ return;
111
+ }
112
+ resolve(output);
113
+ });
114
+ });
115
+ return stdout.split("\0").filter((trackedPath) => trackedPath.length > 0);
116
+ }
117
+ async function readSmallUtf8File(fs, path) {
118
+ let file = null;
119
+ try {
120
+ file = await fs.open(path, "r");
121
+ const bytes = new Uint8Array(MAX_POINTER_BYTES);
122
+ const { bytesRead } = await file.read(bytes, 0, bytes.byteLength, 0);
123
+ const chunk = bytes.subarray(0, bytesRead);
124
+ if (chunk.includes(0)) return null;
125
+ return new TextDecoder("utf-8", { fatal: true }).decode(chunk);
126
+ } catch {
127
+ return null;
128
+ } finally {
129
+ await file?.close().catch(() => void 0);
130
+ }
131
+ }
132
+
133
+ // src/migrate.ts
134
+ var nodeImport3 = (specifier) => import(
135
+ /* @vite-ignore */
136
+ specifier
137
+ );
138
+ async function migrate(options, deps = {}) {
139
+ const scan = deps.scanPointers ?? scanPointers;
140
+ const createClient = deps.createClient ?? ((clientOptions) => new LfsClient(clientOptions));
141
+ const createTempPath = deps.createTempPath ?? defaultCreateTempPath;
142
+ const removeFile = deps.removeFile ?? defaultRemoveFile;
143
+ const getGitConfig = deps.getGitConfig ?? getGitConfigValue;
144
+ const setGitConfig = deps.setGitConfig ?? setGitConfigValue;
145
+ const pointers = await scan(options.repoPath);
146
+ const uniqueObjects = uniqueLfsObjects(pointers);
147
+ const result = {
148
+ scanned: pointers.length,
149
+ unique: uniqueObjects.length,
150
+ migrated: 0,
151
+ skipped: pointers.length - uniqueObjects.length,
152
+ failed: []
153
+ };
154
+ if (options.dryRun) return result;
155
+ const sourceUrl = options.sourceUrl ?? await getGitConfig(options.repoPath, "lfs.url");
156
+ const source = createClient({ baseUrl: sourceUrl.trim(), headers: options.sourceHeaders });
157
+ const target = createClient({ baseUrl: options.targetUrl, headers: { Authorization: `Bearer ${options.targetToken}` } });
158
+ await runPool(uniqueObjects, options.concurrency, async (object) => {
159
+ const tempPath = await createTempPath();
160
+ try {
161
+ const download = await batchAction(source, "download", object, "download");
162
+ if (!download) throw new Error("download action missing");
163
+ await source.downloadToFile(download.href, tempPath, download.header);
164
+ if (await sha256File(tempPath) !== object.oid) throw new Error("hash mismatch");
165
+ const upload = await batchAction(target, "upload", object, "upload");
166
+ if (!upload) {
167
+ result.skipped += 1;
168
+ return;
169
+ }
170
+ await target.uploadFromFile(upload.href, tempPath, upload.header);
171
+ const targetDownload = await batchAction(target, "download", object, "download");
172
+ if (!targetDownload) throw new Error("target download action missing");
173
+ result.migrated += 1;
174
+ } catch (error) {
175
+ result.failed.push({ oid: object.oid, reason: errorMessage(error) });
176
+ } finally {
177
+ await removeFile(tempPath).catch(() => void 0);
178
+ }
179
+ });
180
+ if (options.writeConfig && result.failed.length === 0) {
181
+ await setGitConfig(options.repoPath, "lfs.url", options.targetUrl);
182
+ }
183
+ return result;
184
+ }
185
+ function uniqueLfsObjects(pointers) {
186
+ const seen = /* @__PURE__ */ new Set();
187
+ const objects = [];
188
+ for (const pointer of pointers) {
189
+ if (seen.has(pointer.oid)) continue;
190
+ seen.add(pointer.oid);
191
+ objects.push({ oid: pointer.oid, size: pointer.size });
192
+ }
193
+ return objects;
194
+ }
195
+ async function batchAction(client, operation, object, action) {
196
+ const response = await client.batch(operation, [object]);
197
+ const responseObject = response.objects.find((item) => item.oid === object.oid);
198
+ if (!responseObject) throw new Error(`${operation} batch missing object`);
199
+ if (responseObject.error) throw new Error(responseObject.error.message);
200
+ return responseObject.actions?.[action] ?? null;
201
+ }
202
+ async function runPool(items, concurrency, worker) {
203
+ let index = 0;
204
+ const workers = Array.from({ length: Math.min(Math.max(1, concurrency), items.length) }, async () => {
205
+ while (index < items.length) {
206
+ const item = items[index];
207
+ index += 1;
208
+ await worker(item);
209
+ }
210
+ });
211
+ await Promise.all(workers);
212
+ }
213
+ async function sha256File(path) {
214
+ const [fs, cryptoModule] = await Promise.all([nodeImport3("node:fs"), nodeImport3("node:crypto")]);
215
+ const hash = cryptoModule.createHash("sha256");
216
+ const stream = fs.createReadStream(path);
217
+ return await new Promise((resolve, reject) => {
218
+ stream.on("data", (chunk) => hash.update(chunk));
219
+ stream.on("error", reject);
220
+ stream.on("end", () => resolve(hash.digest("hex")));
221
+ });
222
+ }
223
+ async function defaultCreateTempPath() {
224
+ const [os, path] = await Promise.all([nodeImport3("node:os"), nodeImport3("node:path")]);
225
+ return path.join(os.tmpdir(), `git-me-migrate-${crypto.randomUUID()}`);
226
+ }
227
+ async function defaultRemoveFile(path) {
228
+ const fs = await nodeImport3("node:fs/promises");
229
+ await fs.rm(path, { force: true });
230
+ }
231
+ async function getGitConfigValue(repoPath, key) {
232
+ return (await execGit(repoPath, ["config", "--get", key])).trim();
233
+ }
234
+ async function setGitConfigValue(repoPath, key, value) {
235
+ await execGit(repoPath, ["config", key, value]);
236
+ }
237
+ async function execGit(repoPath, args) {
238
+ const childProcess = await nodeImport3("node:child_process");
239
+ return await new Promise((resolve, reject) => {
240
+ childProcess.execFile("git", ["-C", repoPath, ...args], { encoding: "utf8" }, (error, stdout, stderr) => {
241
+ if (error) {
242
+ reject(new Error(`${error.message}
243
+ ${stderr}`));
244
+ return;
245
+ }
246
+ resolve(stdout);
247
+ });
248
+ });
249
+ }
250
+ function errorMessage(error) {
251
+ return error instanceof Error ? error.message : String(error);
252
+ }
253
+
254
+ // src/cli.ts
255
+ async function runCli(argv, io = {}) {
256
+ const out = io.stdout ?? ((text) => process.stdout.write(text));
257
+ const err = io.stderr ?? ((text) => process.stderr.write(text));
258
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
259
+ out(topLevelUsage());
260
+ return 0;
261
+ }
262
+ const [command, ...args] = argv;
263
+ if (command === "migrate") return await runMigrate(args, { ...io, stdout: out, stderr: err });
264
+ if (command === "user") return await runUser(args, { ...io, stdout: out, stderr: err });
265
+ err(`unknown command: ${command}
266
+
267
+ ${topLevelUsage()}`);
268
+ return 2;
269
+ }
270
+ async function runUser(args, io) {
271
+ if (args.includes("--help") || args.includes("-h")) {
272
+ io.stdout(userUsage());
273
+ return 0;
274
+ }
275
+ const parsed = await parseUserArgs(args, io);
276
+ if ("error" in parsed) {
277
+ io.stderr(`${parsed.error}
278
+
279
+ ${userUsage()}`);
280
+ return 2;
281
+ }
282
+ try {
283
+ const result = await (io.userRequest ?? requestUser)(parsed.options);
284
+ io.stdout(formatUserResult(result));
285
+ return 0;
286
+ } catch (error) {
287
+ io.stderr(`${error instanceof Error ? error.message : String(error)}
288
+ `);
289
+ return 1;
290
+ }
291
+ }
292
+ async function runMigrate(args, io) {
293
+ if (args.includes("--help") || args.includes("-h")) {
294
+ io.stdout(migrateUsage());
295
+ return 0;
296
+ }
297
+ const parsed = await parseMigrateArgs(args, io.cwd?.() ?? process.cwd(), io);
298
+ if ("error" in parsed) {
299
+ io.stderr(`${parsed.error}
300
+
301
+ ${migrateUsage()}`);
302
+ return 2;
303
+ }
304
+ try {
305
+ const result = await (io.migrate ?? migrate)(parsed.options);
306
+ io.stdout(formatResult(result));
307
+ return result.failed.length === 0 ? 0 : 1;
308
+ } catch (error) {
309
+ io.stderr(`${error instanceof Error ? error.message : String(error)}
310
+ `);
311
+ return 1;
312
+ }
313
+ }
314
+ async function parseUserArgs(args, io) {
315
+ const action = args[0];
316
+ if (action !== "add" && action !== "delete") return { error: "missing user action: add or delete" };
317
+ let targetUrl = "";
318
+ let token;
319
+ let username = "";
320
+ let password;
321
+ let access = "";
322
+ for (let index = 1; index < args.length; index += 1) {
323
+ const arg = args[index];
324
+ if (arg === "--token-stdin") {
325
+ if (token) return { error: "duplicate token source" };
326
+ token = { stdin: true };
327
+ continue;
328
+ }
329
+ if (arg === "--password-stdin") {
330
+ if (password) return { error: "duplicate password source" };
331
+ password = { stdin: true };
332
+ continue;
333
+ }
334
+ const value = args[index + 1];
335
+ if (!value || value.startsWith("--")) return { error: `missing value for ${arg}` };
336
+ index += 1;
337
+ if (arg === "--target") targetUrl = value;
338
+ else if (arg === "--token-env") {
339
+ if (token) return { error: "duplicate token source" };
340
+ const source = envSecret(value);
341
+ if (!source) return { error: `invalid environment variable name: ${value}` };
342
+ token = source;
343
+ } else if (arg === "--username") username = value;
344
+ else if (arg === "--password-env") {
345
+ if (password) return { error: "duplicate password source" };
346
+ const source = envSecret(value);
347
+ if (!source) return { error: `invalid environment variable name: ${value}` };
348
+ password = source;
349
+ } else if (arg === "--access") access = value;
350
+ else return { error: `unknown option: ${arg}` };
351
+ }
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" };
357
+ if (isStdinSecret(token) && password && isStdinSecret(password)) return { error: "only one secret may use standard input" };
358
+ try {
359
+ return {
360
+ options: {
361
+ action,
362
+ targetUrl,
363
+ token: await readSecret(token, io),
364
+ username,
365
+ password: password ? await readSecret(password, io) : void 0,
366
+ access: access || void 0
367
+ }
368
+ };
369
+ } catch (error) {
370
+ return { error: error instanceof Error ? error.message : String(error) };
371
+ }
372
+ }
373
+ async function requestUser(options) {
374
+ const url = new URL(`/admin/users/${encodeURIComponent(options.username)}`, options.targetUrl.endsWith("/") ? options.targetUrl : `${options.targetUrl}/`);
375
+ const res = await fetch(url, {
376
+ method: options.action === "add" ? "PUT" : "DELETE",
377
+ headers: {
378
+ Authorization: `Bearer ${options.token}`,
379
+ ...options.action === "add" ? { "Content-Type": "application/json" } : {}
380
+ },
381
+ body: options.action === "add" ? JSON.stringify({ password: options.password, access: options.access }) : void 0
382
+ });
383
+ const text = await res.text();
384
+ if (!res.ok) throw new Error(text.trim() || `request failed: ${res.status}`);
385
+ return JSON.parse(text);
386
+ }
387
+ async function parseMigrateArgs(args, defaultRepoPath, io) {
388
+ const sourceHeaderSources = [];
389
+ let repoPath = defaultRepoPath;
390
+ let sourceUrl;
391
+ let targetUrl;
392
+ let targetToken;
393
+ let concurrency = 4;
394
+ let dryRun = false;
395
+ let writeConfig = false;
396
+ for (let index = 0; index < args.length; index += 1) {
397
+ const arg = args[index];
398
+ if (arg === "--dry-run") {
399
+ dryRun = true;
400
+ continue;
401
+ }
402
+ if (arg === "--write-config") {
403
+ writeConfig = true;
404
+ continue;
405
+ }
406
+ if (arg === "--token-stdin") {
407
+ if (targetToken) return { error: "duplicate token source" };
408
+ targetToken = { stdin: true };
409
+ continue;
410
+ }
411
+ const value = args[index + 1];
412
+ if (!value || value.startsWith("--")) return { error: `missing value for ${arg}` };
413
+ index += 1;
414
+ if (arg === "--repo") repoPath = value;
415
+ else if (arg === "--source-url") sourceUrl = value;
416
+ else if (arg === "--target") targetUrl = value;
417
+ else if (arg === "--token-env") {
418
+ if (targetToken) return { error: "duplicate token source" };
419
+ const source = envSecret(value);
420
+ if (!source) return { error: `invalid environment variable name: ${value}` };
421
+ targetToken = source;
422
+ } else if (arg === "--concurrency") {
423
+ concurrency = Number(value);
424
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) return { error: "invalid --concurrency" };
425
+ } else if (arg === "--source-header-env") {
426
+ const source = envSecret(value);
427
+ if (!source) return { error: `invalid environment variable name: ${value}` };
428
+ sourceHeaderSources.push(source);
429
+ } else return { error: `unknown option: ${arg}` };
430
+ }
431
+ if (!targetUrl) return { error: "missing required option: --target" };
432
+ if (!targetToken) return { error: "missing required option: --token-env or --token-stdin" };
433
+ try {
434
+ const sourceHeaders = {};
435
+ for (const source of sourceHeaderSources) {
436
+ const header = parseHeader(await readSecret(source, io));
437
+ if (!header) return { error: "invalid --source-header-env value" };
438
+ sourceHeaders[header.name] = header.value;
439
+ }
440
+ return { options: { repoPath, sourceUrl, sourceHeaders, targetUrl, targetToken: await readSecret(targetToken, io), concurrency, dryRun, writeConfig } };
441
+ } catch (error) {
442
+ return { error: error instanceof Error ? error.message : String(error) };
443
+ }
444
+ }
445
+ function envSecret(name) {
446
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? { env: name } : null;
447
+ }
448
+ function isStdinSecret(source) {
449
+ return "stdin" in source;
450
+ }
451
+ async function readSecret(source, io) {
452
+ if ("env" in source) {
453
+ const value2 = (io.env ?? process.env)[source.env];
454
+ if (!value2) throw new Error(`missing environment variable: ${source.env}`);
455
+ return value2;
456
+ }
457
+ const value = await (io.readStdin ?? readStdin)();
458
+ const secret = value.replace(/\r?\n$/, "");
459
+ if (!secret) throw new Error("standard input secret is empty");
460
+ return secret;
461
+ }
462
+ async function readStdin() {
463
+ let value = "";
464
+ for await (const chunk of process.stdin) value += String(chunk);
465
+ return value;
466
+ }
467
+ function parseHeader(input) {
468
+ const index = input.indexOf(":");
469
+ if (index < 1) return null;
470
+ const name = input.slice(0, index).trim();
471
+ const value = input.slice(index + 1).trim();
472
+ return name && value ? { name, value } : null;
473
+ }
474
+ function topLevelUsage() {
475
+ return `Usage: git-me <command>
476
+
477
+ Commands:
478
+ migrate migrate Git LFS objects to git-me
479
+ user manage git-me LFS users
480
+ `;
481
+ }
482
+ function migrateUsage() {
483
+ return `Usage: git-me migrate --target <url> (--token-env <name>|--token-stdin) [options]
484
+
485
+ Options:
486
+ --repo <path> repository path (default: current directory)
487
+ --source-url <url> source LFS URL (default: git config lfs.url)
488
+ --source-header-env <name> env var containing name: value, repeatable
489
+ --concurrency <number> concurrent transfers, 1..16 (default: 4)
490
+ --dry-run scan without transferring objects
491
+ --write-config update lfs.url after successful migration
492
+ `;
493
+ }
494
+ function userUsage() {
495
+ return `Usage: git-me user <add|delete> --target <url> (--token-env <name>|--token-stdin) --username <name> [options]
496
+
497
+ Options:
498
+ --password-env <name> env var containing password for add
499
+ --password-stdin read password for add from standard input
500
+ --access <read|write> access for add
501
+ `;
502
+ }
503
+ function formatResult(result) {
504
+ const lines = [`scanned=${result.scanned} unique=${result.unique} migrated=${result.migrated} skipped=${result.skipped} failed=${result.failed.length}`];
505
+ for (const failure of result.failed) lines.push(`${failure.oid}: ${failure.reason}`);
506
+ return `${lines.join("\n")}
507
+ `;
508
+ }
509
+ function formatUserResult(result) {
510
+ if (result.deleted) return `username=${result.username} deleted=true
511
+ `;
512
+ return `username=${result.username} access=${result.access}
513
+ `;
514
+ }
515
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
516
+ runCli(process.argv.slice(2)).then((code) => {
517
+ process.exitCode = code;
518
+ }, (error) => {
519
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
520
+ `);
521
+ process.exitCode = 1;
522
+ });
523
+ }
524
+ export {
525
+ runCli
526
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@0xordek/git-me",
3
+ "version": "0.3.0",
4
+ "description": "Self-hosted Git LFS utility for Cloudflare Workers, R2, and Durable Objects.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/0xordek/git-me.git"
9
+ },
10
+ "homepage": "https://github.com/0xordek/git-me#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/0xordek/git-me/issues"
13
+ },
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "type": "module",
21
+ "bin": {
22
+ "git-me": "dist/cli.js"
23
+ },
24
+ "files": [
25
+ "dist/cli.js"
26
+ ],
27
+ "scripts": {
28
+ "build": "wrangler deploy --dry-run --config wrangler.example.toml --outdir dist --metafile dist/bundle-meta.json",
29
+ "test": "vitest run",
30
+ "typecheck": "tsc --noEmit",
31
+ "build:cli": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\"",
32
+ "check": "npm test && npm run typecheck && npm run build:cli && git diff --exit-code -- dist/cli.js && npm pack --dry-run && npm run deploy:dry",
33
+ "deploy:dry": "wrangler deploy --dry-run --config wrangler.example.toml",
34
+ "deploy": "wrangler deploy --config wrangler.local.toml",
35
+ "version:upload": "wrangler versions upload --config wrangler.local.toml",
36
+ "version:deploy": "wrangler versions deploy --config wrangler.local.toml"
37
+ },
38
+ "devDependencies": {
39
+ "@cloudflare/workers-types": "^4.20260702.1",
40
+ "@types/node": "^26.1.0",
41
+ "esbuild": "^0.28.1",
42
+ "typescript": "^6.0.3",
43
+ "vitest": "^3.2.7",
44
+ "wrangler": "^4.107.1"
45
+ }
46
+ }