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.
- package/README.md +49 -1
- package/demo/git0-button.js +237 -0
- package/demo/index.html +71 -0
- package/dist/cli.js +1015 -4
- package/dist/cli.js.map +11 -5
- package/docs/content/docs/meta.json +1 -0
- package/docs/content/docs/service.mdx +51 -0
- package/package.json +1 -1
- package/src/args.ts +10 -0
- package/src/cli.ts +9 -0
- package/src/service/assets.ts +260 -0
- package/src/service/client.ts +61 -0
- package/src/service/index.ts +77 -0
- package/src/service/installer.ts +246 -0
- package/src/service/server.ts +540 -0
- package/src/service/system-service.ts +190 -0
- package/test/args.test.ts +11 -0
- package/test/service-assets.test.ts +181 -0
- package/test/service-installer.test.ts +201 -0
- package/test/service-server.test.ts +262 -0
|
@@ -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.
|
|
4
|
+
"version": "0.3.68",
|
|
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
|
+
}
|