@wipcomputer/wip-repos 1.9.69 → 1.9.70

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 CHANGED
@@ -24,6 +24,10 @@ wip-repos check
24
24
  wip-repos check --all
25
25
  wip-repos check --class worktree
26
26
 
27
+ # Report release-pipeline enrollment status
28
+ wip-repos release-enrollment
29
+ wip-repos release-enrollment --strict
30
+
27
31
  # See what sync would do
28
32
  wip-repos sync
29
33
 
@@ -46,6 +50,7 @@ wip-repos tree
46
50
  --manifest Path to repos-manifest.json (default: ./repos-manifest.json)
47
51
  --root Path to repos root directory (default: directory containing manifest)
48
52
  --dry-run Show what would happen without making changes
53
+ --strict Treat missing release enrollment decisions as failures
49
54
  --json Output as JSON
50
55
  ```
51
56
 
@@ -57,10 +62,37 @@ wip-repos tree
57
62
 
58
63
  3. **add/move** update the manifest file. The actual folder moves happen on the next `sync`.
59
64
 
65
+ 4. **release-enrollment** reports which active manifest repos are enrolled in the release pipeline, explicitly excluded, still need a release-profile decision, missing on disk, or active on disk but unmanifested. By default, missing repos and unmanifested active repos are blockers. With `--strict`, missing release decisions are blockers too.
66
+
67
+ Release enrollment metadata lives in each manifest entry:
68
+
69
+ ```json
70
+ {
71
+ "ldm-os/devops/my-tool-private": {
72
+ "remote": "wipcomputer/my-tool-private",
73
+ "release": {
74
+ "enabled": true,
75
+ "profile": "node-package",
76
+ "smokeProfile": "ldm-tool",
77
+ "publicMirror": "wipcomputer/my-tool",
78
+ "requiredSecrets": ["NPM_TOKEN"]
79
+ }
80
+ },
81
+ "ldm-os/docs/archive": {
82
+ "remote": "wipcomputer/docs-archive",
83
+ "release": {
84
+ "enabled": false,
85
+ "reason": "archived repo"
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
60
91
  ## Integration
61
92
 
62
93
  - `deploy-public` and `wip-release` can call `wip-repos check` before running. Stale manifest blocks deploys.
63
94
  - CI: run `wip-repos check` as a PR check. Drift = blocked merge.
95
+ - CI/release planning: run `wip-repos release-enrollment --strict --json` to make release-owned repo enrollment machine-readable.
64
96
  - README generation: `wip-repos tree` outputs a directory tree from the manifest.
65
97
 
66
98
  ## Source
package/SKILL.md CHANGED
@@ -39,6 +39,7 @@ Repo manifest reconciler. Like prettier for folder structure. Move folders aroun
39
39
  **Use wip-repos for:**
40
40
  - Checking if the filesystem matches the manifest (`check`)
41
41
  - Moving repos to match the manifest (`sync`)
42
+ - Reporting release-pipeline enrollment state (`release-enrollment`)
42
43
  - Adding a new repo to the manifest (`add`)
43
44
  - Moving a repo in the manifest (`move`)
44
45
  - Generating a directory tree from the manifest (`tree`)
@@ -61,6 +62,8 @@ Repo manifest reconciler. Like prettier for folder structure. Move folders aroun
61
62
  wip-repos check # diff filesystem vs manifest
62
63
  wip-repos check --all # include worktrees/trash/archive paths
63
64
  wip-repos check --class worktree # inspect one lifecycle class
65
+ wip-repos release-enrollment # report release enrollment decisions
66
+ wip-repos release-enrollment --strict --json # CI-ready enrollment gate
64
67
  wip-repos sync # preview moves
65
68
  wip-repos sync --apply # execute safe moves
66
69
  wip-repos add ldm-os/utilities/new-tool --remote wipcomputer/new-tool
@@ -71,9 +74,10 @@ wip-repos tree # generate directory tree
71
74
  ### Module
72
75
 
73
76
  ```javascript
74
- import { check, planSync, addRepo, moveRepo, generateReadmeTree } from '@wipcomputer/wip-repos';
77
+ import { check, releaseEnrollment, planSync, addRepo, moveRepo, generateReadmeTree } from '@wipcomputer/wip-repos';
75
78
 
76
79
  const result = check('/path/to/manifest.json', '/path/to/repos/');
80
+ const enrollment = releaseEnrollment('/path/to/manifest.json', '/path/to/repos/', { strict: true });
77
81
  const moves = planSync('/path/to/manifest.json', '/path/to/repos/');
78
82
  ```
79
83
 
@@ -81,4 +85,4 @@ const moves = planSync('/path/to/manifest.json', '/path/to/repos/');
81
85
 
82
86
  ### MCP
83
87
 
84
- Tools: `repos_check`, `repos_sync_plan`, `repos_add`, `repos_move`, `repos_tree`
88
+ Tools: `repos_check`, `repos_release_enrollment`, `repos_sync_plan`, `repos_add`, `repos_move`, `repos_tree`
package/cli.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * tree - Generate directory tree from manifest
12
12
  */
13
13
 
14
- import { check, planSync, executeSync, addRepo, moveRepo, generateReadmeTree, loadManifest, checkCompliance, fixCompliance, findUnmanifested } from './core.mjs';
14
+ import { check, planSync, executeSync, addRepo, moveRepo, generateReadmeTree, loadManifest, checkCompliance, fixCompliance, findUnmanifested, releaseEnrollment } from './core.mjs';
15
15
  import { runClaude } from './claude.mjs';
16
16
  import { resolve, dirname, join } from 'node:path';
17
17
  import { readFileSync } from 'node:fs';
@@ -35,6 +35,7 @@ Usage:
35
35
  wip-repos sync [--manifest path] [--root path] [--apply]
36
36
  wip-repos compliance [--manifest path] [--root path] [--fix]
37
37
  wip-repos watchdog [--manifest path] [--root path]
38
+ wip-repos release-enrollment [--manifest path] [--root path] [--strict]
38
39
  wip-repos add <path> --remote <org/repo> [--category cat] [--description desc]
39
40
  wip-repos move <path> --to <new-path>
40
41
  wip-repos tree [--manifest path]
@@ -44,6 +45,8 @@ Commands:
44
45
  sync Move local folders to match the manifest
45
46
  compliance Check all repos for .license-guard.json, LICENSE, CLA.md, .npmignore
46
47
  watchdog Find repos on disk that are not in the manifest
48
+ release-enrollment
49
+ Report release-pipeline enrollment decisions for active repos
47
50
  add Add a repo to the manifest
48
51
  move Move a repo to a different category in the manifest
49
52
  tree Generate directory tree from manifest
@@ -53,6 +56,7 @@ Options:
53
56
  --root Path to repos root directory (default: directory containing manifest)
54
57
  --all Include worktrees, trash, archived, and other ignored lifecycle paths
55
58
  --class Show one lifecycle class only (active, worktree, trash, sort, sunsetted, archived, third-party)
59
+ --strict Treat missing release enrollment decisions as failures
56
60
  --apply Apply sync moves. Without this, sync is a dry run
57
61
  --fix Create missing compliance files (with compliance command)
58
62
  --json Output as JSON`);
@@ -265,6 +269,80 @@ try {
265
269
  break;
266
270
  }
267
271
 
272
+ case 'release-enrollment': {
273
+ const result = releaseEnrollment(manifestPath, reposRoot, {
274
+ strict: hasFlag('--strict'),
275
+ });
276
+
277
+ if (jsonOutput) {
278
+ console.log(JSON.stringify(result, null, 2));
279
+ if (!result.clean) process.exit(1);
280
+ break;
281
+ }
282
+
283
+ console.log('Release enrollment inventory');
284
+ console.log(`Active manifest repos: ${result.summary.manifestActive}`);
285
+ console.log(`Enrolled: ${result.summary.enrolled}`);
286
+ console.log(`Excluded: ${result.summary.excluded}`);
287
+ console.log(`Needs decision: ${result.summary.needsDecision}`);
288
+ console.log(`Missing on disk: ${result.summary.missingOnDisk}`);
289
+ console.log(`Unmanifested active: ${result.summary.unmanifestedActive}`);
290
+ console.log(`Blockers: ${result.summary.blockers}`);
291
+ console.log();
292
+
293
+ if (result.enrolled.length > 0) {
294
+ console.log(`ENROLLED (${result.enrolled.length}):`);
295
+ for (const entry of result.enrolled) {
296
+ const profile = entry.profile ? ` profile=${entry.profile}` : '';
297
+ const pkg = entry.packageName ? ` package=${entry.packageName}` : '';
298
+ const issues = entry.issues?.length ? ` issues=${entry.issues.join('; ')}` : '';
299
+ console.log(` ✓ ${entry.path}${profile}${pkg}${issues}`);
300
+ }
301
+ console.log();
302
+ }
303
+
304
+ if (result.excluded.length > 0) {
305
+ console.log(`EXCLUDED (${result.excluded.length}):`);
306
+ for (const entry of result.excluded) {
307
+ console.log(` - ${entry.path}: ${entry.reason}`);
308
+ }
309
+ console.log();
310
+ }
311
+
312
+ if (result.needsDecision.length > 0) {
313
+ const mode = result.strict ? 'BLOCKING because --strict is set' : 'NON-BLOCKING without --strict';
314
+ console.log(`NEEDS DECISION (${result.needsDecision.length}, ${mode}):`);
315
+ for (const entry of result.needsDecision) {
316
+ console.log(` ? ${entry.path}: ${entry.issue}`);
317
+ }
318
+ console.log();
319
+ }
320
+
321
+ if (result.missingOnDisk.length > 0) {
322
+ console.log(`MISSING ON DISK (${result.missingOnDisk.length}):`);
323
+ for (const entry of result.missingOnDisk) {
324
+ console.log(` ! ${entry.path}: ${entry.issue}`);
325
+ }
326
+ console.log();
327
+ }
328
+
329
+ if (result.unmanifestedActive.length > 0) {
330
+ console.log(`UNMANIFESTED ACTIVE (${result.unmanifestedActive.length}):`);
331
+ for (const entry of result.unmanifestedActive) {
332
+ console.log(` + ${entry.path}: ${entry.reason}`);
333
+ }
334
+ console.log();
335
+ }
336
+
337
+ if (result.clean) {
338
+ console.log('Release enrollment inventory is actionable.');
339
+ } else {
340
+ console.log('Release enrollment inventory has blockers.');
341
+ process.exit(1);
342
+ }
343
+ break;
344
+ }
345
+
268
346
  case 'claude': {
269
347
  runClaude(manifestPath, args.slice(1));
270
348
  break;
package/core.mjs CHANGED
@@ -491,3 +491,145 @@ export function findUnmanifested(manifestPath, reposRoot) {
491
491
  const result = check(manifestPath, reposRoot);
492
492
  return result.onDiskOnly;
493
493
  }
494
+
495
+ function readPackageInfo(fullPath) {
496
+ const packagePath = join(fullPath, 'package.json');
497
+ if (!existsSync(packagePath)) return null;
498
+ try {
499
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
500
+ return {
501
+ name: pkg.name || null,
502
+ version: pkg.version || null,
503
+ private: Boolean(pkg.private),
504
+ scripts: pkg.scripts || {},
505
+ };
506
+ } catch (err) {
507
+ return { error: err.message };
508
+ }
509
+ }
510
+
511
+ function getReleaseConfig(info) {
512
+ if (!info || typeof info !== 'object') return {};
513
+ if (info.release && typeof info.release === 'object') return info.release;
514
+ if (info.ci && typeof info.ci === 'object' && info.ci.release && typeof info.ci.release === 'object') {
515
+ return info.ci.release;
516
+ }
517
+ return {};
518
+ }
519
+
520
+ function isReleaseExcluded(releaseConfig) {
521
+ return releaseConfig.enabled === false || releaseConfig.enrolled === false;
522
+ }
523
+
524
+ function isReleaseEnrolled(releaseConfig) {
525
+ return releaseConfig.enabled === true || releaseConfig.enrolled === true || Boolean(releaseConfig.profile);
526
+ }
527
+
528
+ /**
529
+ * Report release-pipeline enrollment status for active manifest repos.
530
+ *
531
+ * This intentionally reuses the manifest and filesystem classifier from check().
532
+ * It answers the Phase 0 release-pipeline question: which repos are enrolled,
533
+ * which are explicitly excluded, and which still need a release-profile decision.
534
+ */
535
+ export function releaseEnrollment(manifestPath, reposRoot, opts = {}) {
536
+ const manifest = loadManifest(manifestPath);
537
+ const inventory = check(manifestPath, reposRoot);
538
+ const strict = Boolean(opts.strict);
539
+
540
+ const enrolled = [];
541
+ const excluded = [];
542
+ const needsDecision = [];
543
+ const missingOnDisk = [];
544
+
545
+ for (const [repoPath, info] of Object.entries(manifest.repos)) {
546
+ const classification = classifyRepoPath(repoPath, join(reposRoot, repoPath));
547
+ if (classification.class !== 'active') continue;
548
+
549
+ const fullPath = join(reposRoot, repoPath);
550
+ const exists = existsSync(fullPath);
551
+ const releaseConfig = getReleaseConfig(info);
552
+ const packageInfo = exists ? readPackageInfo(fullPath) : null;
553
+ const entry = {
554
+ path: repoPath,
555
+ remote: info.remote || null,
556
+ packageName: packageInfo?.name || releaseConfig.packageName || releaseConfig.npmPackage || null,
557
+ packageVersion: packageInfo?.version || null,
558
+ profile: releaseConfig.profile || null,
559
+ smokeProfile: releaseConfig.smokeProfile || null,
560
+ publicMirror: releaseConfig.publicMirror || info.public || null,
561
+ requiredSecrets: Array.isArray(releaseConfig.requiredSecrets) ? releaseConfig.requiredSecrets : [],
562
+ scripts: packageInfo?.scripts ? Object.keys(packageInfo.scripts).sort() : [],
563
+ };
564
+
565
+ if (!exists) {
566
+ missingOnDisk.push({
567
+ ...entry,
568
+ issue: 'manifest entry is active but repo is not on disk',
569
+ });
570
+ continue;
571
+ }
572
+
573
+ if (packageInfo?.error) {
574
+ needsDecision.push({
575
+ ...entry,
576
+ issue: `package.json is invalid: ${packageInfo.error}`,
577
+ });
578
+ continue;
579
+ }
580
+
581
+ if (isReleaseExcluded(releaseConfig)) {
582
+ excluded.push({
583
+ ...entry,
584
+ reason: releaseConfig.reason || releaseConfig.excludeReason || 'release enrollment explicitly disabled',
585
+ });
586
+ continue;
587
+ }
588
+
589
+ if (isReleaseEnrolled(releaseConfig)) {
590
+ const issues = [];
591
+ if (!entry.profile) issues.push('missing release.profile');
592
+ if (!entry.packageName && packageInfo) issues.push('missing package name');
593
+ enrolled.push({ ...entry, issues });
594
+ continue;
595
+ }
596
+
597
+ needsDecision.push({
598
+ ...entry,
599
+ issue: 'missing release enrollment decision',
600
+ });
601
+ }
602
+
603
+ const unmanifestedActive = inventory.onDiskOnlyDetails
604
+ .filter(entry => entry.class === 'active')
605
+ .map(entry => ({
606
+ path: entry.path,
607
+ reason: entry.reason,
608
+ }));
609
+
610
+ const blockers = [
611
+ ...missingOnDisk.map(entry => ({ type: 'missing-on-disk', path: entry.path, issue: entry.issue })),
612
+ ...unmanifestedActive.map(entry => ({ type: 'unmanifested-active', path: entry.path, issue: entry.reason })),
613
+ ...(strict ? needsDecision.map(entry => ({ type: 'needs-decision', path: entry.path, issue: entry.issue })) : []),
614
+ ].sort((a, b) => a.path.localeCompare(b.path));
615
+
616
+ return {
617
+ strict,
618
+ clean: blockers.length === 0,
619
+ summary: {
620
+ manifestActive: enrolled.length + excluded.length + needsDecision.length + missingOnDisk.length,
621
+ enrolled: enrolled.length,
622
+ excluded: excluded.length,
623
+ needsDecision: needsDecision.length,
624
+ missingOnDisk: missingOnDisk.length,
625
+ unmanifestedActive: unmanifestedActive.length,
626
+ blockers: blockers.length,
627
+ },
628
+ enrolled: enrolled.sort((a, b) => a.path.localeCompare(b.path)),
629
+ excluded: excluded.sort((a, b) => a.path.localeCompare(b.path)),
630
+ needsDecision: needsDecision.sort((a, b) => a.path.localeCompare(b.path)),
631
+ missingOnDisk: missingOnDisk.sort((a, b) => a.path.localeCompare(b.path)),
632
+ unmanifestedActive,
633
+ blockers,
634
+ };
635
+ }
package/mcp-server.mjs CHANGED
@@ -7,7 +7,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
9
  import {
10
- check, planSync, addRepo, moveRepo, generateReadmeTree,
10
+ check, planSync, addRepo, moveRepo, generateReadmeTree, releaseEnrollment,
11
11
  } from './core.mjs';
12
12
 
13
13
  const server = new Server(
@@ -43,6 +43,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
43
43
  required: ['manifestPath', 'reposRoot'],
44
44
  },
45
45
  },
46
+ {
47
+ name: 'repos_release_enrollment',
48
+ description: 'Report release-pipeline enrollment status for active manifest repos: enrolled, excluded, needs decision, missing on disk, and unmanifested active repos.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ manifestPath: { type: 'string', description: 'Path to repos-manifest.json' },
53
+ reposRoot: { type: 'string', description: 'Root directory containing repos' },
54
+ strict: { type: 'boolean', description: 'Treat repos missing release enrollment decisions as blockers' },
55
+ },
56
+ required: ['manifestPath', 'reposRoot'],
57
+ },
58
+ },
46
59
  {
47
60
  name: 'repos_add',
48
61
  description: 'Add a repo to the manifest.',
@@ -112,6 +125,19 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
112
125
  };
113
126
  }
114
127
 
128
+ if (name === 'repos_release_enrollment') {
129
+ const result = releaseEnrollment(args.manifestPath, args.reposRoot, {
130
+ strict: Boolean(args.strict),
131
+ });
132
+ return {
133
+ content: [{
134
+ type: 'text',
135
+ text: JSON.stringify(result, null, 2),
136
+ }],
137
+ isError: !result.clean,
138
+ };
139
+ }
140
+
115
141
  if (name === 'repos_add') {
116
142
  const entry = addRepo(args.manifestPath, args.repoPath, args.remote, {
117
143
  description: args.description,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-repos",
3
- "version": "1.9.69",
3
+ "version": "1.9.70",
4
4
  "type": "module",
5
5
  "description": "Repo manifest reconciler. Single source of truth for repo organization. Like prettier for folder structure.",
6
6
  "main": "core.mjs",
package/test.mjs CHANGED
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process';
3
3
  import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { check, executeSync, planSync } from './core.mjs';
6
+ import { check, executeSync, planSync, releaseEnrollment } from './core.mjs';
7
7
 
8
8
  function run(cmd, cwd) {
9
9
  execSync(cmd, { cwd, stdio: 'ignore' });
@@ -22,6 +22,8 @@ const root = mkdtempSync(join(tmpdir(), 'wip-repos-test-'));
22
22
  const manifestPath = join(root, 'repos-manifest.json');
23
23
 
24
24
  initRepo(join(root, 'active/repo-a'), 'wipcomputer/repo-a');
25
+ initRepo(join(root, 'active/repo-b'), 'wipcomputer/repo-b');
26
+ initRepo(join(root, 'active/no-decision'), 'wipcomputer/no-decision');
25
27
  initRepo(join(root, '_trash/old-repo'), 'wipcomputer/old-repo');
26
28
  initRepo(join(root, 'active/misplaced'), 'wipcomputer/right-place');
27
29
  mkdirSync(join(root, '.worktrees'), { recursive: true });
@@ -29,8 +31,19 @@ run(`git worktree add -q -b feat ${join(root, '.worktrees/repo-a--feat')}`, join
29
31
 
30
32
  writeFileSync(manifestPath, JSON.stringify({
31
33
  _format: 'v2',
32
- 'active/repo-a': { remote: 'wipcomputer/repo-a' },
33
- 'active/right-place': { remote: 'wipcomputer/right-place' },
34
+ 'active/repo-a': {
35
+ remote: 'wipcomputer/repo-a',
36
+ release: { enabled: true, profile: 'node-package', smokeProfile: 'none' },
37
+ },
38
+ 'active/repo-b': {
39
+ remote: 'wipcomputer/repo-b',
40
+ release: { enabled: false, reason: 'docs-only fixture' },
41
+ },
42
+ 'active/no-decision': { remote: 'wipcomputer/no-decision' },
43
+ 'active/right-place': {
44
+ remote: 'wipcomputer/right-place',
45
+ release: { enabled: true, profile: 'node-package' },
46
+ },
34
47
  }, null, 2) + '\n');
35
48
 
36
49
  const defaultCheck = check(manifestPath, root);
@@ -54,6 +67,28 @@ if (worktreeCheck.disk.length !== 1 || worktreeCheck.disk[0].class !== 'worktree
54
67
  throw new Error('--class worktree should return only worktrees');
55
68
  }
56
69
 
70
+ const enrollment = releaseEnrollment(manifestPath, root);
71
+ if (enrollment.summary.enrolled !== 1 || enrollment.summary.excluded !== 1) {
72
+ throw new Error('releaseEnrollment should count enrolled and excluded repos');
73
+ }
74
+ if (enrollment.summary.needsDecision !== 1) {
75
+ throw new Error('releaseEnrollment should count repos without release decisions');
76
+ }
77
+ if (enrollment.summary.blockers !== 2) {
78
+ throw new Error('releaseEnrollment should block on missing and unmanifested active repos by default');
79
+ }
80
+ if (!enrollment.missingOnDisk.some(r => r.path === 'active/right-place')) {
81
+ throw new Error('releaseEnrollment should report active manifest repos missing on disk');
82
+ }
83
+ if (!enrollment.unmanifestedActive.some(r => r.path === 'active/misplaced')) {
84
+ throw new Error('releaseEnrollment should report active repos on disk but not in manifest');
85
+ }
86
+
87
+ const strictEnrollment = releaseEnrollment(manifestPath, root, { strict: true });
88
+ if (strictEnrollment.summary.blockers !== 3) {
89
+ throw new Error('releaseEnrollment --strict should make missing decisions blocking');
90
+ }
91
+
57
92
  const moves = planSync(manifestPath, root);
58
93
  if (moves.length !== 1 || moves[0].to !== 'active/right-place') {
59
94
  throw new Error('planSync should plan the misplaced active repo only');