run-repo-script 0.1.0

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 ADDED
@@ -0,0 +1,65 @@
1
+ # run-repo
2
+
3
+ `run-repo` fetches a GitHub repository and runs its installer script with a small, auditable CLI flow.
4
+
5
+ Package name: `run-repo-script`
6
+ CLI command: `run-repo`
7
+
8
+ ## Requirements
9
+
10
+ - Node.js 20+
11
+ - `git` installed and authenticated for the target GitHub repository
12
+ - Runtime for the selected script (`node` or `bash`)
13
+ - `zx` is bundled and used only for explicit zx intent (`--runner zx` or zx shebang)
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ run-repo owner/repo
19
+ run-repo owner/repo#v1.2.3
20
+ run-repo https://github.com/owner/repo.git#main
21
+ ```
22
+
23
+ ## Install and run
24
+
25
+ `run-repo-script` is the npm package name.
26
+ `run-repo` is the CLI command installed from that package.
27
+
28
+ Install globally, then use the command:
29
+
30
+ ```bash
31
+ npm install -g run-repo-script
32
+ run-repo owner/repo
33
+ run-repo owner/repo#v1.2.3
34
+ ```
35
+
36
+ Run directly with npx (no global install). With npx, use the package name:
37
+
38
+ ```bash
39
+ npx run-repo-script owner/repo
40
+ npx run-repo-script owner/repo#v1.2.3
41
+ ```
42
+
43
+ ## Example
44
+
45
+ Run an explicit installer script and forward flags to it:
46
+
47
+ ```bash
48
+ run-repo owner/repo --script scripts/install.sh -- --target local --verbose
49
+ ```
50
+
51
+ Select a runner explicitly:
52
+
53
+ ```bash
54
+ run-repo owner/repo --runner node --dangerously-skip-confirmation
55
+ ```
56
+
57
+ ## Safety notes
58
+
59
+ - The CLI executes code from the fetched repository. Review refs before running.
60
+ - You must confirm execution unless `--dangerously-skip-confirmation` is passed.
61
+ - Clone is non-interactive (`GIT_TERMINAL_PROMPT=0`) to avoid hanging auth prompts.
62
+ - Clone keeps standard GitHub auth token env vars (`GH_TOKEN`, `GITHUB_TOKEN`) so private repository fetches can succeed.
63
+ - Installer execution runs with a strict allowlist environment (for example: `PATH`, `HOME`, temp/locale vars, plus `NO_PROXY` and certificate vars like `SSL_CERT_FILE`, `SSL_CERT_DIR`, and `NODE_EXTRA_CA_CERTS`).
64
+ - Proxy URL vars (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`) are forwarded to clone/installer child processes only when they do not contain embedded credentials.
65
+ - `zx` executions are spawned with `ZX_VERBOSE=true` for documented zx verbosity logging.
package/dist/cli.js ADDED
@@ -0,0 +1,92 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { rm } from 'node:fs/promises';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { resolveInstaller } from './discovery.js';
5
+ import { executeInstaller } from './execute.js';
6
+ import { fetchRepository } from './fetch.js';
7
+ export function parseRunConfig(argv) {
8
+ const parsed = parseArgs({
9
+ args: argv,
10
+ allowPositionals: true,
11
+ strict: true,
12
+ options: {
13
+ script: {
14
+ type: 'string'
15
+ },
16
+ runner: {
17
+ type: 'string'
18
+ },
19
+ 'dangerously-skip-confirmation': {
20
+ type: 'boolean',
21
+ default: false
22
+ },
23
+ help: {
24
+ type: 'boolean',
25
+ default: false
26
+ }
27
+ }
28
+ });
29
+ const optionTerminatorIndex = argv.indexOf('--');
30
+ const forwardArgs = optionTerminatorIndex === -1 ? [] : argv.slice(optionTerminatorIndex + 1);
31
+ const repoTarget = parsed.positionals[0] ?? '';
32
+ return {
33
+ repoTarget,
34
+ script: parsed.values.script,
35
+ runner: parsed.values.runner,
36
+ dangerouslySkipConfirmation: parsed.values['dangerously-skip-confirmation'],
37
+ help: parsed.values.help,
38
+ forwardArgs
39
+ };
40
+ }
41
+ function printUsage() {
42
+ console.log('Usage: run-repo <owner/repo[#ref]|https://github.com/owner/repo[.git][#ref]> [--script <path>] [--runner <node|bash|zx>] [--dangerously-skip-confirmation] [-- <args...>]');
43
+ }
44
+ export async function runCli(argv) {
45
+ let config;
46
+ try {
47
+ config = parseRunConfig(argv);
48
+ }
49
+ catch (error) {
50
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
51
+ printUsage();
52
+ return 1;
53
+ }
54
+ if (config.help) {
55
+ printUsage();
56
+ return 0;
57
+ }
58
+ if (!config.repoTarget) {
59
+ process.stderr.write('Repository target is required.\n');
60
+ printUsage();
61
+ return 1;
62
+ }
63
+ let workspaceDir;
64
+ try {
65
+ const fetchedRepo = await fetchRepository(config.repoTarget);
66
+ workspaceDir = fetchedRepo.workspaceDir;
67
+ const script = await resolveInstaller(fetchedRepo.workspaceDir, config.script);
68
+ return await executeInstaller({
69
+ repoRoot: fetchedRepo.workspaceDir,
70
+ script,
71
+ runnerOverride: config.runner,
72
+ dangerouslySkipConfirmation: config.dangerouslySkipConfirmation,
73
+ forwardArgs: config.forwardArgs
74
+ });
75
+ }
76
+ catch (error) {
77
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
78
+ return 1;
79
+ }
80
+ finally {
81
+ if (workspaceDir) {
82
+ await rm(workspaceDir, { recursive: true, force: true });
83
+ }
84
+ }
85
+ }
86
+ function isDirectExecution() {
87
+ return (Boolean(process.argv[1]) &&
88
+ import.meta.url === pathToFileURL(process.argv[1]).href);
89
+ }
90
+ if (isDirectExecution()) {
91
+ process.exitCode = await runCli(process.argv.slice(2));
92
+ }
@@ -0,0 +1,86 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export const DEFAULT_INSTALLER_PATHS = [
4
+ 'install.mjs',
5
+ 'install.js',
6
+ 'install.sh',
7
+ 'scripts/install.mjs',
8
+ 'scripts/install.js',
9
+ 'scripts/install.sh'
10
+ ];
11
+ function normalizeRelativeScriptPath(inputPath) {
12
+ const normalized = path.posix
13
+ .normalize(inputPath.replaceAll('\\', '/'))
14
+ .replace(/^\.\//, '');
15
+ if (path.posix.isAbsolute(normalized) ||
16
+ normalized === '..' ||
17
+ normalized.startsWith('../')) {
18
+ throw new Error('Explicit --script must point to a file inside the fetched repository.');
19
+ }
20
+ return normalized;
21
+ }
22
+ async function fileExists(absolutePath) {
23
+ try {
24
+ const info = await stat(absolutePath);
25
+ return info.isFile();
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function isPathInsideRoot(rootPath, candidatePath) {
32
+ const relativePath = path.relative(rootPath, candidatePath);
33
+ return (relativePath === '' ||
34
+ (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)));
35
+ }
36
+ async function resolveContainedFilePath(repoRootRealPath, absolutePath, rejectOutsideRoot) {
37
+ let resolvedPath;
38
+ try {
39
+ resolvedPath = await realpath(absolutePath);
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ if (!isPathInsideRoot(repoRootRealPath, resolvedPath)) {
45
+ if (rejectOutsideRoot) {
46
+ throw new Error('Explicit --script must resolve to a file inside the fetched repository.');
47
+ }
48
+ return undefined;
49
+ }
50
+ if (!(await fileExists(resolvedPath))) {
51
+ return undefined;
52
+ }
53
+ return resolvedPath;
54
+ }
55
+ export async function resolveInstaller(repoRoot, explicitScript) {
56
+ const repoRootRealPath = await realpath(repoRoot);
57
+ const searchedPaths = [...DEFAULT_INSTALLER_PATHS];
58
+ if (explicitScript) {
59
+ const relativePath = normalizeRelativeScriptPath(explicitScript);
60
+ const absolutePath = path.join(repoRoot, relativePath);
61
+ const resolvedPath = await resolveContainedFilePath(repoRootRealPath, absolutePath, true);
62
+ if (!resolvedPath) {
63
+ throw new Error(`Explicit script not found: ${relativePath}`);
64
+ }
65
+ return {
66
+ absolutePath: resolvedPath,
67
+ relativePath
68
+ };
69
+ }
70
+ const foundDefaults = [];
71
+ for (const relativePath of searchedPaths) {
72
+ const absolutePath = path.join(repoRoot, relativePath);
73
+ const resolvedPath = await resolveContainedFilePath(repoRootRealPath, absolutePath, false);
74
+ if (resolvedPath) {
75
+ foundDefaults.push({ absolutePath: resolvedPath, relativePath });
76
+ }
77
+ }
78
+ if (foundDefaults.length === 1) {
79
+ return foundDefaults[0];
80
+ }
81
+ if (foundDefaults.length === 0) {
82
+ throw new Error(`No installer script found. Searched: ${searchedPaths.join(', ')}`);
83
+ }
84
+ const matched = foundDefaults.map((result) => result.relativePath).join(', ');
85
+ throw new Error(`Multiple installer scripts found (${matched}). Pass --script to choose exactly one.`);
86
+ }
package/dist/env.js ADDED
@@ -0,0 +1,167 @@
1
+ const SENSITIVE_ENV_KEY_PATTERNS = [
2
+ /^AWS_/i,
3
+ /^AZURE_/i,
4
+ /^GCP_/i,
5
+ /^GOOGLE_/i,
6
+ /^GITHUB_/i,
7
+ /^GH_/i,
8
+ /^NPM_TOKEN$/i,
9
+ /^NODE_AUTH_TOKEN$/i,
10
+ /^CI_JOB_TOKEN$/i,
11
+ /^SSH_AUTH_SOCK$/i,
12
+ /^SSH_AGENT_PID$/i,
13
+ /(^|_)(TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|AUTH)(_|$)/i
14
+ ];
15
+ const INSTALLER_ENV_ALLOWLIST = [
16
+ 'PATH',
17
+ 'HOME',
18
+ 'USER',
19
+ 'LOGNAME',
20
+ 'SHELL',
21
+ 'TERM',
22
+ 'COLORTERM',
23
+ 'NO_COLOR',
24
+ 'FORCE_COLOR',
25
+ 'LANG',
26
+ 'TZ',
27
+ 'HTTP_PROXY',
28
+ 'HTTPS_PROXY',
29
+ 'NO_PROXY',
30
+ 'ALL_PROXY',
31
+ 'SSL_CERT_FILE',
32
+ 'SSL_CERT_DIR',
33
+ 'NODE_EXTRA_CA_CERTS',
34
+ 'TMPDIR',
35
+ 'TMP',
36
+ 'TEMP',
37
+ 'SYSTEMROOT',
38
+ 'WINDIR',
39
+ 'COMSPEC',
40
+ 'PATHEXT',
41
+ 'USERPROFILE',
42
+ 'APPDATA',
43
+ 'LOCALAPPDATA'
44
+ ];
45
+ const GIT_CLONE_ENV_ALLOWLIST = [
46
+ 'PATH',
47
+ 'HOME',
48
+ 'USER',
49
+ 'LOGNAME',
50
+ 'SHELL',
51
+ 'LANG',
52
+ 'TZ',
53
+ 'TMPDIR',
54
+ 'TMP',
55
+ 'TEMP',
56
+ 'SYSTEMROOT',
57
+ 'WINDIR',
58
+ 'COMSPEC',
59
+ 'PATHEXT',
60
+ 'USERPROFILE',
61
+ 'APPDATA',
62
+ 'LOCALAPPDATA',
63
+ 'XDG_CONFIG_HOME',
64
+ 'XDG_CACHE_HOME',
65
+ 'XDG_DATA_HOME',
66
+ 'HTTP_PROXY',
67
+ 'HTTPS_PROXY',
68
+ 'NO_PROXY',
69
+ 'ALL_PROXY',
70
+ 'SSL_CERT_FILE',
71
+ 'SSL_CERT_DIR',
72
+ 'GIT_SSL_CAINFO',
73
+ 'GIT_SSL_CAPATH',
74
+ 'GIT_ASKPASS',
75
+ 'SSH_ASKPASS',
76
+ 'SSH_AUTH_SOCK',
77
+ 'SSH_AGENT_PID',
78
+ 'GH_TOKEN',
79
+ 'GITHUB_TOKEN'
80
+ ];
81
+ const INSTALLER_ENV_ALLOWLIST_PREFIXES = ['LC_'];
82
+ const INSTALLER_ENV_ALLOWLIST_SET = normalizeKeySet(INSTALLER_ENV_ALLOWLIST);
83
+ const GIT_CLONE_ENV_ALLOWLIST_PREFIXES = ['LC_'];
84
+ const GIT_CLONE_ENV_ALLOWLIST_SET = normalizeKeySet(GIT_CLONE_ENV_ALLOWLIST);
85
+ const INSTALLER_PROXY_URL_ENV_KEYS = normalizeKeySet([
86
+ 'HTTP_PROXY',
87
+ 'HTTPS_PROXY',
88
+ 'ALL_PROXY'
89
+ ]);
90
+ function normalizeKeySet(keys) {
91
+ return new Set(keys.map((key) => key.toUpperCase()));
92
+ }
93
+ function isSensitiveEnvironmentKey(key, allowSensitiveKeys) {
94
+ if (allowSensitiveKeys.has(key.toUpperCase())) {
95
+ return false;
96
+ }
97
+ return SENSITIVE_ENV_KEY_PATTERNS.some((pattern) => pattern.test(key));
98
+ }
99
+ export function createSafeEnvironment(sourceEnv = process.env, options = {}) {
100
+ const safeEnv = {};
101
+ const allowSensitiveKeys = normalizeKeySet(options.allowSensitiveKeys ?? []);
102
+ for (const [key, value] of Object.entries(sourceEnv)) {
103
+ if (value === undefined ||
104
+ isSensitiveEnvironmentKey(key, allowSensitiveKeys)) {
105
+ continue;
106
+ }
107
+ safeEnv[key] = value;
108
+ }
109
+ return safeEnv;
110
+ }
111
+ function isInstallerEnvironmentKeyAllowed(key) {
112
+ const normalizedKey = key.toUpperCase();
113
+ if (INSTALLER_ENV_ALLOWLIST_SET.has(normalizedKey)) {
114
+ return true;
115
+ }
116
+ return INSTALLER_ENV_ALLOWLIST_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix));
117
+ }
118
+ function isGitCloneEnvironmentKeyAllowed(key) {
119
+ const normalizedKey = key.toUpperCase();
120
+ if (GIT_CLONE_ENV_ALLOWLIST_SET.has(normalizedKey)) {
121
+ return true;
122
+ }
123
+ return GIT_CLONE_ENV_ALLOWLIST_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix));
124
+ }
125
+ function hasEmbeddedProxyCredentials(proxyValue) {
126
+ const trimmedProxyValue = proxyValue.trim();
127
+ const proxyUrlToParse = trimmedProxyValue.includes('://')
128
+ ? trimmedProxyValue
129
+ : `http://${trimmedProxyValue}`;
130
+ try {
131
+ const parsedProxyUrl = new URL(proxyUrlToParse);
132
+ return parsedProxyUrl.username !== '' || parsedProxyUrl.password !== '';
133
+ }
134
+ catch {
135
+ return trimmedProxyValue.includes('@');
136
+ }
137
+ }
138
+ function shouldDropCredentialBearingProxyEnvironmentKey(key, value) {
139
+ if (!INSTALLER_PROXY_URL_ENV_KEYS.has(key.toUpperCase())) {
140
+ return false;
141
+ }
142
+ return hasEmbeddedProxyCredentials(value);
143
+ }
144
+ export function createInstallerEnvironment(sourceEnv = process.env) {
145
+ const safeEnv = {};
146
+ for (const [key, value] of Object.entries(sourceEnv)) {
147
+ if (value === undefined ||
148
+ !isInstallerEnvironmentKeyAllowed(key) ||
149
+ shouldDropCredentialBearingProxyEnvironmentKey(key, value)) {
150
+ continue;
151
+ }
152
+ safeEnv[key] = value;
153
+ }
154
+ return safeEnv;
155
+ }
156
+ export function createGitCloneEnvironment(sourceEnv = process.env) {
157
+ const safeEnv = {};
158
+ for (const [key, value] of Object.entries(sourceEnv)) {
159
+ if (value === undefined ||
160
+ !isGitCloneEnvironmentKeyAllowed(key) ||
161
+ shouldDropCredentialBearingProxyEnvironmentKey(key, value)) {
162
+ continue;
163
+ }
164
+ safeEnv[key] = value;
165
+ }
166
+ return safeEnv;
167
+ }
@@ -0,0 +1,205 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { createInstallerEnvironment } from './env.js';
8
+ const require = createRequire(import.meta.url);
9
+ const SUPPORTED_RUNNERS = {
10
+ node: true,
11
+ bash: true,
12
+ zx: true
13
+ };
14
+ const NON_INTERACTIVE_CONFIRMATION_ERROR = 'Confirmation requires an interactive terminal. Re-run with --dangerously-skip-confirmation in non-interactive environments.';
15
+ function toSupportedRunner(candidate) {
16
+ if (!candidate) {
17
+ return undefined;
18
+ }
19
+ if (candidate in SUPPORTED_RUNNERS) {
20
+ return candidate;
21
+ }
22
+ if (candidate === 'sh') {
23
+ return 'bash';
24
+ }
25
+ return undefined;
26
+ }
27
+ function parseShebangRunner(shebangLine) {
28
+ const line = shebangLine.trim();
29
+ if (!line.startsWith('#!')) {
30
+ return undefined;
31
+ }
32
+ const tokens = line.slice(2).trim().split(/\s+/);
33
+ if (tokens.length === 0) {
34
+ return undefined;
35
+ }
36
+ if (tokens[0].endsWith('/env')) {
37
+ if (tokens[1] === '-S') {
38
+ return toSupportedRunner(tokens[2]);
39
+ }
40
+ return toSupportedRunner(tokens[1]);
41
+ }
42
+ return toSupportedRunner(path.basename(tokens[0]));
43
+ }
44
+ async function readShebang(scriptAbsolutePath) {
45
+ const content = await readFile(scriptAbsolutePath, 'utf8');
46
+ const firstLine = content.split(/\r?\n/, 1)[0];
47
+ return firstLine.startsWith('#!') ? firstLine : undefined;
48
+ }
49
+ function fallbackRunner(scriptRelativePath) {
50
+ const extension = path.extname(scriptRelativePath);
51
+ if (extension === '.js' || extension === '.mjs') {
52
+ return 'node';
53
+ }
54
+ if (extension === '.sh') {
55
+ return 'bash';
56
+ }
57
+ return undefined;
58
+ }
59
+ function resolveBundledZxCliPath() {
60
+ try {
61
+ const packageJsonPath = require.resolve('zx/package.json');
62
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
63
+ const binPath = typeof packageJson.bin === 'string'
64
+ ? packageJson.bin
65
+ : packageJson.bin?.zx;
66
+ if (!binPath) {
67
+ return undefined;
68
+ }
69
+ const cliPath = path.join(path.dirname(packageJsonPath), binPath);
70
+ return existsSync(cliPath) ? cliPath : undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ function resolveRunnerCommand(runner) {
77
+ if (runner !== 'zx') {
78
+ return {
79
+ command: runner,
80
+ preArgs: []
81
+ };
82
+ }
83
+ const bundledZxCliPath = resolveBundledZxCliPath();
84
+ if (!bundledZxCliPath) {
85
+ throw new Error('Bundled zx runtime is not available in this installation. Reinstall run-repo-script.');
86
+ }
87
+ return {
88
+ command: process.execPath,
89
+ preArgs: [bundledZxCliPath]
90
+ };
91
+ }
92
+ function unavailableRunnerMessage(runner) {
93
+ if (runner === 'zx') {
94
+ return 'Bundled zx runtime is not available in this installation. Reinstall run-repo-script.';
95
+ }
96
+ return `Runner "${runner}" is not available on this host. Install it or use --runner with an available runtime.`;
97
+ }
98
+ export async function resolveRunner(scriptAbsolutePath, scriptRelativePath, runnerOverride) {
99
+ const override = toSupportedRunner(runnerOverride);
100
+ if (runnerOverride && !override) {
101
+ throw new Error(`Unsupported --runner value: ${runnerOverride}. Supported values: node, bash, zx.`);
102
+ }
103
+ if (override) {
104
+ return override;
105
+ }
106
+ const shebang = await readShebang(scriptAbsolutePath);
107
+ const shebangRunner = shebang ? parseShebangRunner(shebang) : undefined;
108
+ if (shebangRunner) {
109
+ return shebangRunner;
110
+ }
111
+ const fallback = fallbackRunner(scriptRelativePath);
112
+ if (fallback) {
113
+ return fallback;
114
+ }
115
+ throw new Error(`Unable to determine runner for script: ${scriptRelativePath}. Use --runner <node|bash|zx>.`);
116
+ }
117
+ export async function isRunnerAvailable(runner) {
118
+ if (runner === 'zx') {
119
+ return resolveBundledZxCliPath() !== undefined;
120
+ }
121
+ return await new Promise((resolve) => {
122
+ const child = spawn(runner, ['--version'], { stdio: 'ignore' });
123
+ child.on('error', () => resolve(false));
124
+ child.on('close', (code) => resolve(code === 0));
125
+ });
126
+ }
127
+ async function confirmExecution(runner, scriptRelativePath, forwardArgs) {
128
+ if (!process.stdin.isTTY ||
129
+ !process.stdout.isTTY ||
130
+ process.stdin.destroyed ||
131
+ process.stdin.readableEnded) {
132
+ throw new Error(NON_INTERACTIVE_CONFIRMATION_ERROR);
133
+ }
134
+ const argsText = forwardArgs.length > 0 ? ` ${forwardArgs.join(' ')}` : '';
135
+ const question = `About to run: ${runner} ${scriptRelativePath}${argsText}\nContinue? [Y/n] `;
136
+ const readline = createInterface({
137
+ input: process.stdin,
138
+ output: process.stdout
139
+ });
140
+ try {
141
+ const answer = await readline.question(question);
142
+ return isConfirmationAccepted(answer);
143
+ }
144
+ finally {
145
+ readline.close();
146
+ }
147
+ }
148
+ export function isConfirmationAccepted(answer) {
149
+ const normalizedAnswer = answer.trim().toLowerCase();
150
+ return (normalizedAnswer === '' ||
151
+ normalizedAnswer === 'y' ||
152
+ normalizedAnswer === 'yes');
153
+ }
154
+ function toRunnerScriptPath(scriptRelativePath) {
155
+ if (scriptRelativePath.startsWith('./')) {
156
+ return scriptRelativePath;
157
+ }
158
+ return `./${scriptRelativePath}`;
159
+ }
160
+ async function runInstaller(runner, options) {
161
+ const runnerCommand = resolveRunnerCommand(runner);
162
+ const args = [
163
+ ...runnerCommand.preArgs,
164
+ toRunnerScriptPath(options.script.relativePath),
165
+ ...options.forwardArgs
166
+ ];
167
+ return await new Promise((resolve, reject) => {
168
+ const child = spawn(runnerCommand.command, args, {
169
+ cwd: options.repoRoot,
170
+ stdio: 'inherit',
171
+ env: createRunnerEnvironment(runner)
172
+ });
173
+ child.on('error', (error) => {
174
+ reject(new Error(`Failed to start installer process: ${error.message}`));
175
+ });
176
+ child.on('close', (code, signal) => {
177
+ if (signal) {
178
+ reject(new Error(`Installer process terminated by signal: ${signal}`));
179
+ return;
180
+ }
181
+ resolve(code ?? 1);
182
+ });
183
+ });
184
+ }
185
+ export function createRunnerEnvironment(runner, sourceEnv = process.env) {
186
+ const env = createInstallerEnvironment(sourceEnv);
187
+ if (runner === 'zx') {
188
+ env.ZX_VERBOSE = 'true';
189
+ }
190
+ return env;
191
+ }
192
+ export async function executeInstaller(options) {
193
+ const runner = await resolveRunner(options.script.absolutePath, options.script.relativePath, options.runnerOverride);
194
+ const available = await isRunnerAvailable(runner);
195
+ if (!available) {
196
+ throw new Error(unavailableRunnerMessage(runner));
197
+ }
198
+ if (!options.dangerouslySkipConfirmation) {
199
+ const confirmed = await confirmExecution(runner, options.script.relativePath, options.forwardArgs);
200
+ if (!confirmed) {
201
+ throw new Error('Execution cancelled by user.');
202
+ }
203
+ }
204
+ return await runInstaller(runner, options);
205
+ }
package/dist/fetch.js ADDED
@@ -0,0 +1,111 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createGitCloneEnvironment } from './env.js';
6
+ const SHORTHAND_REGEX = /^(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+)(?:#(?<ref>.+))?$/;
7
+ const HTTPS_PATH_REGEX = /^(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+?)(?:\.git)?$/;
8
+ const SSH_STYLE_REGEX = /^(git@|ssh:\/\/)/i;
9
+ export const SAFE_GIT_ENV = {
10
+ GIT_TERMINAL_PROMPT: '0',
11
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
12
+ GCM_INTERACTIVE: 'never'
13
+ };
14
+ export function resolveGitHubTarget(input) {
15
+ const target = input.trim();
16
+ if (!target) {
17
+ throw new Error('Repository target is required.');
18
+ }
19
+ if (SSH_STYLE_REGEX.test(target)) {
20
+ throw new Error('Unsupported repository target: SSH syntax is not supported in v1. Use owner/repo or https://github.com/...');
21
+ }
22
+ const shorthandMatch = SHORTHAND_REGEX.exec(target);
23
+ if (shorthandMatch?.groups) {
24
+ const { owner, repo, ref } = shorthandMatch.groups;
25
+ return {
26
+ owner,
27
+ repo,
28
+ ref,
29
+ cloneUrl: `https://github.com/${owner}/${repo}.git`
30
+ };
31
+ }
32
+ let parsed;
33
+ try {
34
+ parsed = new URL(target);
35
+ }
36
+ catch {
37
+ throw new Error('Unsupported repository target. Use owner/repo[#ref] or https://github.com/owner/repo[.git][#ref].');
38
+ }
39
+ if (parsed.protocol !== 'https:') {
40
+ throw new Error('Unsupported repository target: only HTTPS GitHub URLs are supported in v1.');
41
+ }
42
+ if (parsed.hostname !== 'github.com') {
43
+ throw new Error('Unsupported repository host. Only github.com is supported in v1.');
44
+ }
45
+ const pathname = parsed.pathname.replace(/^\/+|\/+$/g, '');
46
+ const pathMatch = HTTPS_PATH_REGEX.exec(pathname);
47
+ if (!pathMatch?.groups) {
48
+ throw new Error('Unsupported GitHub URL format. Expected https://github.com/owner/repo[.git][#ref].');
49
+ }
50
+ const { owner, repo } = pathMatch.groups;
51
+ const ref = parsed.hash
52
+ ? decodeURIComponent(parsed.hash.slice(1))
53
+ : undefined;
54
+ return {
55
+ owner,
56
+ repo,
57
+ ref,
58
+ cloneUrl: `https://github.com/${owner}/${repo}.git`
59
+ };
60
+ }
61
+ export function createGitCloneCommand(resolvedTarget, destinationDir) {
62
+ const args = ['clone', '--depth', '1'];
63
+ if (resolvedTarget.ref) {
64
+ args.push('--branch', resolvedTarget.ref, '--single-branch');
65
+ }
66
+ args.push(resolvedTarget.cloneUrl, destinationDir);
67
+ return {
68
+ command: 'git',
69
+ args,
70
+ env: {
71
+ ...createGitCloneEnvironment(process.env),
72
+ ...SAFE_GIT_ENV
73
+ }
74
+ };
75
+ }
76
+ export async function cloneIntoDirectory(resolvedTarget, destinationDir) {
77
+ const command = createGitCloneCommand(resolvedTarget, destinationDir);
78
+ await new Promise((resolve, reject) => {
79
+ const child = spawn(command.command, command.args, {
80
+ env: command.env,
81
+ stdio: ['ignore', 'pipe', 'pipe']
82
+ });
83
+ let stderr = '';
84
+ child.stderr.on('data', (chunk) => {
85
+ stderr += chunk.toString();
86
+ });
87
+ child.on('error', (error) => {
88
+ reject(new Error(`Failed to launch git clone: ${error.message}`));
89
+ });
90
+ child.on('close', (code) => {
91
+ if (code === 0) {
92
+ resolve();
93
+ return;
94
+ }
95
+ const stderrLine = stderr.trim();
96
+ reject(new Error(`git clone failed with exit code ${code ?? 'unknown'}${stderrLine ? `: ${stderrLine}` : ''}`));
97
+ });
98
+ });
99
+ }
100
+ export async function fetchRepository(target) {
101
+ const resolvedTarget = resolveGitHubTarget(target);
102
+ const workspaceDir = await mkdtemp(join(tmpdir(), 'run-repo-'));
103
+ try {
104
+ await cloneIntoDirectory(resolvedTarget, workspaceDir);
105
+ return { workspaceDir, resolvedTarget };
106
+ }
107
+ catch (error) {
108
+ await rm(workspaceDir, { recursive: true, force: true });
109
+ throw error;
110
+ }
111
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "run-repo-script",
3
+ "version": "0.1.0",
4
+ "description": "Fetch a GitHub repository and run its installer script",
5
+ "type": "module",
6
+ "packageManager": "pnpm@10.13.1",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "bin": {
12
+ "run-repo": "dist/cli.js"
13
+ },
14
+ "scripts": {
15
+ "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "prepack": "pnpm build",
18
+ "prepublishOnly": "pnpm check",
19
+ "pack:check": "node scripts/validate-pack.mjs",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "test:unit": "vitest run --dir test/unit",
22
+ "test:smoke": "vitest run --dir test/smoke",
23
+ "test": "pnpm test:unit && pnpm test:smoke",
24
+ "lint": "eslint . --max-warnings=0",
25
+ "format": "prettier --check .",
26
+ "format:write": "prettier --write .",
27
+ "check": "pnpm format && pnpm lint && pnpm typecheck && pnpm build && pnpm pack:check && pnpm test",
28
+ "prepare": "husky"
29
+ },
30
+ "keywords": [
31
+ "cli",
32
+ "installer",
33
+ "github"
34
+ ],
35
+ "license": "MIT",
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "devDependencies": {
40
+ "@eslint/js": "^9.39.4",
41
+ "@types/node": "^26.0.1",
42
+ "@typescript-eslint/eslint-plugin": "^8.62.0",
43
+ "@typescript-eslint/parser": "^8.62.0",
44
+ "@vitest/eslint-plugin": "^1.6.20",
45
+ "eslint": "^9.39.4",
46
+ "eslint-config-prettier": "^10.1.8",
47
+ "globals": "^16.5.0",
48
+ "husky": "^9.1.7",
49
+ "lint-staged": "^16.4.0",
50
+ "prettier": "^3.8.4",
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^4.1.9"
53
+ },
54
+ "dependencies": {
55
+ "zx": "^8.8.5"
56
+ }
57
+ }