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.
@@ -0,0 +1,190 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { shell } from '../utils.js';
5
+
6
+ /**
7
+ * Registers the git0 service with the OS so it starts at login and keeps
8
+ * running, and registers the `git0://` URL scheme where the OS allows a CLI to.
9
+ *
10
+ * It is a **per-user** service on every platform, not a system-wide daemon:
11
+ * the whole job ends in launching a desktop app, and only a process inside the
12
+ * user's session can do that. It also means no admin rights to install.
13
+ *
14
+ * | OS | Runs as | URL scheme |
15
+ * | --- | --- | --- |
16
+ * | Linux | `systemd --user` unit | `.desktop` handler + `xdg-mime` |
17
+ * | macOS | LaunchAgent | not registered — macOS delivers URLs by Apple Event to app bundles only; buttons use the HTTP endpoint |
18
+ * | Windows | Scheduled task at logon | `HKCU\Software\Classes\git0` |
19
+ */
20
+
21
+ /** Paths and command the service files are generated from. */
22
+ export interface ServiceSpec {
23
+ /** Absolute path of the runtime (`process.execPath`). */
24
+ runtime: string;
25
+ /** Absolute path of the git0 entry script. */
26
+ script: string;
27
+ port: number;
28
+ home?: string;
29
+ }
30
+
31
+ export const SERVICE_LABEL = 'org.git0.service';
32
+ const TASK_NAME = 'git0-service';
33
+
34
+ /** `systemd --user` unit. Restarts on failure; `default.target` starts it at login. */
35
+ export function systemdUnit(spec: ServiceSpec): string {
36
+ return `[Unit]
37
+ Description=git0 background install service
38
+ After=network-online.target
39
+
40
+ [Service]
41
+ ExecStart="${spec.runtime}" "${spec.script}" --service --port=${spec.port}
42
+ Restart=on-failure
43
+ RestartSec=3
44
+
45
+ [Install]
46
+ WantedBy=default.target
47
+ `;
48
+ }
49
+
50
+ /** The `.desktop` entry that makes git0 the handler for `git0://` links on Linux. */
51
+ export function desktopEntry(spec: ServiceSpec): string {
52
+ return `[Desktop Entry]
53
+ Type=Application
54
+ Name=git0
55
+ Comment=Install GitHub releases from download links
56
+ Exec="${spec.runtime}" "${spec.script}" %u
57
+ NoDisplay=true
58
+ Terminal=false
59
+ MimeType=x-scheme-handler/git0;
60
+ `;
61
+ }
62
+
63
+ const xml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
64
+
65
+ /** macOS LaunchAgent: runs at load, restarted if it exits. */
66
+ export function launchAgentPlist(spec: ServiceSpec): string {
67
+ const logDir = path.join(spec.home ?? os.homedir(), 'Library', 'Logs');
68
+ return `<?xml version="1.0" encoding="UTF-8"?>
69
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
70
+ <plist version="1.0">
71
+ <dict>
72
+ <key>Label</key><string>${SERVICE_LABEL}</string>
73
+ <key>ProgramArguments</key>
74
+ <array>
75
+ <string>${xml(spec.runtime)}</string>
76
+ <string>${xml(spec.script)}</string>
77
+ <string>--service</string>
78
+ <string>--port=${spec.port}</string>
79
+ </array>
80
+ <key>RunAtLoad</key><true/>
81
+ <key>KeepAlive</key><true/>
82
+ <key>StandardOutPath</key><string>${xml(path.join(logDir, 'git0-service.log'))}</string>
83
+ <key>StandardErrorPath</key><string>${xml(path.join(logDir, 'git0-service.log'))}</string>
84
+ </dict>
85
+ </plist>
86
+ `;
87
+ }
88
+
89
+ /** Windows: commands that create the logon task and the `git0://` protocol keys. */
90
+ export function windowsCommands(spec: ServiceSpec): string[] {
91
+ const run = `\\"${spec.runtime}\\" \\"${spec.script}\\" --service --port=${spec.port}`;
92
+ const open = `\\"${spec.runtime}\\" \\"${spec.script}\\" \\"%1\\"`;
93
+ return [
94
+ `schtasks /Create /F /SC ONLOGON /RL LIMITED /TN ${TASK_NAME} /TR "${run}"`,
95
+ `reg add "HKCU\\Software\\Classes\\git0" /ve /d "URL:git0 Protocol" /f`,
96
+ `reg add "HKCU\\Software\\Classes\\git0" /v "URL Protocol" /d "" /f`,
97
+ `reg add "HKCU\\Software\\Classes\\git0\\shell\\open\\command" /ve /d "${open}" /f`,
98
+ ];
99
+ }
100
+
101
+ /** Where each OS's service file lives. */
102
+ export function serviceFiles(home = os.homedir()) {
103
+ return {
104
+ systemdUnit: path.join(home, '.config', 'systemd', 'user', 'git0.service'),
105
+ desktopEntry: path.join(home, '.local', 'share', 'applications', 'git0-handler.desktop'),
106
+ launchAgent: path.join(home, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`),
107
+ };
108
+ }
109
+
110
+ function run(cmd: string, quiet = false): boolean {
111
+ try {
112
+ shell.execSync(cmd, { stdio: quiet ? 'ignore' : 'inherit' });
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function write(file: string, content: string): void {
120
+ fs.mkdirSync(path.dirname(file), { recursive: true });
121
+ fs.writeFileSync(file, content);
122
+ }
123
+
124
+ /**
125
+ * Installs and starts the service for the current user. Returns lines to
126
+ * print — what was written, and anything that needs the user.
127
+ */
128
+ export function installSystemService(spec: ServiceSpec, platform = process.platform): string[] {
129
+ const files = serviceFiles(spec.home);
130
+ const out: string[] = [];
131
+
132
+ if (platform === 'linux') {
133
+ write(files.systemdUnit, systemdUnit(spec));
134
+ out.push(`Wrote ${files.systemdUnit}`);
135
+ const started = run('systemctl --user daemon-reload', true) && run('systemctl --user enable --now git0.service', true);
136
+ out.push(started
137
+ ? 'Started git0.service (systemctl --user status git0)'
138
+ : 'systemd --user is not available here — run `git0 --service` from your session startup instead');
139
+
140
+ write(files.desktopEntry, desktopEntry(spec));
141
+ run(`xdg-mime default git0-handler.desktop x-scheme-handler/git0`, true);
142
+ run(`update-desktop-database ${JSON.stringify(path.dirname(files.desktopEntry))}`, true);
143
+ out.push('Registered git0:// links');
144
+ return out;
145
+ }
146
+
147
+ if (platform === 'darwin') {
148
+ write(files.launchAgent, launchAgentPlist(spec));
149
+ run(`launchctl unload ${JSON.stringify(files.launchAgent)}`, true);
150
+ out.push(run(`launchctl load -w ${JSON.stringify(files.launchAgent)}`, true)
151
+ ? `Loaded LaunchAgent ${SERVICE_LABEL}`
152
+ : `Wrote ${files.launchAgent} — load it with launchctl load -w`);
153
+ out.push('git0:// links are not registered on macOS; buttons reach the service over HTTP');
154
+ return out;
155
+ }
156
+
157
+ if (platform === 'win32') {
158
+ for (const cmd of windowsCommands(spec)) run(cmd, true);
159
+ out.push(`Created logon task ${TASK_NAME} and registered git0:// links`);
160
+ out.push(run(`schtasks /Run /TN ${TASK_NAME}`, true) ? 'Started the service' : 'Will start at next logon');
161
+ return out;
162
+ }
163
+
164
+ throw new Error(`Installing as a service is not supported on ${platform}`);
165
+ }
166
+
167
+ /** Stops the service and removes everything {@link installSystemService} added. */
168
+ export function uninstallSystemService(platform = process.platform, home = os.homedir()): string[] {
169
+ const files = serviceFiles(home);
170
+
171
+ if (platform === 'linux') {
172
+ run('systemctl --user disable --now git0.service', true);
173
+ fs.rmSync(files.systemdUnit, { force: true });
174
+ fs.rmSync(files.desktopEntry, { force: true });
175
+ run('systemctl --user daemon-reload', true);
176
+ return ['Removed git0.service and the git0:// handler'];
177
+ }
178
+ if (platform === 'darwin') {
179
+ run(`launchctl unload -w ${JSON.stringify(files.launchAgent)}`, true);
180
+ fs.rmSync(files.launchAgent, { force: true });
181
+ return [`Removed LaunchAgent ${SERVICE_LABEL}`];
182
+ }
183
+ if (platform === 'win32') {
184
+ run(`schtasks /End /TN ${TASK_NAME}`, true);
185
+ run(`schtasks /Delete /F /TN ${TASK_NAME}`, true);
186
+ run(`reg delete "HKCU\\Software\\Classes\\git0" /f`, true);
187
+ return [`Removed task ${TASK_NAME} and the git0:// handler`];
188
+ }
189
+ throw new Error(`Not supported on ${platform}`);
190
+ }
package/test/args.test.ts CHANGED
@@ -41,4 +41,15 @@ describe('parseArgs', () => {
41
41
  expect(warn).toHaveBeenCalled();
42
42
  warn.mockRestore();
43
43
  });
44
+
45
+ test('reads --service with and without an action, and --port', () => {
46
+ expect(parseArgs(['--service']).flags.service).toBe('run');
47
+ expect(parseArgs(['--service=install']).flags.service).toBe('install');
48
+ expect(parseArgs(['--service=status', '--port=5000']).flags).toMatchObject({ service: 'status', port: 5000 });
49
+ expect(parseArgs([]).flags.service).toBeUndefined();
50
+ });
51
+
52
+ test('keeps git0:// links as positional arguments', () => {
53
+ expect(parseArgs(['git0://install/acme/app']).positional).toEqual(['git0://install/acme/app']);
54
+ });
44
55
  });
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ assetArch,
4
+ assetOs,
5
+ installKind,
6
+ normalizeRepoSlug,
7
+ parseSchemeUrl,
8
+ pickAsset,
9
+ type RawRelease,
10
+ } from '../src/service/assets.ts';
11
+ import type { PlatformInfo } from '../src/types.ts';
12
+
13
+ const linux: PlatformInfo = { os: 'linux', arch: 'x86_64', platform: 'linux', architecture: 'x64' };
14
+ const linuxArm: PlatformInfo = { os: 'linux', arch: 'arm64', platform: 'linux', architecture: 'arm64' };
15
+ const macArm: PlatformInfo = { os: 'macos', arch: 'arm64', platform: 'darwin', architecture: 'arm64' };
16
+ const macIntel: PlatformInfo = { os: 'macos', arch: 'x86_64', platform: 'darwin', architecture: 'x64' };
17
+ const win: PlatformInfo = { os: 'windows', arch: 'x86_64', platform: 'win32', architecture: 'x64' };
18
+
19
+ const release = (tag: string, names: string[], extra: Partial<RawRelease> = {}): RawRelease => ({
20
+ tag_name: tag,
21
+ assets: names.map(name => ({ name, size: 100, browser_download_url: `https://dl/${tag}/${name}` })),
22
+ ...extra,
23
+ });
24
+
25
+ describe('assetOs', () => {
26
+ test('does not read darwin as windows', () => {
27
+ expect(assetOs('fzf-0.55.0-darwin_arm64.tar.gz')).toBe('macos');
28
+ });
29
+ test('reads OS from extension', () => {
30
+ expect(assetOs('App.Setup.1.0.exe')).toBe('windows');
31
+ expect(assetOs('App-1.0.dmg')).toBe('macos');
32
+ expect(assetOs('app_1.0_amd64.deb')).toBe('linux');
33
+ });
34
+ test('any when nothing says, ambiguous when several do', () => {
35
+ expect(assetOs('tool.tar.gz')).toBe('any');
36
+ expect(assetOs('tool-win-mac-linux.zip')).toBe('ambiguous');
37
+ });
38
+ });
39
+
40
+ describe('assetArch', () => {
41
+ test.each([
42
+ ['app-linux-aarch64.tar.gz', 'arm64'],
43
+ ['app_amd64.deb', 'x86_64'],
44
+ ['app-x86_64.AppImage', 'x86_64'],
45
+ ['app-armv7.tar.gz', 'arm'],
46
+ ['app-i686.exe', 'i386'],
47
+ ['App-universal.dmg', 'universal'],
48
+ ['app.tar.gz', 'unknown'],
49
+ ])('%s → %s', (name, arch) => {
50
+ expect(assetArch(name)).toBe(arch);
51
+ });
52
+ });
53
+
54
+ describe('installKind', () => {
55
+ test.each([
56
+ ['Obsidian-1.6.7.AppImage', 'appimage'],
57
+ ['a.deb', 'deb'],
58
+ ['a.rpm', 'rpm'],
59
+ ['a.dmg', 'dmg'],
60
+ ['a.pkg', 'pkg'],
61
+ ['a.exe', 'exe'],
62
+ ['a.msi', 'msi'],
63
+ ['a.tar.gz', 'archive'],
64
+ ['a.zip', 'archive'],
65
+ ['ripgrep', 'binary'],
66
+ ['fzf-0.55.0-linux_amd64', 'binary'],
67
+ ['notes.jar', null],
68
+ ])('%s → %s', (name, kind) => {
69
+ expect(installKind(name)).toBe(kind);
70
+ });
71
+ });
72
+
73
+ describe('pickAsset', () => {
74
+ const fzf = release('v0.55.0', [
75
+ 'fzf-0.55.0-darwin_amd64.tar.gz',
76
+ 'fzf-0.55.0-darwin_arm64.tar.gz',
77
+ 'fzf-0.55.0-linux_amd64.tar.gz',
78
+ 'fzf-0.55.0-linux_arm64.tar.gz',
79
+ 'fzf-0.55.0-windows_amd64.zip',
80
+ 'fzf_0.55.0_checksums.txt',
81
+ ]);
82
+
83
+ test('matches OS and architecture', () => {
84
+ expect(pickAsset([fzf], linux)?.name).toBe('fzf-0.55.0-linux_amd64.tar.gz');
85
+ expect(pickAsset([fzf], linuxArm)?.name).toBe('fzf-0.55.0-linux_arm64.tar.gz');
86
+ expect(pickAsset([fzf], macArm)?.name).toBe('fzf-0.55.0-darwin_arm64.tar.gz');
87
+ expect(pickAsset([fzf], win)?.name).toBe('fzf-0.55.0-windows_amd64.zip');
88
+ });
89
+
90
+ test('carries tag, url and install kind', () => {
91
+ expect(pickAsset([fzf], linux)).toEqual({
92
+ name: 'fzf-0.55.0-linux_amd64.tar.gz',
93
+ url: 'https://dl/v0.55.0/fzf-0.55.0-linux_amd64.tar.gz',
94
+ size: 100,
95
+ tag: 'v0.55.0',
96
+ kind: 'archive',
97
+ });
98
+ });
99
+
100
+ test('prefers installer formats over archives', () => {
101
+ const app = release('v1', [
102
+ 'App-1.0.dmg', 'App-1.0-mac.zip', 'App-1.0.AppImage', 'app_1.0_amd64.deb',
103
+ 'App-Setup-1.0.exe', 'App-1.0-win.zip', 'App-1.0.dmg.blockmap', 'latest.yml',
104
+ ]);
105
+ expect(pickAsset([app], macIntel)?.name).toBe('App-1.0.dmg');
106
+ expect(pickAsset([app], linux)?.name).toBe('App-1.0.AppImage');
107
+ expect(pickAsset([app], win)?.name).toBe('App-Setup-1.0.exe');
108
+ });
109
+
110
+ test('prefers an exact arch over an unlabelled one', () => {
111
+ const app = release('v1', ['app-linux.AppImage', 'app-linux-arm64.AppImage']);
112
+ expect(pickAsset([app], linuxArm)?.name).toBe('app-linux-arm64.AppImage');
113
+ expect(pickAsset([app], linux)?.name).toBe('app-linux.AppImage');
114
+ });
115
+
116
+ test('never picks a build for another architecture', () => {
117
+ const app = release('v1', ['app-linux-arm64.tar.gz']);
118
+ expect(pickAsset([app], linux)).toBeNull();
119
+ });
120
+
121
+ test('lets Apple Silicon fall back to an Intel build', () => {
122
+ const app = release('v1', ['app-darwin-x86_64.tar.gz']);
123
+ expect(pickAsset([app], macArm)?.name).toBe('app-darwin-x86_64.tar.gz');
124
+ });
125
+
126
+ test('takes the newest release that has a match', () => {
127
+ const newest = release('v2', ['app-windows.exe']);
128
+ const older = release('v1', ['app-linux.AppImage']);
129
+ expect(pickAsset([newest, older], linux)?.tag).toBe('v1');
130
+ expect(pickAsset([release('v3', ['a.AppImage']), older], linux)?.tag).toBe('v3');
131
+ });
132
+
133
+ test('skips drafts, and pre-releases while a stable one matches', () => {
134
+ const draft = release('v4', ['a.AppImage'], { draft: true });
135
+ const pre = release('v3-rc', ['a.AppImage'], { prerelease: true });
136
+ const stable = release('v2', ['a.AppImage']);
137
+ expect(pickAsset([draft, pre, stable], linux)?.tag).toBe('v2');
138
+ expect(pickAsset([draft, pre], linux)?.tag).toBe('v3-rc');
139
+ });
140
+
141
+ test('returns null when nothing fits', () => {
142
+ expect(pickAsset([], linux)).toBeNull();
143
+ expect(pickAsset([release('v1', ['source.tar.gz', 'SHA256SUMS'])], linux)).toBeNull();
144
+ });
145
+ });
146
+
147
+ describe('normalizeRepoSlug', () => {
148
+ test.each([
149
+ ['junegunn/fzf', 'junegunn/fzf'],
150
+ ['https://github.com/junegunn/fzf', 'junegunn/fzf'],
151
+ ['https://github.com/junegunn/fzf/releases/latest', 'junegunn/fzf'],
152
+ ['github.com/junegunn/fzf.git', 'junegunn/fzf'],
153
+ ['git@github.com:junegunn/fzf.git', 'junegunn/fzf'],
154
+ ])('%s → %s', (input, slug) => {
155
+ expect(normalizeRepoSlug(input)).toBe(slug);
156
+ });
157
+
158
+ test.each(['', 'fzf', '../etc/passwd', 'a/..', 'https://evil.com/a/b', 'own er/repo', null])(
159
+ 'rejects %p',
160
+ input => {
161
+ expect(normalizeRepoSlug(input as any)).toBeNull();
162
+ }
163
+ );
164
+ });
165
+
166
+ describe('parseSchemeUrl', () => {
167
+ test.each([
168
+ ['git0://install/junegunn/fzf', 'junegunn/fzf'],
169
+ ['git0://install?repo=junegunn%2Ffzf', 'junegunn/fzf'],
170
+ ['git0://junegunn/fzf', 'junegunn/fzf'],
171
+ ['git0:junegunn/fzf', 'junegunn/fzf'],
172
+ ['git0://install/https%3A%2F%2Fgithub.com%2Fjunegunn%2Ffzf', 'junegunn/fzf'],
173
+ ])('%s → %s', (link, slug) => {
174
+ expect(parseSchemeUrl(link)).toBe(slug);
175
+ });
176
+
177
+ test('rejects other schemes and junk', () => {
178
+ expect(parseSchemeUrl('https://github.com/junegunn/fzf')).toBeNull();
179
+ expect(parseSchemeUrl('git0://install/')).toBeNull();
180
+ });
181
+ });
@@ -0,0 +1,201 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { execSync } from 'child_process';
5
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
+ import {
7
+ downloadWithProgress,
8
+ findLaunchable,
9
+ installAsset,
10
+ openCommand,
11
+ } from '../src/service/installer.ts';
12
+ import {
13
+ desktopEntry,
14
+ installSystemService,
15
+ launchAgentPlist,
16
+ serviceFiles,
17
+ systemdUnit,
18
+ uninstallSystemService,
19
+ windowsCommands,
20
+ } from '../src/service/system-service.ts';
21
+ import { installFakeShell } from './helpers/fake-shell.ts';
22
+ import type { PickedAsset } from '../src/service/assets.ts';
23
+
24
+ let tmp: string;
25
+ beforeEach(() => {
26
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-inst-'));
27
+ process.env.GIT0_HOME = path.join(tmp, 'home');
28
+ });
29
+ afterEach(() => {
30
+ delete process.env.GIT0_HOME;
31
+ fs.rmSync(tmp, { recursive: true, force: true });
32
+ });
33
+
34
+ const asset = (name: string, kind: PickedAsset['kind']): PickedAsset =>
35
+ ({ name, kind, url: 'https://dl/x', size: 3, tag: 'v1' });
36
+
37
+ describe('downloadWithProgress', () => {
38
+ test('streams to disk and reports progress against content-length', async () => {
39
+ const chunks = [new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])];
40
+ const fakeFetch = (async () =>
41
+ new Response(new ReadableStream({
42
+ start(c) { chunks.forEach(ch => c.enqueue(ch)); c.close(); },
43
+ }), { headers: { 'content-length': '5' } })) as unknown as typeof fetch;
44
+
45
+ const seen: Array<[number, number]> = [];
46
+ const dest = path.join(tmp, 'd', 'file.bin');
47
+ await downloadWithProgress('https://x', dest, (r, t) => seen.push([r, t]), 0, fakeFetch);
48
+
49
+ expect([...fs.readFileSync(dest)]).toEqual([1, 2, 3, 4, 5]);
50
+ expect(seen).toEqual([[0, 5], [2, 5], [5, 5]]);
51
+ expect(fs.existsSync(`${dest}.part`)).toBe(false);
52
+ });
53
+
54
+ test('throws on an HTTP error and leaves no partial file', async () => {
55
+ const fakeFetch = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
56
+ const dest = path.join(tmp, 'f');
57
+ await expect(downloadWithProgress('https://x', dest, () => {}, 0, fakeFetch)).rejects.toThrow(/404/);
58
+ expect(fs.existsSync(dest)).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe('openCommand', () => {
63
+ test('per platform', () => {
64
+ expect(openCommand('/a.app', 'darwin')).toEqual({ cmd: 'open', args: ['/a.app'] });
65
+ expect(openCommand('C:\\a.exe', 'win32')).toEqual({ cmd: 'explorer.exe', args: ['C:\\a.exe'] });
66
+ expect(openCommand('/a.AppImage', 'linux')).toEqual({ cmd: '/a.AppImage', args: [] });
67
+ });
68
+ });
69
+
70
+ describe('findLaunchable', () => {
71
+ test('prefers an executable named after the repo, then the shallowest', () => {
72
+ const d = path.join(tmp, 'x');
73
+ fs.mkdirSync(path.join(d, 'bin'), { recursive: true });
74
+ fs.writeFileSync(path.join(d, 'README'), 'x');
75
+ fs.writeFileSync(path.join(d, 'helper'), 'x', { mode: 0o755 });
76
+ fs.writeFileSync(path.join(d, 'bin', 'my-tool'), 'x', { mode: 0o755 });
77
+ fs.writeFileSync(path.join(d, 'libfoo.so'), 'x', { mode: 0o755 });
78
+ expect(findLaunchable(d, 'my-tool', 'linux')).toBe(path.join(d, 'bin', 'my-tool'));
79
+ expect(findLaunchable(d, 'other', 'linux')).toBe(path.join(d, 'helper'));
80
+ });
81
+
82
+ test('finds a .app bundle on macOS and an .exe on Windows', () => {
83
+ const d = path.join(tmp, 'y');
84
+ fs.mkdirSync(path.join(d, 'Thing.app', 'Contents'), { recursive: true });
85
+ fs.writeFileSync(path.join(d, 'Thing.exe'), 'x');
86
+ fs.writeFileSync(path.join(d, 'unins000.exe'), 'x');
87
+ expect(findLaunchable(d, 'thing', 'darwin')).toBe(path.join(d, 'Thing.app'));
88
+ expect(findLaunchable(d, 'thing', 'win32')).toBe(path.join(d, 'Thing.exe'));
89
+ });
90
+ });
91
+
92
+ describe('installAsset', () => {
93
+ test('AppImage: copied, made executable, launched', () => {
94
+ const file = path.join(tmp, 'App.AppImage');
95
+ fs.writeFileSync(file, 'elf');
96
+ const res = installAsset(file, asset('App.AppImage', 'appimage'), 'acme/app');
97
+ expect(res.installedPath).toBe(path.join(tmp, 'home', 'apps', 'acme__app', 'App.AppImage'));
98
+ expect(fs.statSync(res.installedPath).mode & 0o111).toBeTruthy();
99
+ expect(res.launch).toEqual({ cmd: res.installedPath, args: [] });
100
+ });
101
+
102
+ test('bare binary: installed, not launched', () => {
103
+ const file = path.join(tmp, 'tool');
104
+ fs.writeFileSync(file, 'elf');
105
+ const res = installAsset(file, asset('tool', 'binary'), 'acme/tool');
106
+ expect(res.launch).toBeNull();
107
+ expect(res.message).toMatch(/command-line tool/);
108
+ });
109
+
110
+ test.skipIf(process.platform === 'win32')('archive: extracted, and the CLI inside located', () => {
111
+ const src = path.join(tmp, 'src');
112
+ fs.mkdirSync(path.join(src, 'tool-1.0'), { recursive: true });
113
+ fs.writeFileSync(path.join(src, 'tool-1.0', 'tool'), '#!/bin/sh\n', { mode: 0o755 });
114
+ const archive = path.join(tmp, 'tool.tar.gz');
115
+ execSync(`tar -czf "${archive}" -C "${src}" tool-1.0`);
116
+
117
+ const res = installAsset(archive, asset('tool.tar.gz', 'archive'), 'acme/tool');
118
+ expect(res.installedPath).toBe(path.join(tmp, 'home', 'apps', 'acme__tool', 'v1', 'tool-1.0', 'tool'));
119
+ expect(res.launch).toBeNull();
120
+ });
121
+
122
+ test('installers open their own UI', () => {
123
+ const exe = installAsset('/d/Setup.exe', asset('Setup.exe', 'exe'), 'acme/app');
124
+ expect(exe.launch).toEqual({ cmd: '/d/Setup.exe', args: [] });
125
+ const msi = installAsset('/d/a.msi', asset('a.msi', 'msi'), 'acme/app');
126
+ expect(msi.launch).toEqual({ cmd: 'msiexec', args: ['/i', '/d/a.msi'] });
127
+ });
128
+
129
+ test('deb without pkexec: downloaded, with the command to finish', () => {
130
+ const sh = installFakeShell({ available: [] });
131
+ try {
132
+ const res = installAsset('/d/a.deb', asset('a.deb', 'deb'), 'acme/app');
133
+ expect(res.launch).toBeNull();
134
+ expect(res.message).toContain('sudo dpkg -i "/d/a.deb"');
135
+ expect(sh.commands).toEqual([]);
136
+ } finally {
137
+ sh.restore();
138
+ }
139
+ });
140
+
141
+ test('deb with pkexec: installs through the graphical prompt', () => {
142
+ const sh = installFakeShell({ available: ['pkexec', 'app'] });
143
+ try {
144
+ const res = installAsset('/d/a.deb', asset('a.deb', 'deb'), 'acme/app');
145
+ expect(sh.commands).toEqual(['pkexec dpkg -i "/d/a.deb"']);
146
+ expect(res.launch).toEqual({ cmd: 'app', args: [] });
147
+ } finally {
148
+ sh.restore();
149
+ }
150
+ });
151
+ });
152
+
153
+ describe('system service files', () => {
154
+ const spec = { runtime: '/usr/bin/node', script: '/opt/git0/dist/cli.js', port: 47100, home: '/home/u' };
155
+
156
+ test('systemd unit runs the service on the port', () => {
157
+ const unit = systemdUnit(spec);
158
+ expect(unit).toContain('ExecStart="/usr/bin/node" "/opt/git0/dist/cli.js" --service --port=47100');
159
+ expect(unit).toContain('WantedBy=default.target');
160
+ });
161
+
162
+ test('desktop entry handles git0:// links', () => {
163
+ const entry = desktopEntry(spec);
164
+ expect(entry).toContain('MimeType=x-scheme-handler/git0;');
165
+ expect(entry).toContain('Exec="/usr/bin/node" "/opt/git0/dist/cli.js" %u');
166
+ });
167
+
168
+ test('LaunchAgent keeps the service alive', () => {
169
+ const plist = launchAgentPlist(spec);
170
+ expect(plist).toContain('<string>--service</string>');
171
+ expect(plist).toContain('<key>KeepAlive</key><true/>');
172
+ expect(plist).toContain('/home/u/Library/Logs/git0-service.log');
173
+ });
174
+
175
+ test('Windows: logon task and protocol keys under HKCU', () => {
176
+ const cmds = windowsCommands(spec);
177
+ expect(cmds[0]).toMatch(/^schtasks \/Create .*\/SC ONLOGON .*\/TN git0-service/);
178
+ expect(cmds.join('\n')).toContain('HKCU\\Software\\Classes\\git0');
179
+ expect(cmds.join('\n')).toContain('URL Protocol');
180
+ });
181
+
182
+ test('install and uninstall on Linux write and remove the files', () => {
183
+ const home = path.join(tmp, 'u');
184
+ const sh = installFakeShell();
185
+ try {
186
+ const out = installSystemService({ ...spec, home }, 'linux');
187
+ const files = serviceFiles(home);
188
+ expect(fs.readFileSync(files.systemdUnit, 'utf8')).toContain('--service');
189
+ expect(fs.existsSync(files.desktopEntry)).toBe(true);
190
+ expect(sh.commands).toContain('systemctl --user enable --now git0.service');
191
+ expect(sh.commands).toContain('xdg-mime default git0-handler.desktop x-scheme-handler/git0');
192
+ expect(out.join('\n')).toMatch(/Started git0.service/);
193
+
194
+ uninstallSystemService('linux', home);
195
+ expect(fs.existsSync(files.systemdUnit)).toBe(false);
196
+ expect(fs.existsSync(files.desktopEntry)).toBe(false);
197
+ } finally {
198
+ sh.restore();
199
+ }
200
+ });
201
+ });