git0 0.3.66 → 0.3.68

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.
@@ -0,0 +1,540 @@
1
+ import http from 'http';
2
+ import crypto from 'crypto';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { getCurrentPlatform } from '../platform.js';
6
+ import type { PlatformInfo } from '../types.js';
7
+ import { normalizeRepoSlug, pickAsset, type PickedAsset, type RawRelease } from './assets.js';
8
+ import {
9
+ downloadWithProgress,
10
+ git0Home,
11
+ installAsset,
12
+ launchApp,
13
+ type InstallResult,
14
+ type OnProgress,
15
+ } from './installer.js';
16
+
17
+ /** The port the service listens on unless told otherwise. Spells nothing; picked to be unclaimed. */
18
+ export const DEFAULT_PORT = 47100;
19
+
20
+ /** Every state a job passes through. The last three are terminal. */
21
+ export type JobStatus =
22
+ | 'awaiting-approval'
23
+ | 'resolving'
24
+ | 'downloading'
25
+ | 'installing'
26
+ | 'launching'
27
+ | 'done'
28
+ | 'error'
29
+ | 'denied';
30
+
31
+ const TERMINAL: JobStatus[] = ['done', 'error', 'denied'];
32
+
33
+ /** One install request, as the status endpoint reports it. */
34
+ export interface Job {
35
+ id: string;
36
+ repo: string;
37
+ status: JobStatus;
38
+ /** Overall progress, 0–1, safe to drive a progress bar with directly. */
39
+ progress: number;
40
+ receivedBytes: number;
41
+ totalBytes: number;
42
+ asset: string | null;
43
+ tag: string | null;
44
+ installedPath: string | null;
45
+ launched: boolean;
46
+ message: string;
47
+ error: string | null;
48
+ createdAt: number;
49
+ updatedAt: number;
50
+ }
51
+
52
+ interface JobInternal extends Job {
53
+ /** Secret that only the approval page sees — a cross-origin page cannot read it. */
54
+ approvalToken: string;
55
+ }
56
+
57
+ /** The side effects the service performs, injectable so tests can watch rather than do. */
58
+ export interface ServiceDeps {
59
+ platform: PlatformInfo;
60
+ fetchReleases(repo: string): Promise<RawRelease[]>;
61
+ download(url: string, dest: string, onProgress: OnProgress, size: number): Promise<void>;
62
+ install(file: string, asset: PickedAsset, repo: string): InstallResult;
63
+ launch(cmd: { cmd: string; args: string[] }): void;
64
+ }
65
+
66
+ /** Reads the GitHub releases list without the CLI client's `process.exit` on 403. */
67
+ export async function fetchReleasesFromGithub(repo: string): Promise<RawRelease[]> {
68
+ const headers: Record<string, string> = {
69
+ 'User-Agent': 'git0-service',
70
+ Accept: 'application/vnd.github+json',
71
+ };
72
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `token ${process.env.GITHUB_TOKEN}`;
73
+
74
+ const url = `https://api.github.com/repos/${repo}/releases?per_page=15`;
75
+ let res = await fetch(url, { headers });
76
+ // A stale or revoked token gets 401 even for public repos; public data does
77
+ // not need one, so try again without it before giving up.
78
+ if (res.status === 401 && headers.Authorization) {
79
+ delete headers.Authorization;
80
+ res = await fetch(url, { headers });
81
+ }
82
+ if (res.status === 403 || res.status === 429) {
83
+ throw new Error('GitHub rate limit reached — set GITHUB_TOKEN for the service');
84
+ }
85
+ if (res.status === 404) throw new Error(`Repository ${repo} not found`);
86
+ if (!res.ok) throw new Error(`GitHub returned HTTP ${res.status}`);
87
+ return (await res.json()) as RawRelease[];
88
+ }
89
+
90
+ export function defaultDeps(): ServiceDeps {
91
+ return {
92
+ platform: getCurrentPlatform(),
93
+ fetchReleases: fetchReleasesFromGithub,
94
+ download: (url, dest, onProgress, size) => downloadWithProgress(url, dest, onProgress, size),
95
+ install: installAsset,
96
+ launch: launchApp,
97
+ };
98
+ }
99
+
100
+ /** Repos the user ticked "always allow" for. Kept in `~/.git0/service.json`. */
101
+ export class TrustStore {
102
+ constructor(private file = path.join(git0Home(), 'service.json')) {}
103
+
104
+ private read(): { trustedRepos: string[] } {
105
+ try {
106
+ const data = JSON.parse(fs.readFileSync(this.file, 'utf8'));
107
+ return { trustedRepos: Array.isArray(data.trustedRepos) ? data.trustedRepos : [] };
108
+ } catch {
109
+ return { trustedRepos: [] };
110
+ }
111
+ }
112
+
113
+ has(repo: string): boolean {
114
+ return this.read().trustedRepos.includes(repo.toLowerCase());
115
+ }
116
+
117
+ add(repo: string): void {
118
+ const data = this.read();
119
+ if (!data.trustedRepos.includes(repo.toLowerCase())) data.trustedRepos.push(repo.toLowerCase());
120
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
121
+ fs.writeFileSync(this.file, JSON.stringify(data, null, 2));
122
+ }
123
+ }
124
+
125
+ /** Approval requests left unanswered this long are dropped. */
126
+ const APPROVAL_TTL_MS = 10 * 60 * 1000;
127
+ /** Ceiling on unanswered requests, so a page cannot flood the user with prompts. */
128
+ const MAX_PENDING = 5;
129
+ /** Finished jobs kept for the status endpoint. */
130
+ const MAX_JOBS = 50;
131
+
132
+ /**
133
+ * Holds every install job and runs each one through
134
+ * resolve → download → install → launch.
135
+ *
136
+ * A job for a repository the user has not trusted waits in
137
+ * `awaiting-approval` until someone presses Allow on the local approval page.
138
+ * That page is the one thing a website cannot drive: it is served without
139
+ * CORS headers and refuses to be framed, so the token it carries never reaches
140
+ * the page that asked for the install.
141
+ */
142
+ export class JobManager {
143
+ private jobs = new Map<string, JobInternal>();
144
+
145
+ constructor(private deps: ServiceDeps, private trust = new TrustStore()) {}
146
+
147
+ /** Public view of a job — everything but the approval token. */
148
+ view(job: JobInternal): Job {
149
+ const { approvalToken: _secret, ...rest } = job;
150
+ return rest;
151
+ }
152
+
153
+ get(id: string): JobInternal | undefined {
154
+ this.expire();
155
+ return this.jobs.get(id);
156
+ }
157
+
158
+ list(): Job[] {
159
+ this.expire();
160
+ return [...this.jobs.values()].map(j => this.view(j));
161
+ }
162
+
163
+ /**
164
+ * Starts — or, for a repo already in flight, returns — the install job for
165
+ * `repo`. Throws for a repo string that is not `owner/repo` and when too
166
+ * many approvals are pending.
167
+ */
168
+ request(repoInput: string): JobInternal {
169
+ const repo = normalizeRepoSlug(repoInput);
170
+ if (!repo) throw new Error('Expected a GitHub repository as owner/repo or a github.com URL');
171
+
172
+ this.expire();
173
+ const active = [...this.jobs.values()].find(
174
+ j => j.repo.toLowerCase() === repo.toLowerCase() && !TERMINAL.includes(j.status)
175
+ );
176
+ if (active) return active;
177
+
178
+ const trusted = this.trust.has(repo);
179
+ const pending = [...this.jobs.values()].filter(j => j.status === 'awaiting-approval').length;
180
+ if (!trusted && pending >= MAX_PENDING) throw new Error('Too many install requests are waiting for approval');
181
+
182
+ const now = Date.now();
183
+ const job: JobInternal = {
184
+ id: crypto.randomUUID(),
185
+ repo,
186
+ status: trusted ? 'resolving' : 'awaiting-approval',
187
+ progress: 0,
188
+ receivedBytes: 0,
189
+ totalBytes: 0,
190
+ asset: null,
191
+ tag: null,
192
+ installedPath: null,
193
+ launched: false,
194
+ message: trusted ? 'Finding the right release' : 'Waiting for you to allow this install',
195
+ error: null,
196
+ createdAt: now,
197
+ updatedAt: now,
198
+ approvalToken: crypto.randomBytes(24).toString('hex'),
199
+ };
200
+ this.jobs.set(job.id, job);
201
+ this.prune();
202
+ if (trusted) void this.run(job);
203
+ return job;
204
+ }
205
+
206
+ /** Answers the approval prompt. Returns false when the token is wrong or the job is not waiting. */
207
+ decide(id: string, token: string, allow: boolean, alwaysTrust = false): boolean {
208
+ const job = this.get(id);
209
+ if (!job || job.status !== 'awaiting-approval') return false;
210
+ const a = Buffer.from(token);
211
+ const b = Buffer.from(job.approvalToken);
212
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
213
+
214
+ if (!allow) {
215
+ this.update(job, { status: 'denied', message: 'Install was declined' });
216
+ return true;
217
+ }
218
+ if (alwaysTrust) this.trust.add(job.repo);
219
+ this.update(job, { status: 'resolving', message: 'Finding the right release' });
220
+ void this.run(job);
221
+ return true;
222
+ }
223
+
224
+ private update(job: JobInternal, patch: Partial<Job>): void {
225
+ Object.assign(job, patch, { updatedAt: Date.now() });
226
+ }
227
+
228
+ private expire(): void {
229
+ const now = Date.now();
230
+ for (const job of this.jobs.values()) {
231
+ if (job.status === 'awaiting-approval' && now - job.createdAt > APPROVAL_TTL_MS) {
232
+ this.update(job, { status: 'denied', message: 'Approval request expired' });
233
+ }
234
+ }
235
+ }
236
+
237
+ private prune(): void {
238
+ const done = [...this.jobs.values()].filter(j => TERMINAL.includes(j.status));
239
+ while (this.jobs.size > MAX_JOBS && done.length) this.jobs.delete(done.shift()!.id);
240
+ }
241
+
242
+ /** The pipeline. Every failure lands the job in `error` with a readable message. */
243
+ async run(job: JobInternal): Promise<void> {
244
+ const { platform } = this.deps;
245
+ try {
246
+ const releases = await this.deps.fetchReleases(job.repo);
247
+ const asset = pickAsset(releases, platform);
248
+ if (!asset) {
249
+ throw new Error(`No release of ${job.repo} ships a build for ${platform.os} ${platform.arch}`);
250
+ }
251
+
252
+ this.update(job, {
253
+ status: 'downloading',
254
+ asset: asset.name,
255
+ tag: asset.tag,
256
+ totalBytes: asset.size,
257
+ message: `Downloading ${asset.name}`,
258
+ });
259
+
260
+ const dest = path.join(git0Home(), 'downloads', job.repo.replace('/', '__'), asset.name);
261
+ await this.deps.download(
262
+ asset.url,
263
+ dest,
264
+ (received, total) => {
265
+ const t = total || asset.size;
266
+ this.update(job, {
267
+ receivedBytes: received,
268
+ totalBytes: t,
269
+ // Download is 90% of the bar; install and launch share the rest.
270
+ progress: t ? Math.min(0.9, (received / t) * 0.9) : job.progress,
271
+ });
272
+ },
273
+ asset.size
274
+ );
275
+
276
+ this.update(job, { status: 'installing', progress: 0.92, message: `Installing ${asset.name}` });
277
+ const result = this.deps.install(dest, asset, job.repo);
278
+ this.update(job, { installedPath: result.installedPath, message: result.message });
279
+
280
+ if (result.launch) {
281
+ this.update(job, { status: 'launching', progress: 0.97 });
282
+ this.deps.launch(result.launch);
283
+ this.update(job, { launched: true });
284
+ }
285
+
286
+ this.update(job, { status: 'done', progress: 1 });
287
+ } catch (err: any) {
288
+ this.update(job, { status: 'error', error: err?.message ?? String(err), message: 'Install failed' });
289
+ }
290
+ }
291
+ }
292
+
293
+ // ─── HTTP ─────────────────────────────────────────────────────────────────────
294
+
295
+ const CORS = {
296
+ 'Access-Control-Allow-Origin': '*',
297
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
298
+ 'Access-Control-Allow-Headers': 'Content-Type',
299
+ // Chrome's Private Network Access: a public page may only reach 127.0.0.1
300
+ // when the local server opts in on the preflight.
301
+ 'Access-Control-Allow-Private-Network': 'true',
302
+ 'Access-Control-Max-Age': '600',
303
+ };
304
+
305
+ const NO_FRAME = {
306
+ 'X-Frame-Options': 'DENY',
307
+ 'Content-Security-Policy': "frame-ancestors 'none'",
308
+ 'Cache-Control': 'no-store',
309
+ };
310
+
311
+ function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
312
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS });
313
+ res.end(JSON.stringify(body));
314
+ }
315
+
316
+ function sendHtml(res: http.ServerResponse, status: number, html: string): void {
317
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', ...NO_FRAME });
318
+ res.end(html);
319
+ }
320
+
321
+ function readBody(req: http.IncomingMessage, limit = 4096): Promise<string> {
322
+ return new Promise((resolve, reject) => {
323
+ let data = '';
324
+ req.on('data', chunk => {
325
+ data += chunk;
326
+ if (data.length > limit) {
327
+ reject(new Error('Body too large'));
328
+ req.destroy();
329
+ }
330
+ });
331
+ req.on('end', () => resolve(data));
332
+ req.on('error', reject);
333
+ });
334
+ }
335
+
336
+ const esc = (s: string) =>
337
+ s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
338
+
339
+ /**
340
+ * The local page a person sees after clicking an install link: an Allow/Deny
341
+ * prompt while the job waits, then a live progress bar.
342
+ */
343
+ export function jobPage(job: Job, token: string | null): string {
344
+ const repo = esc(job.repo);
345
+ const prompt = job.status === 'awaiting-approval' && token
346
+ ? `<p>A web page asked git0 to download and install the latest release of
347
+ <a href="https://github.com/${repo}" target="_blank" rel="noopener"><b>${repo}</b></a>
348
+ on this computer and open it.</p>
349
+ <form method="post" action="/approve/${esc(job.id)}">
350
+ <input type="hidden" name="token" value="${esc(token)}">
351
+ <label><input type="checkbox" name="trust"> Always allow ${repo}</label>
352
+ <div class="row">
353
+ <button name="decision" value="allow" class="primary">Allow install</button>
354
+ <button name="decision" value="deny">Deny</button>
355
+ </div>
356
+ </form>`
357
+ : '';
358
+
359
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
360
+ <meta name="viewport" content="width=device-width,initial-scale=1">
361
+ <title>git0 · ${repo}</title>
362
+ <style>
363
+ :root{color-scheme:light dark;--bg:#fafafa;--fg:#111;--muted:#666;--accent:#6d28d9;--track:#e5e5e5}
364
+ @media (prefers-color-scheme:dark){:root{--bg:#111;--fg:#eee;--muted:#999;--accent:#a78bfa;--track:#333}}
365
+ body{margin:0;font:15px/1.5 system-ui,sans-serif;background:var(--bg);color:var(--fg);display:grid;place-items:center;min-height:100vh}
366
+ main{max-width:440px;padding:24px}
367
+ h1{font-size:18px;margin:0 0 12px}
368
+ a{color:var(--accent)}
369
+ .row{display:flex;gap:8px;margin-top:16px}
370
+ button{font:inherit;padding:8px 16px;border-radius:8px;border:1px solid var(--track);background:transparent;color:var(--fg);cursor:pointer}
371
+ button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
372
+ .bar{height:10px;border-radius:5px;background:var(--track);overflow:hidden;margin:16px 0 8px}
373
+ .bar>i{display:block;height:100%;width:0;background:var(--accent);transition:width .3s}
374
+ #msg{color:var(--muted);font-size:13px}
375
+ </style></head><body><main>
376
+ <h1>git0 · ${repo}</h1>
377
+ ${prompt}
378
+ <div id="progress" ${prompt ? 'hidden' : ''}>
379
+ <div class="bar"><i id="fill"></i></div>
380
+ <div id="msg">${esc(job.message)}</div>
381
+ </div>
382
+ <script>
383
+ const id = ${JSON.stringify(job.id)};
384
+ async function poll(){
385
+ try{
386
+ const j = await (await fetch('/status/' + id)).json();
387
+ if (j.status === 'awaiting-approval') return setTimeout(poll, 1000);
388
+ document.getElementById('progress').hidden = false;
389
+ document.getElementById('fill').style.width = Math.round(j.progress * 100) + '%';
390
+ const mb = n => (n / 1048576).toFixed(1);
391
+ document.getElementById('msg').textContent =
392
+ j.status === 'downloading' && j.totalBytes ? j.message + ' — ' + mb(j.receivedBytes) + ' / ' + mb(j.totalBytes) + ' MB'
393
+ : j.status === 'error' ? 'Failed: ' + j.error : j.message;
394
+ if (!['done','error','denied'].includes(j.status)) setTimeout(poll, 500);
395
+ }catch{ setTimeout(poll, 2000) }
396
+ }
397
+ ${prompt ? '' : 'poll();'}
398
+ </script>
399
+ </main></body></html>`;
400
+ }
401
+
402
+ /** Options for {@link createServiceServer}. */
403
+ export interface ServiceOptions {
404
+ port?: number;
405
+ deps?: ServiceDeps;
406
+ trust?: TrustStore;
407
+ version?: string;
408
+ }
409
+
410
+ /**
411
+ * Builds the HTTP server the background service runs.
412
+ *
413
+ * Endpoints (all on 127.0.0.1 only):
414
+ *
415
+ * | Route | Who calls it | Does |
416
+ * | --- | --- | --- |
417
+ * | `GET /health` | download buttons | proves the service is installed and running |
418
+ * | `GET\|POST /install?repo=owner/repo` | download buttons, `git0://` links | queues an install, returns the job |
419
+ * | `GET /status/:id` | download buttons | job state and progress, for an in-button bar |
420
+ * | `GET /jobs` | `git0 --service=status` | every job |
421
+ * | `GET /job/:id` | the person, in a browser tab | approval prompt, then progress |
422
+ * | `POST /approve/:id` | the approval form only | allows or denies the install |
423
+ *
424
+ * Requests whose `Host` is not this machine's loopback address are refused,
425
+ * which is what stops a DNS-rebinding page from talking to the service as if
426
+ * it were same-origin.
427
+ */
428
+ export function createServiceServer(opts: ServiceOptions = {}): { server: http.Server; jobs: JobManager } {
429
+ const deps = opts.deps ?? defaultDeps();
430
+ const jobs = new JobManager(deps, opts.trust ?? new TrustStore());
431
+
432
+ const server = http.createServer(async (req, res) => {
433
+ try {
434
+ const port = (server.address() as any)?.port;
435
+ const host = (req.headers.host ?? '').toLowerCase();
436
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
437
+ return sendJson(res, 403, { error: 'Forbidden host' });
438
+ }
439
+
440
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
441
+ const method = req.method ?? 'GET';
442
+ const parts = url.pathname.split('/').filter(Boolean);
443
+
444
+ if (method === 'OPTIONS') {
445
+ res.writeHead(204, CORS);
446
+ return res.end();
447
+ }
448
+
449
+ if (parts[0] === 'health' && method === 'GET') {
450
+ return sendJson(res, 200, {
451
+ ok: true,
452
+ name: 'git0',
453
+ version: opts.version ?? null,
454
+ os: deps.platform.os,
455
+ arch: deps.platform.arch,
456
+ });
457
+ }
458
+
459
+ if (parts[0] === 'install' && (method === 'GET' || method === 'POST')) {
460
+ let repo = url.searchParams.get('repo');
461
+ if (!repo && method === 'POST') {
462
+ const raw = await readBody(req);
463
+ try { repo = JSON.parse(raw).repo; } catch { repo = new URLSearchParams(raw).get('repo'); }
464
+ }
465
+ let job: JobInternal;
466
+ try {
467
+ job = jobs.request(repo ?? '');
468
+ } catch (err: any) {
469
+ return sendJson(res, 400, { error: err.message });
470
+ }
471
+ const jobUrl = `http://127.0.0.1:${port}/job/${job.id}`;
472
+ // A plain link click is a navigation: send the person to the page
473
+ // that asks them, rather than show them JSON.
474
+ if (method === 'GET' && (req.headers.accept ?? '').includes('text/html')) {
475
+ res.writeHead(303, { Location: `/job/${job.id}`, ...CORS });
476
+ return res.end();
477
+ }
478
+ return sendJson(res, 202, {
479
+ ...jobs.view(job),
480
+ statusUrl: `http://127.0.0.1:${port}/status/${job.id}`,
481
+ approveUrl: job.status === 'awaiting-approval' ? jobUrl : null,
482
+ jobUrl,
483
+ });
484
+ }
485
+
486
+ if (parts[0] === 'status' && parts[1] && method === 'GET') {
487
+ const job = jobs.get(parts[1]);
488
+ return job ? sendJson(res, 200, jobs.view(job)) : sendJson(res, 404, { error: 'No such job' });
489
+ }
490
+
491
+ if (parts[0] === 'jobs' && method === 'GET') {
492
+ return sendJson(res, 200, jobs.list());
493
+ }
494
+
495
+ if (parts[0] === 'job' && parts[1] && method === 'GET') {
496
+ const job = jobs.get(parts[1]);
497
+ if (!job) return sendHtml(res, 404, '<p>No such install request.</p>');
498
+ return sendHtml(res, 200, jobPage(jobs.view(job), job.approvalToken));
499
+ }
500
+
501
+ if (parts[0] === 'approve' && parts[1] && method === 'POST') {
502
+ // Belt and braces next to the token: the form posts from our own origin.
503
+ const origin = req.headers.origin;
504
+ if (origin && origin !== `http://127.0.0.1:${port}` && origin !== `http://localhost:${port}`) {
505
+ return sendHtml(res, 403, '<p>Approval must come from the git0 page itself.</p>');
506
+ }
507
+ const form = new URLSearchParams(await readBody(req));
508
+ const ok = jobs.decide(
509
+ parts[1],
510
+ form.get('token') ?? '',
511
+ form.get('decision') === 'allow',
512
+ form.get('trust') === 'on'
513
+ );
514
+ if (!ok) return sendHtml(res, 400, '<p>This request is no longer waiting for approval.</p>');
515
+ res.writeHead(303, { Location: `/job/${parts[1]}`, ...NO_FRAME });
516
+ return res.end();
517
+ }
518
+
519
+ sendJson(res, 404, { error: 'Not found' });
520
+ } catch (err: any) {
521
+ if (!res.headersSent) sendJson(res, 500, { error: err?.message ?? 'Internal error' });
522
+ }
523
+ });
524
+
525
+ return { server, jobs };
526
+ }
527
+
528
+ /**
529
+ * Starts the service in the foreground on 127.0.0.1 and resolves once it is
530
+ * listening. This is what the system service runs.
531
+ */
532
+ export function startService(opts: ServiceOptions = {}): Promise<{ server: http.Server; jobs: JobManager; port: number }> {
533
+ const { server, jobs } = createServiceServer(opts);
534
+ return new Promise((resolve, reject) => {
535
+ server.once('error', reject);
536
+ server.listen(opts.port ?? DEFAULT_PORT, '127.0.0.1', () => {
537
+ resolve({ server, jobs, port: (server.address() as any).port });
538
+ });
539
+ });
540
+ }