git0 0.3.65 → 0.3.67

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.
@@ -6,6 +6,7 @@
6
6
  "pages": [
7
7
  "---Getting Started---",
8
8
  "index",
9
+ "service",
9
10
  "---API Reference---",
10
11
  "modules",
11
12
  "git0",
@@ -0,0 +1,51 @@
1
+ ---
2
+ title: Install Service
3
+ description: Run git0 in the background so website download buttons install the right release and show progress in the button.
4
+ ---
5
+
6
+
7
+ git0 can run as a small background service that turns any website's download button into a one-click installer. The button hands the service a repository; the service picks the release asset built for your OS and CPU, downloads it, installs it, and opens the app, while the button fills up as a progress bar.
8
+
9
+ ```bash
10
+ git0 --service=install # start at login and register git0:// links
11
+ git0 --service=status # is it running, and what is it installing
12
+ git0 --service=uninstall # remove it
13
+ git0 --service # or run it in the foreground, on 127.0.0.1:47100
14
+ ```
15
+
16
+ It installs as a **per-user** service, because launching a desktop app has to happen inside your session: a `systemd --user` unit on Linux, a LaunchAgent on macOS, and a logon task on Windows. No admin rights are needed to install it.
17
+
18
+ ## Add the button to a site
19
+
20
+ ```html
21
+ <a href="https://github.com/owner/repo/releases/latest" data-git0-repo="owner/repo">Download</a>
22
+ <script src="https://unpkg.com/git0/demo/git0-button.js"></script>
23
+ ```
24
+
25
+ When the service is running, clicking the link does not download a file. The button morphs into a progress bar showing the download, then the install, then the launch. When the service is not running, the link behaves as a normal link. `demo/index.html` has a live and a simulated example.
26
+
27
+ Links also work without the script: `git0://install/owner/repo` is registered with the OS on Linux and Windows, and `http://127.0.0.1:47100/install?repo=owner/repo` works everywhere.
28
+
29
+ ## What it installs
30
+
31
+ | OS | Preferred asset, in order | Result |
32
+ | --- | --- | --- |
33
+ | Linux | `.AppImage`, `.deb`, `.rpm`, `.tar.gz`, bare binary | AppImage and binaries go to `~/.git0/apps`; `.deb`/`.rpm` install through the desktop's password prompt (`pkexec`) |
34
+ | macOS | `.dmg`, `.pkg`, `.zip`/`.tar.gz` | The `.app` is copied to `~/Applications`; a `.pkg` opens Installer |
35
+ | Windows | setup `.exe`, `.msi`, `.zip` | The installer runs with its own UI; a portable `.zip` is extracted to `~/.git0/apps` |
36
+
37
+ Asset names are matched on whole words, so `darwin` is never mistaken for `win`. An exact CPU match beats an unlabelled build, and an unlabelled build beats Rosetta or 32-bit fallbacks. Checksums, signatures and source archives are never picked. A command-line tool is installed but not launched, because it would start with no terminal attached.
38
+
39
+ ## Security
40
+
41
+ Any website can ask, but only you can say yes. The first install of a repository opens a git0 page from `127.0.0.1` asking you to allow it, and the website cannot answer that prompt for you: the page carries a one-time token, is served without CORS headers so other sites can't read it, and refuses to be framed. Tick "Always allow" to skip the prompt for that repository next time (stored in `~/.git0/service.json`). The service listens on loopback only, rejects requests whose `Host` isn't loopback (DNS rebinding), and caps pending prompts at five.
42
+
43
+ ## HTTP API
44
+
45
+ | Route | Returns |
46
+ | --- | --- |
47
+ | `GET /health` | `{ ok, name: "git0", version, os, arch }` |
48
+ | `POST /install` `{ "repo": "owner/repo" }` | the job, plus `statusUrl` and `approveUrl` |
49
+ | `GET /status/:id` | `status` (`awaiting-approval`, `resolving`, `downloading`, `installing`, `launching`, `done`, `error`, `denied`), `progress` 0–1, `receivedBytes`, `totalBytes`, `asset`, `tag`, `installedPath`, `launched`, `error` |
50
+ | `GET /jobs` | every job |
51
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "git0",
3
3
  "description": "CLI tool to search GitHub repositories, download source & releases for your system, and instantly set up, then install dependencies and open code editor.",
4
- "version": "0.3.65",
4
+ "version": "0.3.67",
5
5
  "author": "vtempest",
6
6
  "scripts": {
7
7
  "build": "bun build src/cli.ts --outdir dist --target node --format esm --sourcemap",
package/src/args.ts CHANGED
@@ -16,6 +16,13 @@ export interface Flags {
16
16
  historyOnly: boolean;
17
17
  /** `--mirror` — use `--mirror` rather than `--bare` when cloning history. */
18
18
  mirror: boolean;
19
+ /**
20
+ * `--service[=run|install|uninstall|status]` — the background install
21
+ * service. Bare `--service` runs it in the foreground.
22
+ */
23
+ service?: 'run' | 'install' | 'uninstall' | 'status';
24
+ /** `--port=<n>` — the port the service listens on. */
25
+ port?: number;
19
26
  }
20
27
 
21
28
  /**
@@ -51,6 +58,9 @@ export function parseArgs(argv: string[]): { positional: string[]; flags: Flags
51
58
  else if (name === 'history' || name === 'git') flags.history = true;
52
59
  else if (name === 'history-only' || name === 'git-only') flags.historyOnly = true;
53
60
  else if (name === 'mirror') flags.mirror = true;
61
+ else if (name === 'service' && ['', 'run', 'install', 'uninstall', 'status'].includes(value)) {
62
+ flags.service = (value || 'run') as Flags['service'];
63
+ } else if (name === 'port' && /^\d+$/.test(value)) flags.port = Number(value);
54
64
  else console.log(chalk.yellow(`Ignoring unknown flag: ${arg}`));
55
65
  }
56
66
 
package/src/cli.ts CHANGED
@@ -10,6 +10,8 @@ import { attachGitHistoryLazily, cloneHistoryOnly } from './history.js';
10
10
  import { parseArgs, type Flags } from './args.js';
11
11
  import { getAvailableDirectoryName } from './download.js';
12
12
  import { printLogo } from './utils.js';
13
+ import { runServiceCommand } from './service/index.js';
14
+ import pkg from '../package.json' with { type: 'json' };
13
15
  import type { SearchResult } from './types.js';
14
16
 
15
17
  const Github = new GithubAPI({ debug: false });
@@ -185,11 +187,17 @@ async function handleRepoDownload(selectedRepo: SearchResult, flags: Flags): Pro
185
187
  *
186
188
  * // Search:
187
189
  * // git0 react template starter
190
+ *
191
+ * // Background install service for website download buttons:
192
+ * // git0 --service=install
193
+ * // git0 git0://install/owner/repo
188
194
  */
189
195
  async function main(): Promise<void> {
190
196
  printLogo();
191
197
 
192
198
  const { positional, flags } = parseArgs(process.argv.slice(2));
199
+ if (await runServiceCommand(positional, flags, pkg.version)) return;
200
+
193
201
  if (!positional.length) {
194
202
  console.log(chalk.yellow('Usage: git0 <github-url | owner/repo[/path] | search-query>'));
195
203
  console.log(chalk.gray(' --path=<path> download only that folder or file'));
@@ -197,6 +205,7 @@ async function main(): Promise<void> {
197
205
  console.log(chalk.gray(' --history also fetch .git history, in the background'));
198
206
  console.log(chalk.gray(' --history-only clone only the history, no working files'));
199
207
  console.log(chalk.gray(' --mirror keep every ref when cloning history'));
208
+ console.log(chalk.gray(' --service[=install|uninstall|status] background install service for download buttons'));
200
209
  process.exit(1);
201
210
  }
202
211
 
@@ -0,0 +1,260 @@
1
+ import type { PlatformInfo } from '../types.js';
2
+
3
+ /**
4
+ * How a downloaded release asset gets onto the machine. Read off the file
5
+ * name alone, so it can be decided before a byte is downloaded.
6
+ */
7
+ export type InstallKind =
8
+ | 'appimage' // Linux: chmod +x, run in place
9
+ | 'deb' // Linux: dpkg via pkexec (graphical sudo prompt)
10
+ | 'rpm' // Linux: rpm via pkexec
11
+ | 'dmg' // macOS: mount, copy the .app into ~/Applications
12
+ | 'pkg' // macOS: hand to Installer.app
13
+ | 'exe' // Windows: run it — installer or portable app
14
+ | 'msi' // Windows: msiexec /i
15
+ | 'archive' // any OS: extract, then find something to launch
16
+ | 'binary'; // bare executable: chmod +x, keep in the apps folder
17
+
18
+ /** A release asset chosen for this machine, with how to install it. */
19
+ export interface PickedAsset {
20
+ name: string;
21
+ url: string;
22
+ size: number;
23
+ tag: string;
24
+ kind: InstallKind;
25
+ }
26
+
27
+ /** The fields of a GitHub release this module reads. */
28
+ export interface RawRelease {
29
+ tag_name: string;
30
+ draft?: boolean;
31
+ prerelease?: boolean;
32
+ assets?: Array<{ name: string; size: number; browser_download_url: string }>;
33
+ }
34
+
35
+ /** Files attached to releases that are never the thing to install. */
36
+ const NOT_INSTALLABLE =
37
+ /(\.(sha\d*|sha256sum|md5|sig|asc|pem|crt|txt|json|ya?ml|blockmap|sbom|spdx|intoto\.jsonl|zsync|sym|pdb|dSYM\.zip)$)|checksum|sha256sums|(^|[-_.])src([-_.]|$)|source/i;
38
+
39
+ /**
40
+ * Splits a file name into lowercase tokens on anything that is not a letter or
41
+ * digit. Matching whole tokens rather than substrings is the point: the
42
+ * substring `win` is inside `darwin`, and `arm` is inside `alarm`.
43
+ */
44
+ function tokens(name: string): Set<string> {
45
+ return new Set(name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
46
+ }
47
+
48
+ /**
49
+ * Which operating system an asset targets, judged by whole-word keywords and
50
+ * by extension. Returns `'any'` when nothing says, and `'ambiguous'` when the
51
+ * name names more than one OS (e.g. a combined `win-mac-linux.zip`).
52
+ *
53
+ * @example
54
+ * assetOs('app-darwin-arm64.tar.gz'); // → 'macos' (not 'windows' — `darwin` ≠ `win`)
55
+ * assetOs('app_1.0_amd64.deb'); // → 'linux'
56
+ * assetOs('app.jar'); // → 'any'
57
+ */
58
+ export function assetOs(name: string): 'windows' | 'macos' | 'linux' | 'any' | 'ambiguous' {
59
+ const lower = name.toLowerCase();
60
+ const t = tokens(name);
61
+ const has = (...words: string[]) => words.some(w => t.has(w));
62
+
63
+ const hits: Array<'windows' | 'macos' | 'linux'> = [];
64
+ if (has('win', 'windows', 'win32', 'win64', 'msvc', 'mingw') || /\.(exe|msi|msix|appx)$/.test(lower)) {
65
+ hits.push('windows');
66
+ }
67
+ if (has('mac', 'macos', 'darwin', 'osx', 'apple', 'macosx') || /\.(dmg|pkg)$/.test(lower)) {
68
+ hits.push('macos');
69
+ }
70
+ if (has('linux', 'ubuntu', 'debian', 'fedora', 'musl', 'gnu') || /\.(deb|rpm|appimage|snap|flatpak)$/.test(lower)) {
71
+ hits.push('linux');
72
+ }
73
+
74
+ if (hits.length === 1) return hits[0];
75
+ return hits.length === 0 ? 'any' : 'ambiguous';
76
+ }
77
+
78
+ /**
79
+ * Which CPU architecture an asset targets. `'unknown'` when the name does not
80
+ * say, which in practice usually means x86_64 — so it is allowed, and ranked
81
+ * below an exact match.
82
+ *
83
+ * @example
84
+ * assetArch('fzf-0.55.0-linux_arm64.tar.gz'); // → 'arm64'
85
+ * assetArch('app-x86_64.AppImage'); // → 'x86_64'
86
+ * assetArch('App-universal.dmg'); // → 'universal'
87
+ */
88
+ export function assetArch(name: string): string {
89
+ const n = name.toLowerCase();
90
+ if (/universal/.test(n)) return 'universal';
91
+ if (/aarch64|arm64/.test(n)) return 'arm64';
92
+ if (/armv[67]|armhf|armel|(^|[^a-z])arm([^a-z0-9]|$)/.test(n)) return 'arm';
93
+ if (/x86[_-]64|amd64|x64|win64/.test(n)) return 'x86_64';
94
+ if (/i[36]86|x86[_-]32|ia32|win32|(^|[^a-z0-9])386([^0-9]|$)|(^|[^a-z0-9])x86([^_0-9-]|$)/.test(n)) return 'i386';
95
+ return 'unknown';
96
+ }
97
+
98
+ /**
99
+ * Reads the install kind from the file name.
100
+ *
101
+ * @example
102
+ * installKind('Obsidian-1.6.7.AppImage'); // → 'appimage'
103
+ * installKind('app.tar.gz'); // → 'archive'
104
+ * installKind('ripgrep'); // → 'binary'
105
+ */
106
+ export function installKind(name: string): InstallKind | null {
107
+ const n = name.toLowerCase();
108
+ if (n.endsWith('.appimage')) return 'appimage';
109
+ if (n.endsWith('.deb')) return 'deb';
110
+ if (n.endsWith('.rpm')) return 'rpm';
111
+ if (n.endsWith('.dmg')) return 'dmg';
112
+ if (n.endsWith('.pkg')) return 'pkg';
113
+ if (n.endsWith('.exe')) return 'exe';
114
+ if (n.endsWith('.msi')) return 'msi';
115
+ if (/\.(zip|tar\.gz|tgz|tar\.xz|txz|tar\.bz2|tbz2?|tar\.zst|tar)$/.test(n)) return 'archive';
116
+ // No extension at all, or only a version-ish suffix: a bare executable.
117
+ if (!/\.[a-z][a-z0-9]{0,4}$/.test(n)) return 'binary';
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * How much each install kind is preferred on each OS. Higher wins. A kind
123
+ * missing from an OS's table cannot be installed there.
124
+ *
125
+ * The order favours what installs and launches without a terminal: an
126
+ * AppImage over a `.deb` (no root), a `.dmg` over a `.zip`, a setup `.exe`
127
+ * over a portable `.zip`.
128
+ */
129
+ const KIND_SCORE: Record<string, Partial<Record<InstallKind, number>>> = {
130
+ linux: { appimage: 40, deb: 30, rpm: 25, archive: 20, binary: 15 },
131
+ macos: { dmg: 40, pkg: 35, archive: 25, binary: 15 },
132
+ windows: { exe: 40, msi: 35, archive: 20 },
133
+ };
134
+
135
+ /**
136
+ * Scores one asset for this machine, or returns `null` when it cannot run
137
+ * here at all — wrong OS, wrong architecture, or not an installable file.
138
+ *
139
+ * @internal
140
+ */
141
+ export function scoreAsset(name: string, platform: PlatformInfo): number | null {
142
+ if (NOT_INSTALLABLE.test(name)) return null;
143
+
144
+ const kind = installKind(name);
145
+ if (!kind) return null;
146
+
147
+ const kindScore = KIND_SCORE[platform.os]?.[kind];
148
+ if (kindScore === undefined) return null;
149
+
150
+ const os = assetOs(name);
151
+ if (os === 'ambiguous') return null;
152
+ if (os !== 'any' && os !== platform.os) return null;
153
+
154
+ const arch = assetArch(name);
155
+ let archScore: number;
156
+ if (arch === platform.arch) archScore = 100;
157
+ else if (arch === 'universal') archScore = 90;
158
+ // Unlabelled builds are x86_64 by convention (electron-builder names its
159
+ // x64 AppImage `App-1.0.AppImage`), so on x86_64 that is nearly a match;
160
+ // anywhere else it is a guess.
161
+ else if (arch === 'unknown') archScore = platform.arch === 'x86_64' ? 95 : 50;
162
+ // Apple Silicon runs x86_64 builds under Rosetta, and 64-bit Windows runs
163
+ // 32-bit ones — worse than native, better than nothing.
164
+ else if (platform.os === 'macos' && platform.arch === 'arm64' && arch === 'x86_64') archScore = 10;
165
+ else if (platform.os === 'windows' && platform.arch === 'x86_64' && arch === 'i386') archScore = 10;
166
+ else return null;
167
+
168
+ // An asset that names its OS is a surer bet than one that names nothing.
169
+ const osScore = os === platform.os ? 5 : 0;
170
+
171
+ return archScore + kindScore + osScore;
172
+ }
173
+
174
+ /**
175
+ * Picks the one release asset to install on this machine.
176
+ *
177
+ * Walks releases newest first, skipping drafts and — unless nothing else has a
178
+ * match — pre-releases, and returns the best-scoring asset of the first
179
+ * release that has any match at all. A newer release is always preferred over
180
+ * a better-packaged older one.
181
+ *
182
+ * @param releases - `GET /repos/{owner}/{repo}/releases`, newest first.
183
+ * @param platform - From `getCurrentPlatform()`.
184
+ * @returns The chosen asset, or `null` when no release ships one for this
185
+ * OS/arch.
186
+ *
187
+ * @example
188
+ * pickAsset(releases, { os: 'linux', arch: 'x86_64', … });
189
+ * // → { name: 'app-1.2.0-x86_64.AppImage', kind: 'appimage', tag: 'v1.2.0', … }
190
+ */
191
+ export function pickAsset(releases: RawRelease[], platform: PlatformInfo): PickedAsset | null {
192
+ const published = releases.filter(r => !r.draft);
193
+ const stable = published.filter(r => !r.prerelease);
194
+
195
+ for (const pool of [stable, published]) {
196
+ for (const release of pool) {
197
+ let best: PickedAsset | null = null;
198
+ let bestScore = -1;
199
+
200
+ for (const asset of release.assets ?? []) {
201
+ const score = scoreAsset(asset.name, platform);
202
+ if (score === null || score <= bestScore) continue;
203
+ bestScore = score;
204
+ best = {
205
+ name: asset.name,
206
+ url: asset.browser_download_url,
207
+ size: asset.size,
208
+ tag: release.tag_name,
209
+ kind: installKind(asset.name)!,
210
+ };
211
+ }
212
+
213
+ if (best) return best;
214
+ }
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Accepts the forms a download button might pass and reduces them to
222
+ * `owner/repo`. Anything else is rejected — this string ends up in a GitHub
223
+ * API path, so it is validated rather than trusted.
224
+ *
225
+ * @example
226
+ * normalizeRepoSlug('https://github.com/junegunn/fzf/releases'); // → 'junegunn/fzf'
227
+ * normalizeRepoSlug('junegunn/fzf.git'); // → 'junegunn/fzf'
228
+ * normalizeRepoSlug('../../etc/passwd'); // → null
229
+ */
230
+ export function normalizeRepoSlug(input: string | null | undefined): string | null {
231
+ if (!input) return null;
232
+ let s = input.trim();
233
+ s = s.replace(/^(https?:\/\/)?(www\.)?github\.com\//i, '');
234
+ s = s.replace(/^git@github\.com:/i, '');
235
+ const [owner, repo] = s.split(/[/?#]/);
236
+ if (!owner || !repo) return null;
237
+ const name = repo.replace(/\.git$/i, '');
238
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner)) return null;
239
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(name) || name === '.' || name === '..') return null;
240
+ return `${owner}/${name}`;
241
+ }
242
+
243
+ /**
244
+ * Reads the repository out of a `git0://` link, the custom URL scheme the
245
+ * service registers with the OS.
246
+ *
247
+ * @example
248
+ * parseSchemeUrl('git0://install/junegunn/fzf'); // → 'junegunn/fzf'
249
+ * parseSchemeUrl('git0://install?repo=junegunn/fzf'); // → 'junegunn/fzf'
250
+ * parseSchemeUrl('git0://junegunn/fzf'); // → 'junegunn/fzf'
251
+ */
252
+ export function parseSchemeUrl(link: string): string | null {
253
+ const m = link.match(/^git0:\/{0,2}(.*)$/i);
254
+ if (!m) return null;
255
+ let rest = m[1];
256
+ const query = rest.match(/[?&]repo=([^&#]+)/);
257
+ if (query) return normalizeRepoSlug(decodeURIComponent(query[1]));
258
+ rest = rest.replace(/^install\/?/i, '').replace(/[?#].*$/, '');
259
+ return normalizeRepoSlug(decodeURIComponent(rest));
260
+ }
@@ -0,0 +1,61 @@
1
+ import { shell } from '../utils.js';
2
+ import { openCommand } from './installer.js';
3
+ import { DEFAULT_PORT } from './server.js';
4
+
5
+ const base = (port: number) => `http://127.0.0.1:${port}`;
6
+
7
+ /** True when a git0 service answers on `port`. */
8
+ export async function isServiceRunning(port = DEFAULT_PORT, fetchImpl: typeof fetch = fetch): Promise<boolean> {
9
+ try {
10
+ const res = await fetchImpl(`${base(port)}/health`, { signal: AbortSignal.timeout(800) });
11
+ return res.ok && (await res.json())?.name === 'git0';
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Starts the service in the background when it is not already up, and waits
19
+ * for it to answer. Used by the `git0://` handler so a link works even before
20
+ * `--service=install` has been run.
21
+ */
22
+ export async function ensureService(runtime: string, script: string, port = DEFAULT_PORT): Promise<boolean> {
23
+ if (await isServiceRunning(port)) return true;
24
+ const child = shell.spawn(runtime, [script, '--service', `--port=${port}`], { detached: true, stdio: 'ignore' }) as any;
25
+ child?.on?.('error', () => { /* reported below as "could not start" */ });
26
+ child?.unref?.();
27
+ for (let i = 0; i < 20; i++) {
28
+ await new Promise(r => setTimeout(r, 250));
29
+ if (await isServiceRunning(port)) return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Hands an install request to the running service and opens its page in the
36
+ * default browser — the approval prompt, or the progress bar for a trusted repo.
37
+ */
38
+ export async function requestInstall(repo: string, port = DEFAULT_PORT): Promise<{ id: string; jobUrl: string }> {
39
+ const res = await fetch(`${base(port)}/install`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ repo }),
43
+ });
44
+ const body = await res.json();
45
+ if (!res.ok) throw new Error(body.error ?? `Service returned HTTP ${res.status}`);
46
+ const open = openCommand(body.jobUrl);
47
+ // On Linux `openCommand` runs the target itself; a URL needs xdg-open.
48
+ const { cmd, args } = process.platform === 'linux' ? { cmd: 'xdg-open', args: [body.jobUrl] } : open;
49
+ try {
50
+ const child = shell.spawn(cmd, args, { detached: true, stdio: 'ignore' }) as any;
51
+ child?.on?.('error', () => { /* no browser opener — the caller prints the URL */ });
52
+ child?.unref?.();
53
+ } catch { /* no browser — the URL is printed by the caller */ }
54
+ return { id: body.id, jobUrl: body.jobUrl };
55
+ }
56
+
57
+ /** Every job the running service knows about. */
58
+ export async function listJobs(port = DEFAULT_PORT): Promise<any[]> {
59
+ const res = await fetch(`${base(port)}/jobs`);
60
+ return res.json();
61
+ }
@@ -0,0 +1,77 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import type { Flags } from '../args.js';
5
+ import { parseSchemeUrl } from './assets.js';
6
+ import { ensureService, isServiceRunning, listJobs, requestInstall } from './client.js';
7
+ import { DEFAULT_PORT, startService } from './server.js';
8
+ import { installSystemService, uninstallSystemService } from './system-service.js';
9
+
10
+ export { DEFAULT_PORT, createServiceServer, startService, JobManager, TrustStore } from './server.js';
11
+ export { pickAsset, normalizeRepoSlug, parseSchemeUrl } from './assets.js';
12
+
13
+ /** The runtime and script this process was started with — what the service files re-run. */
14
+ function selfCommand(): { runtime: string; script: string } {
15
+ const script = process.argv[1] ? fs.realpathSync(path.resolve(process.argv[1])) : '';
16
+ return { runtime: process.execPath, script };
17
+ }
18
+
19
+ /**
20
+ * Handles `--service[=…]` and `git0://` links. Returns `true` when it handled
21
+ * the invocation, so `main()` stops there.
22
+ *
23
+ * @example
24
+ * // git0 --service → run the service in the foreground
25
+ * // git0 --service=install → register it to start at login, and start it
26
+ * // git0 --service=uninstall → remove it
27
+ * // git0 --service=status → is it up, and what is it doing
28
+ * // git0 git0://install/owner/repo → what the OS runs when a git0:// link is clicked
29
+ */
30
+ export async function runServiceCommand(positional: string[], flags: Flags, version?: string): Promise<boolean> {
31
+ const port = flags.port ?? DEFAULT_PORT;
32
+ const link = positional.find(p => /^git0:/i.test(p));
33
+
34
+ if (link) {
35
+ const repo = parseSchemeUrl(link);
36
+ if (!repo) throw new Error(`Not a git0 install link: ${link}`);
37
+ const self = selfCommand();
38
+ if (!(await ensureService(self.runtime, self.script, port))) {
39
+ throw new Error('Could not start the git0 service');
40
+ }
41
+ const { jobUrl } = await requestInstall(repo, port);
42
+ console.log(chalk.green(`Install of ${repo} requested — ${jobUrl}`));
43
+ return true;
44
+ }
45
+
46
+ if (!flags.service) return false;
47
+
48
+ switch (flags.service) {
49
+ case 'run': {
50
+ const { port: bound } = await startService({ port, version });
51
+ console.log(chalk.green(`git0 service listening on http://127.0.0.1:${bound}`));
52
+ return true;
53
+ }
54
+ case 'install': {
55
+ for (const line of installSystemService({ ...selfCommand(), port })) console.log(chalk.green(`✅ ${line}`));
56
+ return true;
57
+ }
58
+ case 'uninstall': {
59
+ for (const line of uninstallSystemService()) console.log(chalk.green(`✅ ${line}`));
60
+ return true;
61
+ }
62
+ case 'status': {
63
+ if (!(await isServiceRunning(port))) {
64
+ console.log(chalk.yellow(`git0 service is not running on port ${port}`));
65
+ process.exitCode = 1;
66
+ return true;
67
+ }
68
+ console.log(chalk.green(`git0 service is running on http://127.0.0.1:${port}`));
69
+ for (const j of await listJobs(port)) {
70
+ const pct = `${Math.round(j.progress * 100)}%`.padStart(4);
71
+ console.log(` ${pct} ${j.status.padEnd(17)} ${j.repo}${j.error ? chalk.red(` ${j.error}`) : ''}`);
72
+ }
73
+ return true;
74
+ }
75
+ }
76
+ return false;
77
+ }