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.
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.34",
4
+ "version": "0.3.36",
5
5
  "author": "vtempest",
6
6
  "scripts": {
7
7
  "build": "bun build src/cli.ts --outdir dist --target node --format esm --sourcemap",
package/src/fm.js CHANGED
@@ -4,9 +4,10 @@ import path from 'path';
4
4
  import readline from 'readline';
5
5
  import { execSync } from 'child_process';
6
6
  import os from 'os';
7
+ import { fileURLToPath } from 'url';
7
8
 
8
9
 
9
- class FileManager {
10
+ export class FileManager {
10
11
  constructor() {
11
12
  this.currentPath = process.cwd();
12
13
  this.selectedIndex = 0;
@@ -765,8 +766,8 @@ class FileManager {
765
766
  const selected = this.items[this.selectedIndex];
766
767
 
767
768
  if (selected.isDirectory) {
768
- this.addToHistory(this.currentPath);
769
769
  this.currentPath = selected.fullPath;
770
+ this.addToHistory(this.currentPath);
770
771
  this.selectedIndex = 0;
771
772
  this.selectedItems.clear();
772
773
  await this.loadDirectory();
@@ -786,14 +787,15 @@ class FileManager {
786
787
  async goUp() {
787
788
  const parentPath = path.dirname(this.currentPath);
788
789
  if (parentPath !== this.currentPath) {
789
- this.addToHistory(this.currentPath);
790
790
  this.currentPath = parentPath;
791
+ this.addToHistory(this.currentPath);
791
792
  this.selectedIndex = 0;
792
793
  this.selectedItems.clear();
793
794
  await this.loadDirectory();
794
795
  }
795
796
  }
796
797
 
798
+ // Records the directory just navigated *to*, so back/forward can return to it.
797
799
  addToHistory(path) {
798
800
  // Remove forward history when adding new path
799
801
  this.history = this.history.slice(0, this.historyIndex + 1);
@@ -990,8 +992,8 @@ class FileManager {
990
992
  const bookmarkPath = this.bookmarks[bookmarkName];
991
993
 
992
994
  if (fs.existsSync(bookmarkPath)) {
993
- this.addToHistory(this.currentPath);
994
995
  this.currentPath = bookmarkPath;
996
+ this.addToHistory(this.currentPath);
995
997
  this.selectedIndex = 0;
996
998
  this.selectedItems.clear();
997
999
  await this.loadDirectory();
@@ -1126,5 +1128,19 @@ class FileManager {
1126
1128
  }
1127
1129
  }
1128
1130
 
1129
- var fm = new FileManager()
1130
- fm.run()
1131
+ /**
1132
+ * True when this file is the program being run (the `fm` bin), as opposed to
1133
+ * being imported — so importing the class for tests doesn't take over the
1134
+ * terminal. `realpath` on both sides because npm links bins through symlinks.
1135
+ */
1136
+ function isMainModule() {
1137
+ try {
1138
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
1139
+ } catch {
1140
+ return false;
1141
+ }
1142
+ }
1143
+
1144
+ if (isMainModule()) {
1145
+ new FileManager().run();
1146
+ }
package/src/github-api.ts CHANGED
@@ -104,6 +104,20 @@ export function parseTarget(query: string): RepoTarget | false {
104
104
  * const repos = await github.searchRepositories('nodejs template');
105
105
  * const dir = await github.downloadRepo('facebook/react');
106
106
  */
107
+ /**
108
+ * Coerces a `/releases` response to a plain array.
109
+ *
110
+ * `grab-api.js` hands a JSON array back as an array-like object
111
+ * (`{ 0: …, 1: …, data: […] }`), which has no `length`, so every
112
+ * `releases.length` check downstream would read as "no releases". Older
113
+ * versions return the bare array; accept both.
114
+ */
115
+ function toReleaseList(response: any): any[] {
116
+ if (Array.isArray(response)) return response;
117
+ if (Array.isArray(response?.data)) return response.data;
118
+ return [];
119
+ }
120
+
107
121
  class GithubAPI {
108
122
  /** Default number of repository search results returned per query. */
109
123
  static DEFAULT_RESULTS_PER_PAGE = 10;
@@ -216,8 +230,8 @@ class GithubAPI {
216
230
 
217
231
  return Promise.all(
218
232
  response.items.map(async (repo: SearchResult) => {
219
- const releases = await this.callGithub(
220
- `/repos/${repo.owner.login}/${repo.name}/releases`
233
+ const releases = toReleaseList(
234
+ await this.callGithub(`/repos/${repo.owner.login}/${repo.name}/releases`)
221
235
  );
222
236
  const platform = getCurrentPlatform();
223
237
 
@@ -321,7 +335,7 @@ class GithubAPI {
321
335
  * const releases = await github.getReleases('microsoft', 'vscode');
322
336
  */
323
337
  async getReleases(owner: string, repo: string): Promise<CategorizedRelease[]> {
324
- const releases = await this.callGithub(`/repos/${owner}/${repo}/releases`);
338
+ const releases = toReleaseList(await this.callGithub(`/repos/${owner}/${repo}/releases`));
325
339
  return categorizeReleasesByPlatform(releases);
326
340
  }
327
341
 
@@ -335,7 +349,7 @@ class GithubAPI {
335
349
  * current platform (including `universal` assets).
336
350
  */
337
351
  async getCompatibleReleases(owner: string, repo: string): Promise<CategorizedRelease[]> {
338
- const releases = await this.callGithub(`/repos/${owner}/${repo}/releases`);
352
+ const releases = toReleaseList(await this.callGithub(`/repos/${owner}/${repo}/releases`));
339
353
  return filterReleasesByPlatform(releases, getCurrentPlatform());
340
354
  }
341
355
  }
package/src/ide.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { execSync, spawn } from 'child_process';
3
2
  import fs from 'fs';
4
3
  import path from 'path';
4
+ import { shell } from './utils.js';
5
5
  import type { IdeInfo } from './types.js';
6
6
 
7
7
  /**
@@ -39,7 +39,7 @@ export function getInstalledIde(): IdeInfo | null {
39
39
 
40
40
  for (const ide of SUPPORTED_IDES) {
41
41
  try {
42
- execSync(probe(ide.cmd), { stdio: 'ignore' });
42
+ shell.execSync(probe(ide.cmd), { stdio: 'ignore' });
43
43
  return ide;
44
44
  } catch {
45
45
  continue;
@@ -96,7 +96,7 @@ function findEntryDocument(dir = '.'): string | null {
96
96
  * @internal
97
97
  */
98
98
  function spawnDetached(ide: IdeInfo, args: string[]): void {
99
- spawn(ide.cmd, args, {
99
+ shell.spawn(ide.cmd, args, {
100
100
  detached: true,
101
101
  stdio: 'ignore',
102
102
  shell: process.platform === 'win32',
package/src/install.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { execSync } from 'child_process';
2
1
  import fs from 'fs';
3
- import { exec } from './utils.js';
2
+ import { exec, shell } from './utils.js';
4
3
 
5
4
  /**
6
5
  * A detector checks whether a specific project type is present in the current
@@ -28,7 +27,7 @@ type Installer = () => void;
28
27
  */
29
28
  function bunAvailable(): boolean {
30
29
  try {
31
- execSync(
30
+ shell.execSync(
32
31
  process.platform === 'win32' ? 'where bun' : 'command -v bun',
33
32
  { stdio: 'ignore' }
34
33
  );
package/src/utils.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import chalk from 'chalk';
2
- import { execSync } from 'child_process';
2
+ import { execSync, spawn } from 'child_process';
3
3
 
4
4
  /** Set once the logo has been printed, so repeat calls are no-ops. */
5
+ /**
6
+ * The process-spawning primitives the rest of the CLI shells out through.
7
+ *
8
+ * Routed through one object so tests can replace them: `bun test` (what CI runs
9
+ * for this package) supports neither `vi.mock` nor a runtime `PATH` change for
10
+ * `child_process`, so a seam like this is the only way to observe — rather than
11
+ * perform — an install or an IDE launch.
12
+ */
13
+ export const shell = { execSync, spawn };
14
+
5
15
  let logoPrinted = false;
6
16
 
7
17
  /**
@@ -62,7 +72,7 @@ export function resetLogo(): void {
62
72
  */
63
73
  export function exec(cmd: string, showError = false): void {
64
74
  try {
65
- execSync(cmd, { stdio: 'inherit' });
75
+ shell.execSync(cmd, { stdio: 'inherit' });
66
76
  } catch {
67
77
  if (showError) console.error(chalk.red(`❌ Failed: ${cmd}`));
68
78
  }
@@ -0,0 +1,51 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ /**
4
+ * Only the offline branch of the CLI entry is exercised here: with no
5
+ * arguments it prints usage and exits 1 before touching the network. The
6
+ * download and search branches talk to GitHub and are covered through
7
+ * downloadRepo / GithubAPI instead.
8
+ */
9
+ describe('cli entry', () => {
10
+ let lines: string[];
11
+ let exits: Array<number | undefined>;
12
+ let origLog: typeof console.log;
13
+ let origErr: typeof console.error;
14
+ let origExit: typeof process.exit;
15
+ let origArgv: string[];
16
+
17
+ beforeEach(() => {
18
+ lines = [];
19
+ exits = [];
20
+ origLog = console.log;
21
+ origErr = console.error;
22
+ origExit = process.exit;
23
+ origArgv = process.argv;
24
+ console.log = (...a: unknown[]) => void lines.push(a.join(' '));
25
+ console.error = (...a: unknown[]) => void lines.push(a.join(' '));
26
+ (process as any).exit = (code?: number) => {
27
+ exits.push(code);
28
+ // First exit halts main(); the .catch handler's own exit must not throw.
29
+ if (exits.length === 1) throw new Error('process.exit');
30
+ };
31
+ });
32
+
33
+ afterEach(() => {
34
+ console.log = origLog;
35
+ console.error = origErr;
36
+ process.exit = origExit;
37
+ process.argv = origArgv;
38
+ });
39
+
40
+ test('prints usage and exits 1 when given no arguments', async () => {
41
+ process.argv = ['bun', 'cli.ts'];
42
+ await import('../src/cli.ts?no-args');
43
+ await new Promise((r) => setTimeout(r, 20));
44
+
45
+ const text = lines.join('\n');
46
+ expect(text).toContain('Usage: git0 <github-url | owner/repo[/path] | search-query>');
47
+ expect(text).toContain('--history-only');
48
+ expect(text).toContain('--mirror');
49
+ expect(exits[0]).toBe(1);
50
+ });
51
+ });
@@ -0,0 +1,153 @@
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 { Readable } from 'stream';
6
+ import { downloadPackage, printInstallInstructions } from '../src/download.ts';
7
+ import { showPackageMenu, buildReleaseChoices } from '../src/package-menu.ts';
8
+
9
+ /** Captures console.log / console.error output for the duration of a test. */
10
+ function captureConsole() {
11
+ const lines: string[] = [];
12
+ const orig = { log: console.log, error: console.error };
13
+ console.log = (...a: unknown[]) => void lines.push(a.join(' '));
14
+ console.error = (...a: unknown[]) => void lines.push(a.join(' '));
15
+ return {
16
+ text: () => lines.join('\n'),
17
+ restore() {
18
+ console.log = orig.log;
19
+ console.error = orig.error;
20
+ },
21
+ };
22
+ }
23
+
24
+ /** Pretends to run on `platform` for both `process.platform` and `os.platform()`. */
25
+ async function withPlatform<T>(platform: string, fn: () => T | Promise<T>): Promise<T> {
26
+ const original = Object.getOwnPropertyDescriptor(process, 'platform')!;
27
+ const originalOs = os.platform;
28
+ Object.defineProperty(process, 'platform', { value: platform });
29
+ (os as any).platform = () => platform;
30
+ try {
31
+ return await fn();
32
+ } finally {
33
+ Object.defineProperty(process, 'platform', original);
34
+ (os as any).platform = originalOs;
35
+ }
36
+ }
37
+
38
+ /** A `callGithub` stand-in that streams `body` to `onStream`, like `grab` does. */
39
+ const fakeGithub =
40
+ (body: string | Error) =>
41
+ async (_url: string, params: { onStream: (r: ReadableStream) => Promise<void> }) => {
42
+ if (body instanceof Error) throw body;
43
+ await params.onStream(Readable.toWeb(Readable.from([Buffer.from(body)])) as ReadableStream);
44
+ return {};
45
+ };
46
+
47
+ describe('downloadPackage', () => {
48
+ let tmp: string;
49
+ let con: ReturnType<typeof captureConsole>;
50
+
51
+ beforeEach(() => {
52
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-pkg-'));
53
+ con = captureConsole();
54
+ });
55
+
56
+ afterEach(() => {
57
+ con.restore();
58
+ fs.rmSync(tmp, { recursive: true, force: true });
59
+ });
60
+
61
+ test('streams the asset to disk and returns its path', async () => {
62
+ const dest = path.join(tmp, 'app.deb');
63
+ const result = await downloadPackage(fakeGithub('payload'), 'https://x/app.deb', dest);
64
+ expect(result).toBe(dest);
65
+ expect(fs.readFileSync(dest, 'utf8')).toBe('payload');
66
+ expect(con.text()).toContain('Downloaded app.deb');
67
+ expect(con.text()).toContain('sudo dpkg -i');
68
+ });
69
+
70
+ test('marks extension-less binaries executable (non-Windows)', async () => {
71
+ const dest = path.join(tmp, 'tool');
72
+ await downloadPackage(fakeGithub('#!/bin/sh\n'), 'https://x/tool', dest);
73
+ expect(fs.statSync(dest).mode & 0o111).not.toBe(0);
74
+ expect(con.text()).toContain('Made tool executable');
75
+ expect(con.text()).toContain('Binary is ready to use');
76
+ });
77
+
78
+ test('does not chmod on Windows', async () => {
79
+ const dest = path.join(tmp, 'tool');
80
+ await withPlatform('win32', () => downloadPackage(fakeGithub('x'), 'https://x/tool', dest));
81
+ expect(con.text()).not.toContain('executable');
82
+ });
83
+
84
+ test('reports and rethrows download failures', async () => {
85
+ const dest = path.join(tmp, 'app.deb');
86
+ await expect(
87
+ downloadPackage(fakeGithub(new Error('HTTP 404')), 'https://x/app.deb', dest)
88
+ ).rejects.toThrow('HTTP 404');
89
+ expect(con.text()).toContain('Failed to download app.deb');
90
+ });
91
+ });
92
+
93
+ describe('printInstallInstructions', () => {
94
+ let con: ReturnType<typeof captureConsole>;
95
+ beforeEach(() => void (con = captureConsole()));
96
+ afterEach(() => con.restore());
97
+
98
+ const run = async (platform: string, file: string) => {
99
+ await withPlatform(platform, () => printInstallInstructions(`/dl/${file}`, file));
100
+ return con.text();
101
+ };
102
+
103
+ test('Windows: exe and msi', async () => {
104
+ expect(await run('win32', 'a.exe')).toContain('Run the executable');
105
+ expect(await run('win32', 'a.msi')).toContain('msiexec /i "/dl/a.msi"');
106
+ });
107
+
108
+ test('Windows: other files print nothing', async () => {
109
+ expect(await run('win32', 'a.zip')).toBe('');
110
+ });
111
+
112
+ test('macOS: dmg and pkg', async () => {
113
+ expect(await run('darwin', 'a.dmg')).toContain('open "/dl/a.dmg"');
114
+ expect(await run('darwin', 'a.pkg')).toContain('sudo installer -pkg "/dl/a.pkg" -target /');
115
+ });
116
+
117
+ test('macOS: other files print nothing', async () => {
118
+ expect(await run('darwin', 'a.zip')).toBe('');
119
+ });
120
+
121
+ test('Linux: deb, rpm, AppImage and bare binaries', async () => {
122
+ expect(await run('linux', 'a.deb')).toContain('sudo dpkg -i "/dl/a.deb"');
123
+ expect(await run('linux', 'a.rpm')).toContain('sudo rpm -i "/dl/a.rpm"');
124
+ expect(await run('linux', 'a.AppImage')).toContain('chmod +x "/dl/a.AppImage"');
125
+ expect(await run('linux', 'bin')).toContain('sudo mv "/dl/bin" /usr/local/bin/');
126
+ });
127
+
128
+ test('Linux: archives print nothing', async () => {
129
+ expect(await run('linux', 'a.tar.gz')).toBe('');
130
+ });
131
+ });
132
+
133
+ describe('showPackageMenu', () => {
134
+ test('says so and downloads nothing when no packages are selectable', async () => {
135
+ const con = captureConsole();
136
+ let called = false;
137
+ try {
138
+ await showPackageMenu(
139
+ { allReleases: [] },
140
+ async () => {
141
+ called = true;
142
+ return '';
143
+ },
144
+ { os: 'linux', arch: 'x64', platform: 'linux' } as any
145
+ );
146
+ expect(con.text()).toContain('No packages found for download.');
147
+ expect(called).toBe(false);
148
+ expect(buildReleaseChoices([], { os: 'linux' } as any)).toEqual([]);
149
+ } finally {
150
+ con.restore();
151
+ }
152
+ });
153
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
+ import { exec } from '../src/utils.ts';
3
+ import { installFakeShell, type FakeShell } from './helpers/fake-shell.ts';
4
+
5
+ describe('exec', () => {
6
+ let fake: FakeShell;
7
+ let errors: string[];
8
+ let origError: typeof console.error;
9
+
10
+ beforeEach(() => {
11
+ errors = [];
12
+ origError = console.error;
13
+ console.error = (...a: unknown[]) => void errors.push(a.join(' '));
14
+ });
15
+
16
+ afterEach(() => {
17
+ console.error = origError;
18
+ fake?.restore();
19
+ });
20
+
21
+ test('runs the command through the shell', () => {
22
+ fake = installFakeShell();
23
+ exec('tool --flag value');
24
+ expect(fake.commands).toEqual(['tool --flag value']);
25
+ });
26
+
27
+ test('swallows failures silently by default', () => {
28
+ fake = installFakeShell({ failing: ['tool'] });
29
+ expect(() => exec('tool')).not.toThrow();
30
+ expect(errors).toEqual([]);
31
+ });
32
+
33
+ test('reports the failed command when showError is set', () => {
34
+ fake = installFakeShell({ failing: ['tool'] });
35
+ exec('tool', true);
36
+ expect(errors.join('\n')).toContain('Failed: tool');
37
+ });
38
+ });