vibe-forge 0.8.5 → 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.
Files changed (2) hide show
  1. package/bin/cli.js +192 -20
  2. package/package.json +1 -1
package/bin/cli.js CHANGED
@@ -11,12 +11,14 @@
11
11
 
12
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
 
@@ -86,6 +88,138 @@ function buildChildEnv(ambient, extras = {}) {
86
88
  return env;
87
89
  }
88
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
+
89
223
  // Colors for terminal output
90
224
  // NOTE: Intentionally duplicated from src/lib/colors.sh
91
225
  // This is by design: cli.js runs standalone via npx before the rest of Vibe Forge
@@ -272,13 +406,15 @@ async function initCommand() {
272
406
  logSuccess('Prerequisites OK');
273
407
  log('');
274
408
 
275
- // Clone the repository. Uses spawnSync with an arg array rather than
276
- // execSync with a template string so there is no shell-injection surface
277
- // if REPO_URL or FORGE_DIR are ever derived from untrusted input.
278
- logInfo(`Cloning Vibe Forge into ${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}/...`);
279
415
  const cloneResult = spawnSync(
280
416
  'git',
281
- ['clone', '--depth', '1', REPO_URL, FORGE_DIR],
417
+ buildCloneArgs(REPO_URL, tag, FORGE_DIR),
282
418
  {
283
419
  stdio: 'inherit',
284
420
  cwd: process.cwd(),
@@ -286,7 +422,9 @@ async function initCommand() {
286
422
  }
287
423
  );
288
424
  if (cloneResult.status !== 0) {
289
- logError('Failed to clone repository');
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.`);
290
428
  process.exit(1);
291
429
  }
292
430
  logSuccess('Clone complete');
@@ -397,31 +535,61 @@ async function updateCommand() {
397
535
  process.exit(1);
398
536
  }
399
537
 
400
- logInfo('Updating Vibe Forge...');
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;
544
+ try {
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)...`);
401
553
 
402
554
  const childEnv = buildChildEnv(process.env);
403
555
 
404
- const fetchResult = spawnSync('git', ['fetch', 'origin'], {
405
- stdio: 'inherit',
406
- cwd: targetDir,
407
- env: childEnv,
408
- });
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
+ );
409
570
  if (fetchResult.status !== 0) {
410
- logError('Failed to update. Check your network connection or try deleting _vibe-forge and running init again.');
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.`);
411
573
  process.exit(1);
412
574
  }
413
575
 
414
- const resetResult = spawnSync('git', ['reset', '--hard', 'origin/main'], {
415
- stdio: 'inherit',
416
- cwd: targetDir,
417
- env: childEnv,
418
- });
576
+ const resetResult = spawnSync(
577
+ 'git',
578
+ ['reset', '--hard', `refs/tags/${tag}`],
579
+ { stdio: 'inherit', cwd: targetDir, env: childEnv }
580
+ );
419
581
  if (resetResult.status !== 0) {
420
- logError('Failed to update. Check your network connection or try deleting _vibe-forge and running init again.');
582
+ const spawnErr = resetResult.error ? `: ${resetResult.error.message}` : '';
583
+ logError(`Failed to check out tag ${tag}${spawnErr}.`);
421
584
  process.exit(1);
422
585
  }
423
586
 
424
- logSuccess('Update complete');
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})`);
425
593
  }
426
594
 
427
595
  // Main entry point
@@ -463,4 +631,8 @@ if (require.main === module) {
463
631
  module.exports = {
464
632
  buildChildEnv,
465
633
  ENV_ALLOWLIST,
634
+ buildTagRef,
635
+ buildCloneArgs,
636
+ getLatestPublishedVersion,
637
+ NPM_PKG_NAME_RE,
466
638
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-forge",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "Multi-agent development orchestration system for terminal-native vibe coding",
5
5
  "keywords": [
6
6
  "vibe-coding",