vibe-forge 0.8.3 → 0.8.6

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/bin/cli.js CHANGED
@@ -9,17 +9,217 @@
9
9
  * npx vibe-forge --help Show help
10
10
  */
11
11
 
12
- const { execSync, spawn } = require('child_process');
12
+ const { execSync, spawn, spawnSync } = require('child_process');
13
13
  const fs = require('fs');
14
+ const https = require('https');
14
15
  const path = require('path');
15
16
  const os = require('os');
16
17
 
17
18
  // Read version from package.json (single source of truth)
18
19
  const packageJson = require(path.join(__dirname, '..', 'package.json'));
19
20
  const VERSION = packageJson.version;
21
+ const PKG_NAME = packageJson.name;
20
22
  const REPO_URL = 'https://github.com/sugar-crash-studios/vibe-forge.git';
21
23
  const FORGE_DIR = '_vibe-forge';
22
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // Environment allowlist for child processes.
27
+ //
28
+ // forge-setup.sh runs with full user privileges, so we pass only the minimum
29
+ // env it needs instead of the entire parent environment. This keeps ambient
30
+ // secrets (AWS_*, GITHUB_TOKEN, NPM_TOKEN, OPENAI_API_KEY, ANTHROPIC_API_KEY,
31
+ // DATABASE_URL, STRIPE_*, etc.) from leaking into setup scripts if a user's
32
+ // shell happens to have them set at invocation time.
33
+ // ---------------------------------------------------------------------------
34
+ // Frozen so third parties (or a poisoned import chain) cannot push a secret
35
+ // name onto the allowlist at runtime and leak it into the next buildChildEnv
36
+ // call. The whole point of the allowlist is defense-in-depth, so the exported
37
+ // surface is treated as immutable.
38
+ const ENV_ALLOWLIST = Object.freeze([
39
+ // POSIX basics
40
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'TMPDIR',
41
+ // Locale
42
+ 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES',
43
+ // Windows basics
44
+ 'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC', 'USERPROFILE', 'LOCALAPPDATA',
45
+ 'APPDATA', 'TEMP', 'TMP', 'USERNAME', 'PATHEXT', 'WINDIR',
46
+ 'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432',
47
+ 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER',
48
+ // Git Bash / MSYS
49
+ 'MSYSTEM', 'MINGW_PREFIX', 'MINGW_CHOST',
50
+ // Proxies (needed for git/npm in corporate networks)
51
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY',
52
+ 'http_proxy', 'https_proxy', 'no_proxy',
53
+ // TLS CA bundles — consistent partner of the proxy vars above. Corporate
54
+ // networks with intercepting proxies need these or node/git fail TLS.
55
+ 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR',
56
+ 'GIT_SSL_CAINFO', 'GIT_SSL_CAPATH',
57
+ // Claude Code — setup validates `claude --version`, which needs this on Windows
58
+ 'CLAUDE_CODE_GIT_BASH_PATH',
59
+ ]);
60
+
61
+ function buildChildEnv(ambient, extras = {}) {
62
+ // Reject non-object extras loudly. String/number/boolean extras silently
63
+ // spread wrong shapes (e.g. Object.assign({}, 'abc') → {0:'a',1:'b',2:'c'}),
64
+ // which produces a silently-wrong env instead of a caller-visible error.
65
+ if (extras !== undefined && extras !== null && (typeof extras !== 'object' || Array.isArray(extras))) {
66
+ throw new TypeError(`buildChildEnv: extras must be a plain object, got ${typeof extras}`);
67
+ }
68
+
69
+ const source = ambient || {};
70
+ const env = {};
71
+ for (const key of ENV_ALLOWLIST) {
72
+ if (source[key] !== undefined) {
73
+ env[key] = source[key];
74
+ }
75
+ }
76
+
77
+ // Merge extras, but treat `undefined` as a no-op rather than overwriting the
78
+ // allowlisted value with an undefined. The source-side loop deliberately
79
+ // skips undefined keys; extras should follow the same rule for symmetry.
80
+ if (extras) {
81
+ for (const key of Object.keys(extras)) {
82
+ if (extras[key] !== undefined) {
83
+ env[key] = extras[key];
84
+ }
85
+ }
86
+ }
87
+
88
+ return env;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Tag-based supply-chain helpers
93
+ //
94
+ // forge init — clones the tag matching the CLI's own npm version, so the
95
+ // installed tarball and the git tree are always in sync.
96
+ // forge update — queries the npm registry directly via HTTPS (no subprocess)
97
+ // to find the latest published version, then fetches that tag.
98
+ // Using https.get instead of execFileSync('npm', ['view', ...])
99
+ // avoids ENOENT on Windows where npm is `npm.cmd` and Node's
100
+ // CVE-2024-27980 fix blocks .cmd/.bat spawn without shell:true.
101
+ // Direct HTTPS also removes the PATH-shadowing attack class.
102
+ // ---------------------------------------------------------------------------
103
+
104
+ // Semver regex per semver.org spec:
105
+ // - §2/§4: numeric identifiers in major/minor/patch must not have leading zeros
106
+ // - §9: pre-release identifiers between dots must be non-empty (no "..")
107
+ // - §10: build metadata identifiers are alphanumeric+hyphen, non-empty between dots
108
+ // Note: leading zeros in NUMERIC pre-release identifiers (e.g. "1.0.0-01") are not
109
+ // rejected here — npm does not publish such versions in practice and tightening the
110
+ // pre-release numeric check would significantly complicate the pattern.
111
+ const SEMVER_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\w]+(?:\.[\w]+)*)?(?:\+[\w.-]+)?$/;
112
+
113
+ function buildTagRef(version) {
114
+ if (typeof version !== 'string' || !version) {
115
+ throw new TypeError(`buildTagRef: version must be a non-empty string, got ${JSON.stringify(version)}`);
116
+ }
117
+ // Strip any accidental leading 'v' so buildTagRef('v1.2.3') → 'v1.2.3'
118
+ // rather than 'vv1.2.3', which would not match the git tag.
119
+ const stripped = version.replace(/^v/, '');
120
+ // Validate semver format — rejects null bytes, newlines, spaces, and any
121
+ // other character that would produce a malformed or ambiguous git ref.
122
+ if (!SEMVER_RE.test(stripped)) {
123
+ throw new TypeError(`buildTagRef: invalid semver "${version}"`);
124
+ }
125
+ return `v${stripped}`;
126
+ }
127
+
128
+ function buildCloneArgs(repoUrl, tag, targetDir) {
129
+ if (typeof repoUrl !== 'string' || !repoUrl) throw new TypeError('buildCloneArgs: repoUrl must be a non-empty string');
130
+ if (typeof tag !== 'string' || !tag) throw new TypeError('buildCloneArgs: tag must be a non-empty string');
131
+ // Require the tag to be a v-prefixed semver string (as produced by buildTagRef).
132
+ // Defense-in-depth: prevents callers that skip buildTagRef from passing an
133
+ // adversarial string (e.g. '--upload-pack=evil') into the git argv array.
134
+ if (tag[0] !== 'v' || !SEMVER_RE.test(tag.slice(1))) {
135
+ throw new TypeError(`buildCloneArgs: tag must be a v-prefixed semver string, got "${tag}"`);
136
+ }
137
+ if (typeof targetDir !== 'string' || !targetDir) throw new TypeError('buildCloneArgs: targetDir must be a non-empty string');
138
+ return ['clone', '--depth', '1', '--branch', tag, repoUrl, targetDir];
139
+ }
140
+
141
+ // Matches unscoped (e.g. "vibe-forge") and scoped (e.g. "@scope/pkg") npm
142
+ // package names per the npm package-name spec:
143
+ // - all lowercase; no uppercase
144
+ // - starts and ends with a letter or digit (not tilde, dot, or hyphen)
145
+ // - may contain hyphens, underscores, and dots in the middle
146
+ // - consecutive dots (..) are rejected via the negative lookahead
147
+ // - no path separators, tildes, or shell metacharacters
148
+ // Length limit (max 214 chars) is enforced separately in getLatestPublishedVersion.
149
+ const NPM_PKG_NAME_RE = /^(?!.*\.\.)(?:@[a-z0-9](?:[a-z0-9-._]*[a-z0-9])?\/)?[a-z0-9](?:[a-z0-9-._]*[a-z0-9])?$/;
150
+
151
+ function getLatestPublishedVersion(pkgName = PKG_NAME) {
152
+ if (
153
+ typeof pkgName !== 'string' ||
154
+ pkgName.length > 214 ||
155
+ !NPM_PKG_NAME_RE.test(pkgName)
156
+ ) {
157
+ return Promise.reject(new TypeError(`getLatestPublishedVersion: invalid package name "${pkgName}"`));
158
+ }
159
+ return new Promise((resolve, reject) => {
160
+ // Captured in the outer closure so the timeout handler can also destroy the
161
+ // response stream, preventing buffered data/end events from racing the error.
162
+ let resRef = null;
163
+ const req = https.get(
164
+ `https://registry.npmjs.org/${pkgName}`,
165
+ { headers: { Accept: 'application/vnd.npm.install-v1+json' } },
166
+ (res) => {
167
+ resRef = res;
168
+ // Forward response-stream errors (e.g. from res.destroy in size-cap path)
169
+ // to the promise rejection channel.
170
+ res.on('error', reject);
171
+ if (res.statusCode !== 200) {
172
+ // Drain the response body so the underlying socket is released back
173
+ // to the connection pool before we reject. Harmless for a CLI that
174
+ // exits immediately, but required for correctness in longer-lived use.
175
+ res.resume();
176
+ reject(new Error(`npm registry returned ${res.statusCode}`));
177
+ return;
178
+ }
179
+ // Cap the accumulated body at 1 MB. The abbreviated manifest format
180
+ // (application/vnd.npm.install-v1+json) is < 1 KB in practice.
181
+ const MAX_BODY = 1 * 1024 * 1024;
182
+ let body = '';
183
+ res.on('data', (chunk) => {
184
+ body += chunk;
185
+ if (body.length > MAX_BODY) {
186
+ res.destroy(new Error('npm registry response too large (> 1 MB)'));
187
+ }
188
+ });
189
+ res.on('end', () => {
190
+ try {
191
+ const data = JSON.parse(body);
192
+ const version = data['dist-tags']?.latest;
193
+ if (!version) {
194
+ reject(new Error('No latest dist-tag in registry response'));
195
+ return;
196
+ }
197
+ // Accept full semver including pre-release and build metadata.
198
+ // SEMVER_RE rejects leading zeros in major/minor/patch (semver §4)
199
+ // and empty pre-release identifiers (semver §9). See SEMVER_RE
200
+ // definition for caveats on pre-release numeric leading zeros.
201
+ if (!SEMVER_RE.test(version)) {
202
+ reject(new Error(`Registry returned unrecognized version: "${version}"`));
203
+ return;
204
+ }
205
+ resolve(version);
206
+ } catch (err) {
207
+ reject(err);
208
+ }
209
+ });
210
+ }
211
+ );
212
+ req.on('error', reject);
213
+ req.setTimeout(10000, () => {
214
+ // Destroy both the request and the response stream (if already received)
215
+ // so that any buffered data/end events cannot race and resolve the promise
216
+ // after the timeout rejection.
217
+ req.destroy(new Error('npm registry request timed out after 10s'));
218
+ if (resRef) resRef.destroy();
219
+ });
220
+ });
221
+ }
222
+
23
223
  // Colors for terminal output
