git0 0.3.66 → 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.
- 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,246 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { shell } from '../utils.js';
|
|
5
|
+
import type { PickedAsset } from './assets.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the service keeps its state, downloads and portable apps.
|
|
9
|
+
* `GIT0_HOME` overrides it — the tests point it at a temp dir.
|
|
10
|
+
*/
|
|
11
|
+
export function git0Home(): string {
|
|
12
|
+
return process.env.GIT0_HOME || path.join(os.homedir(), '.git0');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Download progress callback: bytes so far, and the total when known (0 if not). */
|
|
16
|
+
export type OnProgress = (received: number, total: number) => void;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Streams a URL to a file, reporting progress as the bytes arrive.
|
|
20
|
+
*
|
|
21
|
+
* Uses the global `fetch` rather than the CLI's GitHub client on purpose: the
|
|
22
|
+
* client calls `process.exit` on a 403, which is right for a one-shot command
|
|
23
|
+
* and wrong for a daemon, and it does not expose progress.
|
|
24
|
+
*
|
|
25
|
+
* @param url - Asset URL; GitHub redirects these to its CDN, which fetch follows.
|
|
26
|
+
* @param dest - File to write.
|
|
27
|
+
* @param onProgress - Called after every chunk.
|
|
28
|
+
* @param expectedSize - Used as the total when the server sends no length.
|
|
29
|
+
*/
|
|
30
|
+
export async function downloadWithProgress(
|
|
31
|
+
url: string,
|
|
32
|
+
dest: string,
|
|
33
|
+
onProgress: OnProgress,
|
|
34
|
+
expectedSize = 0,
|
|
35
|
+
fetchImpl: typeof fetch = fetch
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const res = await fetchImpl(url, { headers: { 'User-Agent': 'git0-service' }, redirect: 'follow' });
|
|
38
|
+
if (!res.ok || !res.body) throw new Error(`Download failed: HTTP ${res.status}`);
|
|
39
|
+
|
|
40
|
+
const total = Number(res.headers.get('content-length')) || expectedSize || 0;
|
|
41
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
42
|
+
const partial = `${dest}.part`;
|
|
43
|
+
const out = fs.createWriteStream(partial);
|
|
44
|
+
let received = 0;
|
|
45
|
+
onProgress(0, total);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const reader = res.body.getReader();
|
|
49
|
+
for (;;) {
|
|
50
|
+
const { done, value } = await reader.read();
|
|
51
|
+
if (done) break;
|
|
52
|
+
received += value.byteLength;
|
|
53
|
+
if (!out.write(value)) await new Promise(r => out.once('drain', r));
|
|
54
|
+
onProgress(received, total);
|
|
55
|
+
}
|
|
56
|
+
await new Promise<void>((resolve, reject) => out.end((err?: Error | null) => (err ? reject(err) : resolve())));
|
|
57
|
+
fs.renameSync(partial, dest);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
out.destroy();
|
|
60
|
+
fs.rmSync(partial, { force: true });
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What an install left on disk, and how to start it. */
|
|
66
|
+
export interface InstallResult {
|
|
67
|
+
/** The installed app, binary or folder. */
|
|
68
|
+
installedPath: string;
|
|
69
|
+
/** Command to launch it, or `null` when there is nothing to launch (a CLI tool, a system package). */
|
|
70
|
+
launch: { cmd: string; args: string[] } | null;
|
|
71
|
+
/** One line for the status endpoint. */
|
|
72
|
+
message: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when a command is on PATH. */
|
|
76
|
+
function hasCommand(cmd: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
shell.execSync(process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`, { stdio: 'ignore' });
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const q = (s: string) => JSON.stringify(s);
|
|
86
|
+
|
|
87
|
+
/** The command that opens a file or app the way a double-click would. */
|
|
88
|
+
export function openCommand(target: string, platform = process.platform): { cmd: string; args: string[] } {
|
|
89
|
+
if (platform === 'darwin') return { cmd: 'open', args: [target] };
|
|
90
|
+
if (platform === 'win32') return { cmd: 'explorer.exe', args: [target] };
|
|
91
|
+
return { cmd: target, args: [] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Walks an extracted archive for the thing a user would double-click: a
|
|
96
|
+
* `.app` bundle on macOS, an `.exe` on Windows, an executable file elsewhere.
|
|
97
|
+
* Prefers a name that contains the repository name, then the shallowest hit.
|
|
98
|
+
*
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
export function findLaunchable(dir: string, repoName: string, platform = process.platform): string | null {
|
|
102
|
+
const hits: Array<{ p: string; depth: number; named: boolean }> = [];
|
|
103
|
+
const want = repoName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
104
|
+
|
|
105
|
+
const walk = (d: string, depth: number) => {
|
|
106
|
+
if (depth > 4) return;
|
|
107
|
+
let entries: fs.Dirent[];
|
|
108
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
109
|
+
for (const e of entries) {
|
|
110
|
+
const p = path.join(d, e.name);
|
|
111
|
+
const named = e.name.toLowerCase().replace(/[^a-z0-9]/g, '').includes(want);
|
|
112
|
+
if (platform === 'darwin' && e.isDirectory() && e.name.endsWith('.app')) {
|
|
113
|
+
hits.push({ p, depth, named });
|
|
114
|
+
continue; // never descend into a bundle
|
|
115
|
+
}
|
|
116
|
+
if (e.isDirectory()) { walk(p, depth + 1); continue; }
|
|
117
|
+
if (!e.isFile()) continue;
|
|
118
|
+
if (platform === 'win32') {
|
|
119
|
+
if (/\.exe$/i.test(e.name) && !/unins|uninstall|setup-helper/i.test(e.name)) hits.push({ p, depth, named });
|
|
120
|
+
} else if (platform !== 'darwin' || !e.name.includes('.')) {
|
|
121
|
+
try {
|
|
122
|
+
if (fs.statSync(p).mode & 0o111 && !/\.(so|dylib|sh)(\.|$)/.test(e.name)) hits.push({ p, depth, named });
|
|
123
|
+
} catch { /* unreadable — skip */ }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
walk(dir, 0);
|
|
128
|
+
|
|
129
|
+
hits.sort((a, b) => Number(b.named) - Number(a.named) || a.depth - b.depth);
|
|
130
|
+
return hits[0]?.p ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Extracts an archive into `dest` with the system's own tools. */
|
|
134
|
+
function extract(file: string, dest: string): void {
|
|
135
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
136
|
+
const isZip = /\.zip$/i.test(file);
|
|
137
|
+
if (isZip && process.platform === 'linux') {
|
|
138
|
+
shell.execSync(`unzip -q -o ${q(file)} -d ${q(dest)}`, { stdio: 'ignore' });
|
|
139
|
+
} else {
|
|
140
|
+
// bsdtar on macOS and Windows 10+ reads zip as well as every tar flavour.
|
|
141
|
+
shell.execSync(`tar -xf ${q(file)} -C ${q(dest)}`, { stdio: 'ignore' });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Installs a downloaded release asset for the current OS and says how to
|
|
147
|
+
* launch it.
|
|
148
|
+
*
|
|
149
|
+
* Nothing here runs with elevated rights silently. `.deb`/`.rpm` go through
|
|
150
|
+
* `pkexec`, which shows the desktop's own password dialog; `.pkg` and `.msi`
|
|
151
|
+
* open their own installer UI; a `.exe` is the installer. Everything else is
|
|
152
|
+
* a per-user install under `~/.git0/apps` (or `~/Applications` on macOS).
|
|
153
|
+
*
|
|
154
|
+
* @param file - The downloaded asset.
|
|
155
|
+
* @param asset - The pick it came from, for its install kind.
|
|
156
|
+
* @param repo - `owner/repo`, which names the install folder.
|
|
157
|
+
*/
|
|
158
|
+
export function installAsset(file: string, asset: PickedAsset, repo: string): InstallResult {
|
|
159
|
+
const repoName = repo.split('/')[1];
|
|
160
|
+
const appsDir = path.join(git0Home(), 'apps', repo.replace('/', '__'));
|
|
161
|
+
fs.mkdirSync(appsDir, { recursive: true });
|
|
162
|
+
|
|
163
|
+
switch (asset.kind) {
|
|
164
|
+
case 'appimage':
|
|
165
|
+
case 'binary': {
|
|
166
|
+
const target = path.join(appsDir, asset.name);
|
|
167
|
+
fs.copyFileSync(file, target);
|
|
168
|
+
fs.chmodSync(target, 0o755);
|
|
169
|
+
return asset.kind === 'appimage'
|
|
170
|
+
? { installedPath: target, launch: { cmd: target, args: [] }, message: `Installed ${asset.name}` }
|
|
171
|
+
: { installedPath: target, launch: null, message: `Installed command-line tool at ${target}` };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case 'deb':
|
|
175
|
+
case 'rpm': {
|
|
176
|
+
const tool = asset.kind === 'deb' ? `dpkg -i ${q(file)}` : `rpm -U --replacepkgs ${q(file)}`;
|
|
177
|
+
if (!hasCommand('pkexec')) {
|
|
178
|
+
return { installedPath: file, launch: null, message: `Downloaded. Install with: sudo ${tool}` };
|
|
179
|
+
}
|
|
180
|
+
shell.execSync(`pkexec ${tool}`, { stdio: 'ignore' });
|
|
181
|
+
const launch = hasCommand(repoName) ? { cmd: repoName, args: [] } : null;
|
|
182
|
+
return { installedPath: file, launch, message: `Installed ${asset.name} system-wide` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
case 'dmg': {
|
|
186
|
+
const mount = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-dmg-'));
|
|
187
|
+
shell.execSync(`hdiutil attach -nobrowse -readonly -mountpoint ${q(mount)} ${q(file)}`, { stdio: 'ignore' });
|
|
188
|
+
try {
|
|
189
|
+
const app = fs.readdirSync(mount).find(n => n.endsWith('.app'));
|
|
190
|
+
if (!app) {
|
|
191
|
+
const pkg = fs.readdirSync(mount).find(n => n.endsWith('.pkg'));
|
|
192
|
+
if (pkg) {
|
|
193
|
+
const copy = path.join(appsDir, pkg);
|
|
194
|
+
fs.copyFileSync(path.join(mount, pkg), copy);
|
|
195
|
+
return { installedPath: copy, launch: openCommand(copy), message: `Opening installer ${pkg}` };
|
|
196
|
+
}
|
|
197
|
+
throw new Error('No .app or .pkg found in the disk image');
|
|
198
|
+
}
|
|
199
|
+
const userApps = path.join(os.homedir(), 'Applications');
|
|
200
|
+
fs.mkdirSync(userApps, { recursive: true });
|
|
201
|
+
const target = path.join(userApps, app);
|
|
202
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
203
|
+
shell.execSync(`cp -R ${q(path.join(mount, app))} ${q(target)}`, { stdio: 'ignore' });
|
|
204
|
+
return { installedPath: target, launch: openCommand(target), message: `Installed ${app} to ~/Applications` };
|
|
205
|
+
} finally {
|
|
206
|
+
try { shell.execSync(`hdiutil detach ${q(mount)} -quiet`, { stdio: 'ignore' }); } catch { /* already gone */ }
|
|
207
|
+
fs.rmSync(mount, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'pkg':
|
|
212
|
+
case 'msi':
|
|
213
|
+
case 'exe': {
|
|
214
|
+
// These are installers with their own UI: running one *is* the install,
|
|
215
|
+
// and the user approves or cancels it there.
|
|
216
|
+
const launch = asset.kind === 'msi'
|
|
217
|
+
? { cmd: 'msiexec', args: ['/i', file] }
|
|
218
|
+
: asset.kind === 'pkg' ? openCommand(file) : { cmd: file, args: [] };
|
|
219
|
+
return { installedPath: file, launch, message: `Opening ${asset.name}` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'archive': {
|
|
223
|
+
const dest = path.join(appsDir, asset.tag || 'latest');
|
|
224
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
225
|
+
extract(file, dest);
|
|
226
|
+
const found = findLaunchable(dest, repoName);
|
|
227
|
+
if (!found) return { installedPath: dest, launch: null, message: `Extracted to ${dest}` };
|
|
228
|
+
if (found.endsWith('.app') || /\.exe$/i.test(found)) {
|
|
229
|
+
return { installedPath: found, launch: openCommand(found), message: `Installed ${path.basename(found)}` };
|
|
230
|
+
}
|
|
231
|
+
// An executable from a tarball is far more often a CLI than a GUI app:
|
|
232
|
+
// report where it is rather than start it with no terminal attached.
|
|
233
|
+
return { installedPath: found, launch: null, message: `Installed command-line tool at ${found}` };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Starts the installed app detached from the service, so it outlives it and
|
|
240
|
+
* does not hold its stdio.
|
|
241
|
+
*/
|
|
242
|
+
export function launchApp(launch: { cmd: string; args: string[] }): void {
|
|
243
|
+
const child = shell.spawn(launch.cmd, launch.args, { detached: true, stdio: 'ignore' }) as any;
|
|
244
|
+
child?.on?.('error', () => { /* reported as launched; the app's own failure is not ours */ });
|
|
245
|
+
child?.unref?.();
|
|
246
|
+
}
|