git0 0.2.13 → 0.2.14

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.
Files changed (71) hide show
  1. package/dist/cli.js +27780 -0
  2. package/dist/cli.js.map +331 -0
  3. package/docs/404.html +2 -2
  4. package/docs/Footer/index.html +2 -2
  5. package/docs/assets/js/1df93b7f.1385eba0.js +2 -0
  6. package/docs/assets/js/22dd74f7.e13ac7a4.js +1 -0
  7. package/docs/assets/js/28ab763d.192aa3bb.js +1 -0
  8. package/docs/assets/js/299d276b.c9496717.js +1 -0
  9. package/docs/assets/js/{68cef36b.c312447e.js → 68cef36b.e5b1975b.js} +1 -1
  10. package/docs/assets/js/a9cef9d5.a7253e41.js +1 -0
  11. package/docs/assets/js/c3a618e1.1c56fb03.js +590 -0
  12. package/docs/assets/js/{main.cf858a7d.js → main.1f646b09.js} +2 -2
  13. package/docs/assets/js/runtime~main.f69f8f44.js +1 -0
  14. package/docs/functions/fm/index.html +26 -0
  15. package/docs/functions/git0/index.html +6 -6
  16. package/docs/functions/github-api/index.html +20 -20
  17. package/docs/functions/index.html +889 -71
  18. package/docs/functions/modules/index.html +4 -3
  19. package/docs/index.html +2 -2
  20. package/docs/lunr-index-1749760982052.json +1 -0
  21. package/docs/lunr-index.json +1 -1
  22. package/docs/search-doc-1749760982052.json +1 -0
  23. package/docs/search-doc.json +1 -1
  24. package/docs/sitemap.xml +1 -1
  25. package/docs-config/.docusaurus/client-manifest.json +36 -24
  26. package/docs-config/.docusaurus/docusaurus-plugin-content-docs/default/p/index-466.json +1 -1
  27. package/docs-config/.docusaurus/docusaurus-plugin-content-docs/default/site-src-functions-fm-md-a9c.json +19 -0
  28. package/docs-config/.docusaurus/docusaurus-plugin-content-docs/default/site-src-functions-git-0-md-299.json +4 -0
  29. package/docs-config/.docusaurus/docusaurus-plugin-content-docs/default/site-src-functions-index-md-c3a.json +1 -1
  30. package/docs-config/.docusaurus/globalData.json +10 -5
  31. package/docs-config/.docusaurus/registry.js +1 -0
  32. package/docs-config/.docusaurus/routes.js +9 -3
  33. package/docs-config/.docusaurus/routesChunkNames.json +7 -3
  34. package/docs-config/src/functions/fm.md +1 -0
  35. package/docs-config/src/functions/git0.md +2 -2
  36. package/docs-config/src/functions/github-api.md +34 -17
  37. package/docs-config/src/functions/index.md +19 -30
  38. package/docs-config/src/functions/modules.md +1 -0
  39. package/docs-config/src/functions/typedoc-sidebar.cjs +5 -0
  40. package/docs-config/src/pages/index.tsx +2 -3
  41. package/package.json +20 -10
  42. package/readme.md +19 -21
  43. package/src/cli.ts +150 -0
  44. package/src/download.ts +240 -0
  45. package/src/fm.js +0 -0
  46. package/src/github-api.ts +237 -0
  47. package/src/ide.ts +141 -0
  48. package/src/install.ts +147 -0
  49. package/src/package-menu.ts +183 -0
  50. package/src/platform.ts +53 -0
  51. package/src/releases.ts +159 -0
  52. package/src/setup.ts +9 -0
  53. package/src/types.ts +97 -0
  54. package/src/utils.ts +49 -0
  55. package/test/download.test.ts +48 -0
  56. package/test/github-api.test.ts +79 -0
  57. package/test/package-menu.test.ts +134 -0
  58. package/test/platform.test.ts +64 -0
  59. package/test/releases.test.ts +169 -0
  60. package/docs/assets/js/1df93b7f.d8c05d2c.js +0 -2
  61. package/docs/assets/js/22dd74f7.237398b4.js +0 -1
  62. package/docs/assets/js/28ab763d.5714aa16.js +0 -1
  63. package/docs/assets/js/299d276b.1a1baa1c.js +0 -1
  64. package/docs/assets/js/c3a618e1.965a31da.js +0 -1
  65. package/docs/assets/js/runtime~main.7520dc36.js +0 -1
  66. package/docs/lunr-index-1749613752315.json +0 -1
  67. package/docs/search-doc-1749613752315.json +0 -1
  68. package/src/git0.js +0 -379
  69. package/src/github-api.js +0 -472
  70. /package/docs/assets/js/{1df93b7f.d8c05d2c.js.LICENSE.txt → 1df93b7f.1385eba0.js.LICENSE.txt} +0 -0
  71. /package/docs/assets/js/{main.cf858a7d.js.LICENSE.txt → main.1f646b09.js.LICENSE.txt} +0 -0