24
224
  // NOTE: Intentionally duplicated from src/lib/colors.sh
25
225
  // This is by design: cli.js runs standalone via npx before the rest of Vibe Forge
@@ -170,10 +370,7 @@ function runBashScript(scriptPath, args = []) {
170
370
  const child = spawn(bashPath, [scriptPath, ...args], {
171
371
  stdio: 'inherit',
172
372
  cwd: process.cwd(),
173
- env: {
174
- ...process.env,
175
- CLAUDE_CODE_GIT_BASH_PATH: bashPath,
176
- },
373
+ env: buildChildEnv(process.env, { CLAUDE_CODE_GIT_BASH_PATH: bashPath }),
177
374
  });
178
375
 
179
376
  child.on('close', (code) => {
@@ -209,18 +406,28 @@ async function initCommand() {
209
406
  logSuccess('Prerequisites OK');
210
407
  log('');
211
408
 
212
- // Clone the repository
213
- logInfo(`Cloning Vibe Forge into ${FORGE_DIR}/...`);
214
- try {
215
- execSync(`git clone --depth 1 ${REPO_URL} ${FORGE_DIR}`, {
409
+ // Clone the repository at the tag matching this CLI version so the installed
410
+ // npm tarball and the git tree are always in sync. Uses spawnSync with an
411
+ // arg array (no shell-injection surface). Fails loudly if the tag doesn't
412
+ // exist rather than silently falling back to main.
413
+ const tag = buildTagRef(VERSION);
414
+ logInfo(`Cloning Vibe Forge ${tag} into ${FORGE_DIR}/...`);
415
+ const cloneResult = spawnSync(
416
+ 'git',
417
+ buildCloneArgs(REPO_URL, tag, FORGE_DIR),
418
+ {
216
419
  stdio: 'inherit',
217
420
  cwd: process.cwd(),
218
- });
219
- logSuccess('Clone complete');
220
- } catch {
221
- logError('Failed to clone repository');
421
+ env: buildChildEnv(process.env),
422
+ }
423
+ );
424
+ if (cloneResult.status !== 0) {
425
+ const spawnErr = cloneResult.error ? `: ${cloneResult.error.message}` : '';
426
+ logError(`Failed to clone ${REPO_URL} at tag ${tag}${spawnErr}`);
427
+ logInfo(`If the tag does not exist yet, publish ${PKG_NAME}@${VERSION} via the release workflow first.`);
222
428
  process.exit(1);
223
429
  }
430
+ logSuccess('Clone complete');
224
431
 
225
432
  log('');
226
433
 
@@ -247,16 +454,26 @@ function validateAgentsConfig(forgeDir) {
247
454
  const checkScript = path.join(forgeDir, 'bin', 'lib', 'check-aliases.js');
248
455
  if (!fs.existsSync(checkScript)) return;
249
456
 
457
+ const agentsFile = path.join(forgeDir, 'config', 'agents.json');
458
+ const result = spawnSync('node', [checkScript, agentsFile], {
459
+ stdio: ['ignore', 'pipe', 'pipe'],
460
+ env: buildChildEnv(process.env),
461
+ });
462
+
463
+ if (result.status !== 0) {
464
+ logError('Agent alias collisions detected in config/agents.json');
465
+ const stderr = result.stderr ? result.stderr.toString().trim() : '';
466
+ logInfo(stderr || 'Run node src/lib/check-aliases.js for details');
467
+ logError('Fix collisions before using Vibe Forge.');
468
+ process.exit(1);
469
+ }
470
+
250
471
  try {
251
- const agentsFile = path.join(forgeDir, 'config', 'agents.json');
252
- execSync(`node "${checkScript}" "${agentsFile}"`, { stdio: 'pipe' });
253
472
  const config = JSON.parse(fs.readFileSync(agentsFile, 'utf8'));
254
473
  const agentCount = Object.keys(config.agents || {}).length;
255
474
  logSuccess(`Agent config valid (${agentCount} agents, no alias collisions)`);
256
- } catch (err) {
257
- logError('Agent alias collisions detected in config/agents.json');
258
- logInfo(err.stderr ? err.stderr.toString().trim() : 'Run node src/lib/check-aliases.js for details');
259
- logError('Fix collisions before using Vibe Forge.');
475
+ } catch {
476
+ logError('Agent config validation passed, but agents.json could not be parsed.');
260
477
  process.exit(1);
261
478
  }
262
479
  }
@@ -318,23 +535,61 @@ async function updateCommand() {
318
535
  process.exit(1);
319
536
  }
320
537
 
321
- logInfo('Updating Vibe Forge...');
322
-
538
+ // Resolve the latest published version from the npm registry via direct
539
+ // HTTPS (no subprocess). Using execFileSync('npm', ['view', ...]) fails with
540
+ // ENOENT on Windows because npm is npm.cmd and Node's CVE-2024-27980 fix
541
+ // blocks .cmd/.bat spawn without shell:true. Direct HTTPS also removes the
542
+ // PATH-shadowing attack class entirely.
543
+ let latestVersion;
323
544
  try {
324
- // Fetch and reset to origin/main - works even without tracking branch
325
- execSync('git fetch origin', {
326
- stdio: 'inherit',
327
- cwd: targetDir,
328
- });
329
- execSync('git reset --hard origin/main', {
330
- stdio: 'inherit',
331
- cwd: targetDir,
332
- });
333
- logSuccess('Update complete');
334
- } catch {
335
- logError('Failed to update. Check your network connection or try deleting _vibe-forge and running init again.');
545
+ latestVersion = await getLatestPublishedVersion();
546
+ } catch (err) {
547
+ logError(`Could not query the latest ${PKG_NAME} version from npm: ${err.message}`);
548
+ process.exit(1);
549
+ }
550
+
551
+ const tag = buildTagRef(latestVersion);
552
+ logInfo(`Updating Vibe Forge to ${tag} (latest on npm)...`);
553
+
554
+ const childEnv = buildChildEnv(process.env);
555
+
556
+ // Fetch the specific tag only no --force. If the local tag already exists
557
+ // pointing at a different SHA, git will reject the fetch and surface the
558
+ // conflict to the user. --force would silently accept tag relocations and
559
+ // apply them to every existing install on next update (Slag HIGH finding
560
+ // RT-20260411-001).
561
+ // Fetch only this specific tag refspec — no --tags. The --tags flag would
562
+ // additionally pull all remote tags via refs/tags/*:refs/tags/*, defeating
563
+ // the goal of fetching only the target version. The explicit refspec without
564
+ // a leading '+' already enforces the no-force constraint.
565
+ const fetchResult = spawnSync(
566
+ 'git',
567
+ ['fetch', 'origin', `refs/tags/${tag}:refs/tags/${tag}`],
568
+ { stdio: 'inherit', cwd: targetDir, env: childEnv }
569
+ );
570
+ if (fetchResult.status !== 0) {
571
+ const spawnErr = fetchResult.error ? `: ${fetchResult.error.message}` : '';
572
+ logError(`Failed to fetch tag ${tag}${spawnErr}. If the local tag has drifted, delete it with 'git -C ${FORGE_DIR} tag -d ${tag}' and retry.`);
573
+ process.exit(1);
574
+ }
575
+
576
+ const resetResult = spawnSync(
577
+ 'git',
578
+ ['reset', '--hard', `refs/tags/${tag}`],
579
+ { stdio: 'inherit', cwd: targetDir, env: childEnv }
580
+ );
581
+ if (resetResult.status !== 0) {
582
+ const spawnErr = resetResult.error ? `: ${resetResult.error.message}` : '';
583
+ logError(`Failed to check out tag ${tag}${spawnErr}.`);
336
584
  process.exit(1);
337
585
  }
586
+
587
+ // NOTE: _vibe-forge/node_modules is NOT updated here. If the new tag
588
+ // changed forge's own package.json dependencies, modules may be stale until
589
+ // the user re-runs forge-setup.sh or manually runs npm install inside
590
+ // _vibe-forge/. This is pre-existing behavior; a future improvement would
591
+ // re-invoke forge-setup.sh automatically after a successful tag switch.
592
+ logSuccess(`Update complete (now at ${tag})`);
338
593
  }
339
594
 
340
595
  // Main entry point
@@ -366,7 +621,18 @@ async function main() {
366
621
  }
367
622
  }
368
623
 
369
- main().catch((err) => {
370
- logError(err.message);
371
- process.exit(1);
372
- });
624
+ if (require.main === module) {
625
+ main().catch((err) => {
626
+ logError(err.message);
627
+ process.exit(1);
628
+ });
629
+ }
630
+
631
+ module.exports = {
632
+ buildChildEnv,
633
+ ENV_ALLOWLIST,
634
+ buildTagRef,
635
+ buildCloneArgs,
636
+ getLatestPublishedVersion,
637
+ NPM_PKG_NAME_RE,
638
+ };