git0 0.3.34 → 0.3.36

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,159 @@
1
+ import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2
+ import http from 'http';
3
+ import type { AddressInfo } from 'net';
4
+ import GithubAPI from '../src/github-api.ts';
5
+
6
+ /**
7
+ * Runs the real client against a local HTTP server standing in for
8
+ * api.github.com — no module mocking, so it behaves the same under vitest and
9
+ * `bun test`.
10
+ */
11
+ const RELEASES = [
12
+ {
13
+ tag_name: 'v1.0.0',
14
+ name: 'v1.0.0',
15
+ published_at: '2024-01-01T00:00:00Z',
16
+ assets: [
17
+ { name: 'tool-linux-x86_64.tar.gz', browser_download_url: 'https://x/linux', size: 1, download_count: 1 },
18
+ { name: 'tool-windows-x86_64.zip', browser_download_url: 'https://x/win', size: 1, download_count: 1 },
19
+ { name: 'tool-macos-arm64.dmg', browser_download_url: 'https://x/mac', size: 1, download_count: 1 },
20
+ ],
21
+ },
22
+ ];
23
+
24
+ const REPOS = [
25
+ { name: 'alpha', full_name: 'o/alpha', owner: { login: 'o' }, url: 'https://github.com/o/alpha', stargazers_count: 5 },
26
+ { name: 'beta', full_name: 'o/beta', owner: { login: 'o' }, url: 'https://github.com/o/beta', stargazers_count: 3 },
27
+ ];
28
+
29
+ let server: http.Server;
30
+ let base: string;
31
+ let requests: Array<{ url: string; auth?: string }>;
32
+ let mode: 'ok' | 'rate-limit' | 'empty';
33
+
34
+ beforeAll(async () => {
35
+ server = http.createServer((req, res) => {
36
+ requests.push({ url: req.url ?? '', auth: req.headers.authorization });
37
+ const json = (status: number, body: unknown) => {
38
+ res.writeHead(status, { 'content-type': 'application/json' });
39
+ res.end(JSON.stringify(body));
40
+ };
41
+ if (mode === 'rate-limit') return json(403, { message: 'rate limit' });
42
+ if (req.url?.startsWith('/search/repositories')) {
43
+ return json(200, mode === 'empty' ? { total_count: 0 } : { items: REPOS });
44
+ }
45
+ if (req.url?.endsWith('/releases')) {
46
+ return json(200, req.url.includes('/alpha/') ? RELEASES : []);
47
+ }
48
+ json(404, { message: 'not found' });
49
+ });
50
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
51
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
52
+ });
53
+
54
+ afterAll(() => new Promise<void>((r) => server.close(() => r())));
55
+
56
+ let logs: string[];
57
+ let origLog: typeof console.log;
58
+ let origErr: typeof console.error;
59
+ let origExit: typeof process.exit;
60
+ let exits: Array<number | undefined>;
61
+
62
+ beforeEach(() => {
63
+ requests = [];
64
+ mode = 'ok';
65
+ logs = [];
66
+ exits = [];
67
+ origLog = console.log;
68
+ origErr = console.error;
69
+ origExit = process.exit;
70
+ console.log = (...a: unknown[]) => void logs.push(a.join(' '));
71
+ console.error = (...a: unknown[]) => void logs.push(a.join(' '));
72
+ (process as any).exit = (code?: number) => void exits.push(code);
73
+ });
74
+
75
+ afterEach(() => {
76
+ console.log = origLog;
77
+ console.error = origErr;
78
+ process.exit = origExit;
79
+ });
80
+
81
+ describe('GithubAPI over HTTP', () => {
82
+ test('searchRepositories queries by name sorted by stars and enriches release info', async () => {
83
+ const api = new GithubAPI({ baseURL: base });
84
+ const results = await api.searchRepositories('alpha', { perPage: 2 });
85
+
86
+ const search = requests.find((r) => r.url.startsWith('/search/repositories'))!;
87
+ expect(decodeURIComponent(search.url).replace(/\+/g, ' ')).toContain('q=alpha in:name');
88
+ expect(search.url).toContain('sort=stars');
89
+ expect(search.url).toContain('order=desc');
90
+ expect(search.url).toContain('per_page=2');
91
+
92
+ expect(results).toHaveLength(2);
93
+ const alpha = results.find((r) => r.name === 'alpha')!;
94
+ const beta = results.find((r) => r.name === 'beta')!;
95
+ expect(alpha.hasReleases).toBe(true);
96
+ expect(alpha.allReleases.length).toBeGreaterThan(0);
97
+ expect(beta.hasReleases).toBe(false);
98
+ expect(beta.hasCompatibleReleases).toBe(false);
99
+ });
100
+
101
+ test('getReleaseInfo: false returns the raw items without extra requests', async () => {
102
+ const api = new GithubAPI({ baseURL: base });
103
+ const results = await api.searchRepositories('alpha', { getReleaseInfo: false });
104
+ expect(results.map((r) => r.name)).toEqual(['alpha', 'beta']);
105
+ expect(requests.filter((r) => r.url.endsWith('/releases'))).toHaveLength(0);
106
+ });
107
+
108
+ test('an empty response logs and returns no results', async () => {
109
+ mode = 'empty';
110
+ const api = new GithubAPI({ baseURL: base });
111
+ expect(await api.searchRepositories('nothing')).toEqual([]);
112
+ expect(logs.join('\n')).toContain('No response');
113
+ });
114
+
115
+ test('sends the token as an Authorization header', async () => {
116
+ const api = new GithubAPI({ baseURL: base, token: 'abc123' });
117
+ await api.getReleases('o', 'alpha');
118
+ expect(requests[0].auth).toBe('token abc123');
119
+ });
120
+
121
+ test('falls back to GITHUB_TOKEN from the environment', async () => {
122
+ const prev = process.env.GITHUB_TOKEN;
123
+ process.env.GITHUB_TOKEN = 'envtoken';
124
+ try {
125
+ const api = new GithubAPI({ baseURL: base });
126
+ await api.getReleases('o', 'alpha');
127
+ expect(requests[0].auth).toBe('token envtoken');
128
+ } finally {
129
+ if (prev === undefined) delete process.env.GITHUB_TOKEN;
130
+ else process.env.GITHUB_TOKEN = prev;
131
+ }
132
+ });
133
+
134
+ test('getReleases categorizes by platform; getCompatibleReleases filters', async () => {
135
+ const api = new GithubAPI({ baseURL: base });
136
+ const all = await api.getReleases('o', 'alpha');
137
+ expect(all).toHaveLength(1);
138
+ expect(all[0].tag_name).toBe('v1.0.0');
139
+ expect(requests[0].url).toBe('/repos/o/alpha/releases');
140
+
141
+ const compatible = await api.getCompatibleReleases('o', 'alpha');
142
+ expect(Array.isArray(compatible)).toBe(true);
143
+ });
144
+
145
+ test('a 403 tells the user to set GITHUB_TOKEN and exits 1', async () => {
146
+ mode = 'rate-limit';
147
+ const api = new GithubAPI({ baseURL: base });
148
+ await api.getReleases('o', 'alpha').catch(() => {});
149
+ expect(logs.join('\n')).toContain('Rate limit exceeded. Set the GITHUB_TOKEN env var.');
150
+ expect(exits).toContain(1);
151
+ });
152
+
153
+ test('delegates the small helpers', () => {
154
+ const api = new GithubAPI({ baseURL: base });
155
+ expect(api.getCurrentPlatform().platform).toBe(process.platform);
156
+ expect(api.parseTarget('vitejs/vite')).toMatchObject({ owner: 'vitejs', name: 'vite' });
157
+ expect(api.parseTarget('react starter')).toBe(false);
158
+ });
159
+ });
@@ -0,0 +1,47 @@
1
+ import { shell } from '../../src/utils.ts';
2
+
3
+ /**
4
+ * Replaces the CLI's process-spawning seam (see `shell` in src/utils.ts) with a
5
+ * recorder, so install and IDE-launch logic can be observed without running
6
+ * anything. Works under both `vitest` and `bun test`.
7
+ */
8
+ export interface FakeShell {
9
+ /** Commands run through `execSync`, excluding availability probes. */
10
+ commands: string[];
11
+ /** Availability probes (`command -v x` / `where x`), by tool name. */
12
+ probes: string[];
13
+ /** `spawn` calls as `[cmd, args]`. */
14
+ spawned: Array<[string, string[]]>;
15
+ restore(): void;
16
+ }
17
+
18
+ export function installFakeShell(
19
+ opts: { available?: string[]; failing?: string[] } = {}
20
+ ): FakeShell {
21
+ const { available = [], failing = [] } = opts;
22
+ const original = { execSync: shell.execSync, spawn: shell.spawn };
23
+ const fake: FakeShell = { commands: [], probes: [], spawned: [], restore() {} };
24
+
25
+ (shell as any).execSync = (cmd: string) => {
26
+ const probe = cmd.match(/^(?:command -v|where) (\S+)$/);
27
+ if (probe) {
28
+ fake.probes.push(probe[1]);
29
+ if (!available.includes(probe[1])) throw new Error(`${probe[1]}: not found`);
30
+ return Buffer.from('');
31
+ }
32
+ fake.commands.push(cmd);
33
+ if (failing.some((f) => cmd.startsWith(f))) throw new Error(`${cmd}: failed`);
34
+ return Buffer.from('');
35
+ };
36
+
37
+ (shell as any).spawn = (cmd: string, args: string[]) => {
38
+ fake.spawned.push([cmd, args]);
39
+ return { unref() {} };
40
+ };
41
+
42
+ fake.restore = () => {
43
+ (shell as any).execSync = original.execSync;
44
+ (shell as any).spawn = original.spawn;
45
+ };
46
+ return fake;
47
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { getInstalledIde, openInIDE } from '../src/ide.ts';
6
+ import { shell } from '../src/utils.ts';
7
+ import { installFakeShell, type FakeShell } from './helpers/fake-shell.ts';
8
+
9
+ describe('getInstalledIde', () => {
10
+ let fake: FakeShell;
11
+ afterEach(() => fake?.restore());
12
+
13
+ test('returns null when no supported editor is installed', () => {
14
+ fake = installFakeShell();
15
+ expect(getInstalledIde()).toBeNull();
16
+ expect(fake.probes).toEqual([
17
+ 'antigravity', 'cursor', 'windsurf', 'code', 'code-server', 'nvim', 'webstorm',
18
+ ]);
19
+ });
20
+
21
+ test('returns the first editor in priority order', () => {
22
+ fake = installFakeShell({ available: ['code', 'nvim', 'cursor'] });
23
+ expect(getInstalledIde()).toEqual({ name: 'Cursor', cmd: 'cursor' });
24
+ expect(fake.probes).toEqual(['antigravity', 'cursor']);
25
+ });
26
+
27
+ test('probes with `where` on Windows', () => {
28
+ fake = installFakeShell({ available: ['code'] });
29
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
30
+ Object.defineProperty(process, 'platform', { value: 'win32' });
31
+ try {
32
+ expect(getInstalledIde()?.cmd).toBe('code');
33
+ } finally {
34
+ Object.defineProperty(process, 'platform', platform);
35
+ }
36
+ });
37
+ });
38
+
39
+ describe('openInIDE', () => {
40
+ let fake: FakeShell;
41
+ let logs: string[];
42
+ let origLog: typeof console.log;
43
+ let tmp: string;
44
+
45
+ beforeEach(() => {
46
+ logs = [];
47
+ origLog = console.log;
48
+ console.log = (...a: unknown[]) => void logs.push(a.join(' '));
49
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-ide-'));
50
+ });
51
+
52
+ afterEach(() => {
53
+ console.log = origLog;
54
+ fake?.restore();
55
+ fs.rmSync(tmp, { recursive: true, force: true });
56
+ });
57
+
58
+ test('warns and spawns nothing without a supported IDE', () => {
59
+ fake = installFakeShell();
60
+ openInIDE(tmp);
61
+ expect(fake.spawned).toEqual([]);
62
+ expect(logs.join('\n')).toContain('No supported IDE found');
63
+ });
64
+
65
+ test('launches the IDE detached on the project directory', () => {
66
+ fake = installFakeShell({ available: ['code'] });
67
+ openInIDE(tmp);
68
+ expect(fake.spawned).toEqual([['code', [tmp]]]);
69
+ expect(logs.join('\n')).toContain(`Opening ${path.basename(tmp)} in VSCode`);
70
+ });
71
+
72
+ test('code-server gets --open', () => {
73
+ fake = installFakeShell({ available: ['code-server'] });
74
+ openInIDE(tmp);
75
+ expect(fake.spawned[0]).toEqual(['code-server', [tmp, '--open']]);
76
+ });
77
+
78
+ test('swallows launch errors', () => {
79
+ fake = installFakeShell({ available: ['code'] });
80
+ (shell as any).spawn = () => {
81
+ throw new Error('spawn failed');
82
+ };
83
+ expect(() => openInIDE(tmp)).not.toThrow();
84
+ expect(logs.join('\n')).not.toContain('Opening');
85
+ });
86
+
87
+ test('opens the README a few seconds later', async () => {
88
+ fake = installFakeShell({ available: ['code'] });
89
+ fs.writeFileSync(path.join(tmp, 'README.md'), '# hi');
90
+ openInIDE(tmp);
91
+ expect(fake.spawned).toHaveLength(1);
92
+ await new Promise((r) => setTimeout(r, 3200));
93
+ expect(fake.spawned).toHaveLength(2);
94
+ expect(fake.spawned[1]).toEqual(['code', [`${tmp}/README.md`]]);
95
+ }, 10_000);
96
+
97
+ test('falls back to package.json, and skips when there is no entry document', async () => {
98
+ fake = installFakeShell({ available: ['code'] });
99
+ fs.writeFileSync(path.join(tmp, 'package.json'), '{}');
100
+ openInIDE(tmp);
101
+ await new Promise((r) => setTimeout(r, 3200));
102
+ expect(fake.spawned[1]).toEqual(['code', [`${tmp}/package.json`]]);
103
+
104
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-ide-empty-'));
105
+ try {
106
+ const before = fake.spawned.length;
107
+ openInIDE(empty);
108
+ await new Promise((r) => setTimeout(r, 3200));
109
+ expect(fake.spawned.length).toBe(before + 1);
110
+ } finally {
111
+ fs.rmSync(empty, { recursive: true, force: true });
112
+ }
113
+ }, 15_000);
114
+ });
@@ -0,0 +1,132 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { installDependencies } from '../src/install.ts';
6
+ import { installFakeShell, type FakeShell } from './helpers/fake-shell.ts';
7
+
8
+ describe('installDependencies', () => {
9
+ let cwd: string;
10
+ let project: string;
11
+ let fake: FakeShell;
12
+
13
+ const touch = (...names: string[]) => {
14
+ for (const n of names) fs.writeFileSync(path.join(project, n), '');
15
+ };
16
+
17
+ beforeEach(() => {
18
+ cwd = process.cwd();
19
+ project = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-install-'));
20
+ });
21
+
22
+ afterEach(() => {
23
+ process.chdir(cwd);
24
+ fake?.restore();
25
+ fs.rmSync(project, { recursive: true, force: true });
26
+ });
27
+
28
+ test('changes into the target directory', async () => {
29
+ fake = installFakeShell();
30
+ await installDependencies(project);
31
+ expect(fs.realpathSync(process.cwd())).toBe(fs.realpathSync(project));
32
+ });
33
+
34
+ test('runs nothing for an empty project', async () => {
35
+ fake = installFakeShell({ available: ['bun'] });
36
+ await installDependencies(project);
37
+ expect(fake.commands).toEqual([]);
38
+ });
39
+
40
+ test('uses bun for a Node project when bun is available', async () => {
41
+ fake = installFakeShell({ available: ['bun'] });
42
+ touch('package.json');
43
+ await installDependencies(project);
44
+ expect(fake.commands).toEqual(['bun install', 'bun run dev; bun run start']);
45
+ });
46
+
47
+ test('falls back to npm for a Node project when bun is missing', async () => {
48
+ fake = installFakeShell();
49
+ touch('package.json');
50
+ await installDependencies(project);
51
+ expect(fake.probes).toEqual(['bun']);
52
+ expect(fake.commands).toEqual(['npm install', 'npm run dev; npm run start']);
53
+ });
54
+
55
+ test('probes with `where` on Windows', async () => {
56
+ fake = installFakeShell({ available: ['bun'] });
57
+ touch('package.json');
58
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
59
+ Object.defineProperty(process, 'platform', { value: 'win32' });
60
+ try {
61
+ await installDependencies(project);
62
+ } finally {
63
+ Object.defineProperty(process, 'platform', platform);
64
+ }
65
+ expect(fake.probes).toEqual(['bun']);
66
+ expect(fake.commands[0]).toBe('bun install');
67
+ });
68
+
69
+ test('prefers docker-compose over a bare Dockerfile', async () => {
70
+ fake = installFakeShell();
71
+ touch('Dockerfile', 'docker-compose.yml');
72
+ await installDependencies(project);
73
+ expect(fake.commands).toEqual(['sudo docker-compose up -d']);
74
+ });
75
+
76
+ test('builds the image when only a Dockerfile exists', async () => {
77
+ fake = installFakeShell();
78
+ touch('Dockerfile');
79
+ await installDependencies(project);
80
+ expect(fake.commands).toEqual(['sudo docker build -t project .']);
81
+ });
82
+
83
+ test('does nothing for docker-compose.yml alone being removed mid-run', async () => {
84
+ fake = installFakeShell();
85
+ touch('docker-compose.yml');
86
+ await installDependencies(project);
87
+ expect(fake.commands).toEqual(['sudo docker-compose up -d']);
88
+ });
89
+
90
+ test('sets up a venv and installs requirements.txt', async () => {
91
+ fake = installFakeShell();
92
+ touch('requirements.txt');
93
+ await installDependencies(project);
94
+ expect(fake.commands).toEqual([
95
+ 'python -m venv .venv',
96
+ 'source .venv/bin/activate',
97
+ 'pip install -r requirements.txt',
98
+ ]);
99
+ });
100
+
101
+ test('installs a setup.py project in editable mode', async () => {
102
+ fake = installFakeShell();
103
+ touch('setup.py');
104
+ await installDependencies(project);
105
+ expect(fake.commands).toEqual([
106
+ 'python -m venv .venv',
107
+ 'source .venv/bin/activate',
108
+ 'pip install -e .',
109
+ ]);
110
+ });
111
+
112
+ test('builds Rust and Go projects', async () => {
113
+ fake = installFakeShell();
114
+ touch('Cargo.toml', 'go.mod');
115
+ await installDependencies(project);
116
+ expect(fake.commands).toEqual(['cargo build', 'go mod tidy']);
117
+ });
118
+
119
+ test('keeps going when an install command fails', async () => {
120
+ fake = installFakeShell({ failing: ['cargo'] });
121
+ touch('Cargo.toml', 'go.mod');
122
+ await installDependencies(project);
123
+ expect(fake.commands).toEqual(['cargo build', 'go mod tidy']);
124
+ });
125
+
126
+ test('handles a polyglot project in detector order', async () => {
127
+ fake = installFakeShell({ available: ['bun'] });
128
+ touch('package.json', 'go.mod');
129
+ await installDependencies(project);
130
+ expect(fake.commands).toEqual(['bun install', 'bun run dev; bun run start', 'go mod tidy']);
131
+ });
132
+ });