@@ -0,0 +1,237 @@
1
+ import { grab } from 'grab-api.js';
2
+ import chalk from 'chalk';
3
+ import gitUrlParse from 'git-url-parse';
4
+ import type { PlatformInfo, CategorizedRelease, SearchResult } from './types.js';
5
+ import { getCurrentPlatform } from './platform.js';
6
+ import { categorizeReleasesByPlatform, filterReleasesByPlatform } from './releases.js';
7
+ import { downloadRepo, downloadPackage } from './download.js';
8
+
9
+ /**
10
+ * GitHub API client for searching repositories, downloading source tarballs,
11
+ * and fetching release assets.
12
+ *
13
+ * Internally uses a `grab` instance pre-configured with the GitHub base URL,
14
+ * optional bearer token, and a 403 rate-limit handler.
15
+ *
16
+ * @example
17
+ * const github = new GithubAPI({ token: process.env.GITHUB_TOKEN });
18
+ * const repos = await github.searchRepositories('nodejs template');
19
+ * const dir = await github.downloadRepo('facebook/react');
20
+ */
21
+ class GithubAPI {
22
+ /** Default number of repository search results returned per query. */
23
+ static DEFAULT_RESULTS_PER_PAGE = 10;
24
+
25
+ private callGithub: ReturnType<typeof grab.instance>;
26
+
27
+ /**
28
+ * Creates a new GithubAPI instance and initialises the underlying HTTP client.
29
+ *
30
+ * When no `token` is supplied the constructor falls back to the
31
+ * `GITHUB_TOKEN` environment variable. Unauthenticated requests are
32
+ * limited to 60/hour by GitHub; a token raises this to 5 000/hour.
33
+ *
34
+ * @param options.token - GitHub personal access token.
35
+ * @param options.debug - Pass `true` to enable `grab` request logging.
36
+ * @param options.baseURL - Override the GitHub REST API base URL
37
+ * (useful for GitHub Enterprise).
38
+ */
39
+ constructor(options: { token?: string; debug?: boolean; baseURL?: string } = {}) {
40
+ const token = options.token || process.env.GITHUB_TOKEN;
41
+ const debug = options.debug ?? false;
42
+ const baseURL = options.baseURL || 'https://api.github.com';
43
+
44
+ this.callGithub = grab.instance({
45
+ debug,
46
+ baseURL,
47
+ timeout: 500,
48
+ headers: token ? { Authorization: `token ${token}` } : {},
49
+ onError: (error: string) => {
50
+ if (error.includes('403')) {
51
+ console.log(chalk.red(
52
+ 'Rate limit exceeded. Set the GITHUB_TOKEN env var.\n' +
53
+ 'Create a token at https://github.com/settings/personal-access-tokens/new'
54
+ ));
55
+ process.exit(1);
56
+ }
57
+ },
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Downloads a GitHub repository as a tarball and extracts it locally.
63
+ *
64
+ * Delegates to {@link downloadRepo} in `download.ts`.
65
+ * Falls back from the `master` branch to `main` when the first attempt fails.
66
+ *
67
+ * @param repo - Full GitHub URL or `owner/repo` shorthand.
68
+ * @param targetDir - Optional extraction folder name; defaults to the repo name.
69
+ * @returns Absolute path of the extracted project directory.
70
+ *
71
+ * @example
72
+ * const dir = await github.downloadRepo('https://github.com/vitejs/vite');
73
+ * // → '/current/working/dir/vite'
74
+ */
75
+ async downloadRepo(repo: string, targetDir: string | null = null): Promise<string> {
76
+ return downloadRepo(this.callGithub, repo, targetDir);
77
+ }
78
+
79
+ /**
80
+ * Searches GitHub repositories by name and enriches each result with
81
+ * release availability information.
82
+ *
83
+ * Results are sorted by stars descending by default. When `getReleaseInfo`
84
+ * is true (the default), the function fires one extra API request per result
85
+ * to fetch release data — all requests run concurrently via `Promise.all`.
86
+ *
87
+ * @param query - Search string matched against repository names.
88
+ * @param options.perPage - Maximum results (default {@link DEFAULT_RESULTS_PER_PAGE}).
89
+ * @param options.sort - GitHub sort field: `'stars'` | `'forks'` | `'updated'`.
90
+ * @param options.order - Sort direction: `'asc'` | `'desc'`.
91
+ * @param options.getReleaseInfo - When `false`, skips the per-repo release fetch.
92
+ * @returns Array of {@link SearchResult} objects ordered by `sort`/`order`.
93
+ * @throws Re-throws any network or API error after logging it.
94
+ *
95
+ * @example
96
+ * const repos = await github.searchRepositories('react starter');
97
+ * repos.forEach(r => console.log(r.full_name, r.hasCompatibleReleases));
98
+ */
99
+ async searchRepositories(
100
+ query: string,
101
+ options: { perPage?: number; sort?: string; order?: string; getReleaseInfo?: boolean } = {}
102
+ ): Promise<SearchResult[]> {
103
+ const {
104
+ perPage = GithubAPI.DEFAULT_RESULTS_PER_PAGE,
105
+ sort = 'stars',
106
+ order = 'desc',
107
+ getReleaseInfo = true,
108
+ } = options;
109
+
110
+ try {
111
+ const response = await this.callGithub('/search/repositories', {
112
+ q: `${query} in:name`,
113
+ sort,
114
+ order,
115
+ per_page: perPage,
116
+ });
117
+
118
+ if (response.error || !response.items) {
119
+ console.log('No response');
120
+ return [];
121
+ }
122
+
123
+ if (!getReleaseInfo) return response.items;
124
+
125
+ return Promise.all(
126
+ response.items.map(async (repo: SearchResult) => {
127
+ const releases = await this.callGithub(
128
+ `/repos/${repo.owner.login}/${repo.name}/releases`
129
+ );
130
+ const platform = getCurrentPlatform();
131
+
132
+ return {
133
+ ...repo,
134
+ hasReleases: releases?.length > 0,
135
+ hasCompatibleReleases: filterReleasesByPlatform(releases, platform).length > 0,
136
+ releases: filterReleasesByPlatform(releases, platform),
137
+ allReleases: categorizeReleasesByPlatform(releases),
138
+ };
139
+ })
140
+ );
141
+ } catch (error: any) {
142
+ console.error(chalk.red('Search failed:'), error.message);
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Downloads a single release asset binary to disk.
149
+ *
150
+ * Delegates to {@link downloadPackage} in `download.ts`.
151
+ *
152
+ * @param packageURL - Direct HTTPS download URL for the asset.
153
+ * @param downloadPath - Absolute local path to write the file to.
154
+ * @returns The `downloadPath` on success.
155
+ * @throws When the download request fails.
156
+ */
157
+ async downloadPackage(packageURL: string, downloadPath: string): Promise<string> {
158
+ return downloadPackage(this.callGithub, packageURL, downloadPath);
159
+ }
160
+
161
+ /**
162
+ * Parses a GitHub URL or `owner/repo` shorthand into a structured URL object.
163
+ *
164
+ * Accepts:
165
+ * - Full HTTPS URLs: `https://github.com/owner/repo`
166
+ * - SSH URLs: `git@github.com:owner/repo`
167
+ * - Git protocol: `git://github.com/owner/repo`
168
+ * - Shorthand: `owner/repo`
169
+ *
170
+ * @param query - The string to parse.
171
+ * @returns A `git-url-parse` result object, or `false` when the input does
172
+ * not look like a GitHub reference.
173
+ *
174
+ * @example
175
+ * github.parseURL('facebook/react');
176
+ * // → { owner: 'facebook', name: 'react', href: 'https://github.com/facebook/react', … }
177
+ *
178
+ * github.parseURL('just a search query'); // → false
179
+ */
180
+ parseURL(query: string): ReturnType<typeof gitUrlParse> | false {
181
+ if (
182
+ query.includes('github.com') ||
183
+ query.startsWith('git@github.com:') ||
184
+ query.startsWith('https://') ||
185
+ query.startsWith('git://')
186
+ ) {
187
+ return gitUrlParse(query);
188
+ }
189
+ if (/^[\w-]+\/[\w.-]+$/.test(query)) {
190
+ return gitUrlParse(`https://github.com/${query}`);
191
+ }
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Returns normalized OS and CPU architecture for the current machine.
197
+ *
198
+ * Delegates to {@link getCurrentPlatform} from `platform.ts`.
199
+ *
200
+ * @returns {@link PlatformInfo} with canonical OS/arch strings.
201
+ */
202
+ getCurrentPlatform(): PlatformInfo {
203
+ return getCurrentPlatform();
204
+ }
205
+
206
+ /**
207
+ * Fetches all releases for a repo and categorizes their assets by platform.
208
+ *
209
+ * @param owner - GitHub username or organisation name.
210
+ * @param repo - Repository name.
211
+ * @returns Array of {@link CategorizedRelease} objects, one per release that
212
+ * has at least one downloadable asset.
213
+ *
214
+ * @example
215
+ * const releases = await github.getReleases('microsoft', 'vscode');
216
+ */
217
+ async getReleases(owner: string, repo: string): Promise<CategorizedRelease[]> {
218
+ const releases = await this.callGithub(`/repos/${owner}/${repo}/releases`);
219
+ return categorizeReleasesByPlatform(releases);
220
+ }
221
+
222
+ /**
223
+ * Fetches releases that have at least one asset compatible with the current
224
+ * operating system.
225
+ *
226
+ * @param owner - GitHub username or organisation name.
227
+ * @param repo - Repository name.
228
+ * @returns Subset of {@link CategorizedRelease} objects compatible with the
229
+ * current platform (including `universal` assets).
230
+ */
231
+ async getCompatibleReleases(owner: string, repo: string): Promise<CategorizedRelease[]> {
232
+ const releases = await this.callGithub(`/repos/${owner}/${repo}/releases`);
233
+ return filterReleasesByPlatform(releases, getCurrentPlatform());
234
+ }
235
+ }
236
+
237
+ export default GithubAPI;
package/src/ide.ts ADDED
@@ -0,0 +1,141 @@
1
+ import chalk from 'chalk';
2
+ import { execSync, spawn } from 'child_process';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import type { IdeInfo } from './types.js';
6
+
7
+ /**
8
+ * Ordered list of editors to probe. The first one found on `PATH` wins.
9
+ * Add new entries at the top to give them higher priority.
10
+ *
11
+ * @internal
12
+ */
13
+ const SUPPORTED_IDES: IdeInfo[] = [
14
+ { name: 'Antigravity', cmd: 'antigravity' },
15
+ { name: 'Cursor', cmd: 'cursor' },
16
+ { name: 'Windsurf', cmd: 'windsurf' },
17
+ { name: 'VSCode', cmd: 'code' },
18
+ { name: 'Code Server', cmd: 'code-server' },
19
+ { name: 'Neovim', cmd: 'nvim' },
20
+ { name: 'Webstorm', cmd: 'webstorm' },
21
+ ];
22
+
23
+ /**
24
+ * Probes `PATH` for the first IDE in {@link SUPPORTED_IDES} that is installed.
25
+ *
26
+ * Uses `where` on Windows and `command -v` on Unix. The check is intentionally
27
+ * silent (`stdio: 'ignore'`) so no output leaks into the CLI.
28
+ *
29
+ * @returns The first installed {@link IdeInfo}, or `null` when none are found.
30
+ *
31
+ * @example
32
+ * const ide = getInstalledIde();
33
+ * if (ide) console.log(`Found: ${ide.name}`);
34
+ */
35
+ export function getInstalledIde(): IdeInfo | null {
36
+ const probe = process.platform === 'win32'
37
+ ? (cmd: string) => `where ${cmd}`
38
+ : (cmd: string) => `command -v ${cmd}`;
39
+
40
+ for (const ide of SUPPORTED_IDES) {
41
+ try {
42
+ execSync(probe(ide.cmd), { stdio: 'ignore' });
43
+ return ide;
44
+ } catch {
45
+ continue;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Builds the argument list for spawning an editor process.
53
+ *
54
+ * `code-server` requires a `--open` flag to actually open the browser;
55
+ * all other editors accept a bare path.
56
+ *
57
+ * @param ide - The IDE to build args for.
58
+ * @param target - Path to open (directory or file).
59
+ * @returns Argument array suitable for `spawn(ide.cmd, args)`.
60
+ *
61
+ * @internal
62
+ */
63
+ function buildIdeArgs(ide: IdeInfo, target: string): string[] {
64
+ return ide.cmd === 'code-server' ? [target, '--open'] : [target];
65
+ }
66
+
67
+ /**
68
+ * Locates the entry-point document to open after the IDE loads the project
69
+ * folder. Checks for README files first (case-insensitive), then falls back
70
+ * to `package.json`.
71
+ *
72
+ * @param dir - Directory to search inside (defaults to `process.cwd()`).
73
+ * @returns Relative path of the first match, or `null` if nothing is found.
74
+ *
75
+ * @internal
76
+ */
77
+ function findEntryDocument(dir = '.'): string | null {
78
+ const candidates = [
79
+ `${dir}/readme.md`,
80
+ `${dir}/Readme.md`,
81
+ `${dir}/README.md`,
82
+ `${dir}/package.json`,
83
+ ];
84
+ return candidates.find(p => fs.existsSync(p)) ?? null;
85
+ }
86
+
87
+ /**
88
+ * Spawns an IDE process in a detached, fire-and-forget manner.
89
+ *
90
+ * The process is detached and unreffed so it outlives the parent CLI process.
91
+ * Shell mode is enabled on Windows to handle PATH resolution correctly.
92
+ *
93
+ * @param ide - IDE to launch.
94
+ * @param args - Arguments to pass (e.g. path to open).
95
+ *
96
+ * @internal
97
+ */
98
+ function spawnDetached(ide: IdeInfo, args: string[]): void {
99
+ spawn(ide.cmd, args, {
100
+ detached: true,
101
+ stdio: 'ignore',
102
+ shell: process.platform === 'win32',
103
+ }).unref();
104
+ }
105
+
106
+ /**
107
+ * Opens a project directory in the first available IDE and, after a short
108
+ * delay, also opens the project's entry document (README or `package.json`).
109
+ *
110
+ * The 3-second delay gives the IDE time to finish loading the workspace before
111
+ * it receives the file-open command — opening too early can cause some editors
112
+ * to ignore the second argument.
113
+ *
114
+ * Does nothing and prints a warning when no supported IDE is installed.
115
+ *
116
+ * @param targetDir - Absolute path to the project directory to open.
117
+ *
118
+ * @example
119
+ * openInIDE('/home/user/projects/my-app');
120
+ * // Logs: 🚀 Opening my-app in Cursor
121
+ */
122
+ export function openInIDE(targetDir: string): void {
123
+ const ide = getInstalledIde();
124
+ if (!ide) {
125
+ console.log(chalk.yellow('⚠️ No supported IDE found'));
126
+ return;
127
+ }
128
+
129
+ try {
130
+ spawnDetached(ide, buildIdeArgs(ide, targetDir));
131
+
132
+ setTimeout(() => {
133
+ const doc = findEntryDocument(targetDir);
134
+ if (doc) spawnDetached(ide, buildIdeArgs(ide, doc));
135
+ }, 3000);
136
+
137
+ console.log(chalk.green(`🚀 Opening ${path.basename(targetDir)} in ${ide.name}`));
138
+ } catch {
139
+ // Swallow — IDE launch errors are non-fatal for the download workflow.
140
+ }
141
+ }
package/src/install.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { execSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import { exec } from './utils.js';
4
+
5
+ /**
6
+ * A detector checks whether a specific project type is present in the current
7
+ * working directory by looking for a characteristic file.
8
+ *
9
+ * @internal
10
+ */
11
+ type Detector = () => boolean;
12
+
13
+ /**
14
+ * An installer runs the appropriate package manager or build tool for a
15
+ * detected project type.
16
+ *
17
+ * @internal
18
+ */
19
+ type Installer = () => void;
20
+
21
+ /**
22
+ * Returns true when the `bun` binary is available on `PATH`.
23
+ *
24
+ * Uses `where` on Windows and `command -v` on Unix. Throws when not found
25
+ * (callers catch this to fall back to npm).
26
+ *
27
+ * @internal
28
+ */
29
+ function bunAvailable(): boolean {
30
+ try {
31
+ execSync(
32
+ process.platform === 'win32' ? 'where bun' : 'command -v bun',
33
+ { stdio: 'ignore' }
34
+ );
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Installs Node.js dependencies and starts the dev/start script.
43
+ *
44
+ * Prefers `bun` when available; falls back to `npm`. Runs `dev` first and
45
+ * `start` as a fallback in the same shell invocation — both are expected to
46
+ * fail gracefully when the script doesn't exist.
47
+ *
48
+ * @internal
49
+ */
50
+ function installNode(): void {
51
+ if (bunAvailable()) {
52
+ exec('bun install');
53
+ exec('bun run dev; bun run start');
54
+ } else {
55
+ exec('npm install');
56
+ exec('npm run dev; npm run start');
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Starts a Docker project using Compose if `docker-compose.yml` is present,
62
+ * otherwise builds a plain Docker image from `Dockerfile`.
63
+ *
64
+ * @internal
65
+ */
66
+ function installDocker(): void {
67
+ if (fs.existsSync('docker-compose.yml')) {
68
+ exec('sudo docker-compose up -d');
69
+ } else if (fs.existsSync('Dockerfile')) {
70
+ exec('sudo docker build -t project .');
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Creates a Python virtualenv, activates it, and installs dependencies.
76
+ *
77
+ * Installs from `requirements.txt` and/or `setup.py` depending on which
78
+ * files exist. The `source .venv/bin/activate` call is a no-op on Windows
79
+ * (where the command is not available) but is harmless to include.
80
+ *
81
+ * @internal
82
+ */
83
+ function installPython(): void {
84
+ exec('python -m venv .venv');
85
+ exec('source .venv/bin/activate');
86
+ if (fs.existsSync('requirements.txt')) exec('pip install -r requirements.txt');
87
+ if (fs.existsSync('setup.py')) exec('pip install -e .');
88
+ }
89
+
90
+ /**
91
+ * Project-type detectors keyed by ecosystem name.
92
+ * Each function returns `true` when it recognises the current directory.
93
+ *
94
+ * @internal
95
+ */
96
+ const DETECTORS: Record<string, Detector> = {
97
+ nodejs: () => fs.existsSync('package.json'),
98
+ docker: () => fs.existsSync('Dockerfile') || fs.existsSync('docker-compose.yml'),
99
+ python: () => fs.existsSync('requirements.txt') || fs.existsSync('setup.py'),
100
+ rust: () => fs.existsSync('Cargo.toml'),
101
+ go: () => fs.existsSync('go.mod'),
102
+ };
103
+
104
+ /**
105
+ * Per-ecosystem install handlers, keyed to match {@link DETECTORS}.
106
+ *
107
+ * @internal
108
+ */
109
+ const INSTALLERS: Record<string, Installer> = {
110
+ nodejs: installNode,
111
+ docker: installDocker,
112
+ python: installPython,
113
+ rust: () => exec('cargo build'),
114
+ go: () => exec('go mod tidy'),
115
+ };
116
+
117
+ /**
118
+ * Detects the project type(s) in `targetDir` and runs the appropriate
119
+ * dependency installer(s).
120
+ *
121
+ * A directory can match multiple project types simultaneously (e.g. a Node.js
122
+ * project that also has a Dockerfile). All matched installers are run in order.
123
+ *
124
+ * Supported ecosystems:
125
+ * | Ecosystem | Detection file | Install command |
126
+ * |-----------|------------------------|---------------------------|
127
+ * | Node.js | `package.json` | `bun install` / `npm i` |
128
+ * | Docker | `Dockerfile` / Compose | `docker-compose up -d` |
129
+ * | Python | `requirements.txt` | `pip install -r …` |
130
+ * | Rust | `Cargo.toml` | `cargo build` |
131
+ * | Go | `go.mod` | `go mod tidy` |
132
+ *
133
+ * @param targetDir - Absolute path to the project root to install into.
134
+ * The function changes the process working directory to this path before
135
+ * running any commands.
136
+ *
137
+ * @example
138
+ * await installDependencies('/home/user/projects/my-node-app');
139
+ * // Detects package.json → runs bun install && bun run dev
140
+ */
141
+ export async function installDependencies(targetDir: string): Promise<void> {
142
+ process.chdir(targetDir);
143
+
144
+ for (const [name, detect] of Object.entries(DETECTORS)) {
145
+ if (detect()) INSTALLERS[name]?.();
146
+ }
147
+ }