@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.
- package/LICENSE +21 -0
- package/README.md +248 -0
- package/dist/cli.js +526 -0
- 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
|
+
}
|