@wipcomputer/wip-repos 1.9.68 → 1.9.69
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/CHANGELOG.md +13 -0
- package/README.md +6 -4
- package/SKILL.md +6 -2
- package/cli.mjs +101 -10
- package/core.mjs +213 -10
- package/package.json +2 -2
- package/test.mjs +68 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.9.69 (2026-04-24)
|
|
4
|
+
|
|
5
|
+
# wip-repos v1.9.69
|
|
6
|
+
|
|
7
|
+
## Classify lifecycle paths and make sync apply explicit
|
|
8
|
+
|
|
9
|
+
- Classifies discovered repos as active, worktree, trash, sort, sunsetted, archived, or third-party.
|
|
10
|
+
- Ignores worktrees and lifecycle folders by default in `wip-repos check`, with `--all` and `--class` for inspection.
|
|
11
|
+
- Makes `wip-repos sync` dry-run by default; repo moves require `--apply`.
|
|
12
|
+
- Refuses sync moves for dirty repos, linked worktrees, and target collisions.
|
|
13
|
+
- Adds a regression test for default check filtering, lifecycle class output, sync planning, and dirty-repo refusal.
|
package/README.md
CHANGED
|
@@ -21,12 +21,14 @@ Move folders around all day. On sync, everything snaps back to where the manifes
|
|
|
21
21
|
```bash
|
|
22
22
|
# Check for drift between filesystem and manifest
|
|
23
23
|
wip-repos check
|
|
24
|
+
wip-repos check --all
|
|
25
|
+
wip-repos check --class worktree
|
|
24
26
|
|
|
25
27
|
# See what sync would do
|
|
26
|
-
wip-repos sync
|
|
28
|
+
wip-repos sync
|
|
27
29
|
|
|
28
30
|
# Actually move folders to match manifest
|
|
29
|
-
wip-repos sync
|
|
31
|
+
wip-repos sync --apply
|
|
30
32
|
|
|
31
33
|
# Add a new repo
|
|
32
34
|
wip-repos add ldm-os/utilities/my-tool --remote wipcomputer/my-tool
|
|
@@ -49,9 +51,9 @@ wip-repos tree
|
|
|
49
51
|
|
|
50
52
|
## How It Works
|
|
51
53
|
|
|
52
|
-
1. **check** walks the filesystem,
|
|
54
|
+
1. **check** walks the filesystem, classifies repos, and compares active repos against the manifest. Worktrees, trash, sort, sunsetted, and archived paths are ignored by default and summarized separately. Use `--all` or `--class <class>` to inspect them.
|
|
53
55
|
|
|
54
|
-
2. **sync** matches repos by their git remote URL. If a repo's remote matches a manifest entry but it's at the wrong path, sync moves
|
|
56
|
+
2. **sync** matches active repos by their git remote URL. If a repo's remote matches a manifest entry but it's at the wrong path, sync prints the move plan by default. It only moves with `--apply`, and refuses dirty repos, linked worktrees, and target collisions.
|
|
55
57
|
|
|
56
58
|
3. **add/move** update the manifest file. The actual folder moves happen on the next `sync`.
|
|
57
59
|
|
package/SKILL.md
CHANGED
|
@@ -59,8 +59,10 @@ Repo manifest reconciler. Like prettier for folder structure. Move folders aroun
|
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
wip-repos check # diff filesystem vs manifest
|
|
62
|
-
wip-repos
|
|
63
|
-
wip-repos
|
|
62
|
+
wip-repos check --all # include worktrees/trash/archive paths
|
|
63
|
+
wip-repos check --class worktree # inspect one lifecycle class
|
|
64
|
+
wip-repos sync # preview moves
|
|
65
|
+
wip-repos sync --apply # execute safe moves
|
|
64
66
|
wip-repos add ldm-os/utilities/new-tool --remote wipcomputer/new-tool
|
|
65
67
|
wip-repos move ldm-os/utilities/tool --to ldm-os/devops/tool
|
|
66
68
|
wip-repos tree # generate directory tree
|
|
@@ -75,6 +77,8 @@ const result = check('/path/to/manifest.json', '/path/to/repos/');
|
|
|
75
77
|
const moves = planSync('/path/to/manifest.json', '/path/to/repos/');
|
|
76
78
|
```
|
|
77
79
|
|
|
80
|
+
`sync` is dry-run by default. It refuses dirty repos, linked worktrees, and target collisions when `--apply` is used.
|
|
81
|
+
|
|
78
82
|
### MCP
|
|
79
83
|
|
|
80
84
|
Tools: `repos_check`, `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 } from './core.mjs';
|
|
14
|
+
import { check, planSync, executeSync, addRepo, moveRepo, generateReadmeTree, loadManifest, checkCompliance, fixCompliance, findUnmanifested } 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';
|
|
@@ -31,16 +31,30 @@ function usage() {
|
|
|
31
31
|
console.log(`wip-repos ... repo manifest reconciler
|
|
32
32
|
|
|
33
33
|
Usage:
|
|
34
|
-
wip-repos check
|
|
35
|
-
wip-repos sync
|
|
34
|
+
wip-repos check [--manifest path] [--root path] [--all] [--class class]
|
|
35
|
+
wip-repos sync [--manifest path] [--root path] [--apply]
|
|
36
|
+
wip-repos compliance [--manifest path] [--root path] [--fix]
|
|
37
|
+
wip-repos watchdog [--manifest path] [--root path]
|
|
36
38
|
wip-repos add <path> --remote <org/repo> [--category cat] [--description desc]
|
|
37
39
|
wip-repos move <path> --to <new-path>
|
|
38
|
-
wip-repos tree
|
|
40
|
+
wip-repos tree [--manifest path]
|
|
41
|
+
|
|
42
|
+
Commands:
|
|
43
|
+
check Diff filesystem against manifest, flag drift
|
|
44
|
+
sync Move local folders to match the manifest
|
|
45
|
+
compliance Check all repos for .license-guard.json, LICENSE, CLA.md, .npmignore
|
|
46
|
+
watchdog Find repos on disk that are not in the manifest
|
|
47
|
+
add Add a repo to the manifest
|
|
48
|
+
move Move a repo to a different category in the manifest
|
|
49
|
+
tree Generate directory tree from manifest
|
|
39
50
|
|
|
40
51
|
Options:
|
|
41
52
|
--manifest Path to repos-manifest.json (default: ./repos-manifest.json)
|
|
42
53
|
--root Path to repos root directory (default: directory containing manifest)
|
|
43
|
-
--
|
|
54
|
+
--all Include worktrees, trash, archived, and other ignored lifecycle paths
|
|
55
|
+
--class Show one lifecycle class only (active, worktree, trash, sort, sunsetted, archived, third-party)
|
|
56
|
+
--apply Apply sync moves. Without this, sync is a dry run
|
|
57
|
+
--fix Create missing compliance files (with compliance command)
|
|
44
58
|
--json Output as JSON`);
|
|
45
59
|
}
|
|
46
60
|
|
|
@@ -56,13 +70,17 @@ function hasFlag(flag) {
|
|
|
56
70
|
|
|
57
71
|
const manifestPath = resolve(getFlag('--manifest') || 'repos-manifest.json');
|
|
58
72
|
const reposRoot = resolve(getFlag('--root') || dirname(manifestPath));
|
|
59
|
-
const
|
|
73
|
+
const apply = hasFlag('--apply');
|
|
74
|
+
const dryRun = hasFlag('--dry-run') || !apply;
|
|
60
75
|
const jsonOutput = hasFlag('--json');
|
|
61
76
|
|
|
62
77
|
try {
|
|
63
78
|
switch (command) {
|
|
64
79
|
case 'check': {
|
|
65
|
-
const result = check(manifestPath, reposRoot
|
|
80
|
+
const result = check(manifestPath, reposRoot, {
|
|
81
|
+
all: hasFlag('--all'),
|
|
82
|
+
classFilter: getFlag('--class') || null,
|
|
83
|
+
});
|
|
66
84
|
|
|
67
85
|
if (jsonOutput) {
|
|
68
86
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -72,12 +90,18 @@ try {
|
|
|
72
90
|
console.log(`Manifest: ${result.total.manifest} repos`);
|
|
73
91
|
console.log(`On disk: ${result.total.disk} repos`);
|
|
74
92
|
console.log(`Matched: ${result.total.matched}`);
|
|
93
|
+
if (Object.keys(result.ignored).length > 0 && !hasFlag('--all') && !getFlag('--class')) {
|
|
94
|
+
const ignored = Object.entries(result.ignored).map(([k, v]) => `${k}=${v}`).join(', ');
|
|
95
|
+
console.log(`Ignored lifecycle paths: ${ignored}`);
|
|
96
|
+
}
|
|
75
97
|
console.log();
|
|
76
98
|
|
|
77
99
|
if (result.onDiskOnly.length > 0) {
|
|
78
100
|
console.log(`On disk but NOT in manifest (${result.onDiskOnly.length}):`);
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
for (const p of result.onDiskOnly) {
|
|
102
|
+
const detail = result.onDiskOnlyDetails.find(d => d.path === p);
|
|
103
|
+
const suffix = detail ? ` [${detail.class}: ${detail.reason}]` : '';
|
|
104
|
+
console.log(` + ${p}${suffix}`);
|
|
81
105
|
}
|
|
82
106
|
console.log();
|
|
83
107
|
}
|
|
@@ -122,7 +146,7 @@ try {
|
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
if (dryRun) {
|
|
125
|
-
console.log('Dry run. No changes made.');
|
|
149
|
+
console.log('Dry run. No changes made. Re-run with --apply to move repos.');
|
|
126
150
|
break;
|
|
127
151
|
}
|
|
128
152
|
|
|
@@ -174,6 +198,73 @@ try {
|
|
|
174
198
|
break;
|
|
175
199
|
}
|
|
176
200
|
|
|
201
|
+
case 'compliance': {
|
|
202
|
+
const results = checkCompliance(manifestPath, reposRoot);
|
|
203
|
+
const failing = results.filter(r => !r.clean);
|
|
204
|
+
const passing = results.filter(r => r.clean);
|
|
205
|
+
|
|
206
|
+
if (jsonOutput) {
|
|
207
|
+
console.log(JSON.stringify(results, null, 2));
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(`Compliance check: ${results.length} repos scanned\n`);
|
|
212
|
+
|
|
213
|
+
if (failing.length > 0) {
|
|
214
|
+
console.log(`FAILING (${failing.length}):`);
|
|
215
|
+
for (const r of failing) {
|
|
216
|
+
console.log(` ✗ ${r.repoPath}`);
|
|
217
|
+
for (const issue of r.issues) {
|
|
218
|
+
console.log(` - ${issue}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
console.log();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (passing.length > 0) {
|
|
225
|
+
console.log(`PASSING (${passing.length}):`);
|
|
226
|
+
for (const r of passing) {
|
|
227
|
+
console.log(` ✓ ${r.repoPath}`);
|
|
228
|
+
}
|
|
229
|
+
console.log();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (failing.length > 0 && hasFlag('--fix')) {
|
|
233
|
+
console.log('Fixing...\n');
|
|
234
|
+
for (const r of failing) {
|
|
235
|
+
const created = fixCompliance(r.fullPath);
|
|
236
|
+
if (created.length > 0) {
|
|
237
|
+
console.log(` ${r.repoPath}: created ${created.join(', ')}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
console.log('\nFiles created. You still need to commit and push each repo.');
|
|
241
|
+
} else if (failing.length > 0) {
|
|
242
|
+
console.log('Run with --fix to create missing files.');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (failing.length > 0) process.exit(1);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
case 'watchdog': {
|
|
250
|
+
const unmanifested = findUnmanifested(manifestPath, reposRoot);
|
|
251
|
+
if (jsonOutput) {
|
|
252
|
+
console.log(JSON.stringify(unmanifested, null, 2));
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
if (unmanifested.length === 0) {
|
|
256
|
+
console.log('All repos on disk are in the manifest.');
|
|
257
|
+
} else {
|
|
258
|
+
console.log(`Repos on disk but NOT in manifest (${unmanifested.length}):`);
|
|
259
|
+
for (const p of unmanifested) {
|
|
260
|
+
console.log(` ! ${p}`);
|
|
261
|
+
}
|
|
262
|
+
console.log('\nAdd them with: wip-repos add <path> --remote <org/repo>');
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
|
|
177
268
|
case 'claude': {
|
|
178
269
|
runClaude(manifestPath, args.slice(1));
|
|
179
270
|
break;
|
package/core.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Like prettier for folder structure.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
8
9
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, renameSync } from 'node:fs';
|
|
9
10
|
import { join, dirname, relative, resolve } from 'node:path';
|
|
10
11
|
|
|
@@ -58,45 +59,91 @@ export function saveManifest(manifestPath, meta, repos) {
|
|
|
58
59
|
writeFileSync(manifestPath, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
export function classifyRepoPath(relPath, fullPath) {
|
|
63
|
+
const parts = relPath.split('/');
|
|
64
|
+
if (parts.includes('.worktrees')) return { class: 'worktree', reason: 'inside .worktrees' };
|
|
65
|
+
if (parts.includes('_trash')) return { class: 'trash', reason: 'inside _trash' };
|
|
66
|
+
if (parts.includes('_sort')) return { class: 'sort', reason: 'inside _sort' };
|
|
67
|
+
if (parts.includes('_sunsetted')) return { class: 'sunsetted', reason: 'inside _sunsetted' };
|
|
68
|
+
if (parts.includes('_archive') || parts.includes('archive') || parts.includes('archived')) {
|
|
69
|
+
return { class: 'archived', reason: 'inside archive path' };
|
|
70
|
+
}
|
|
71
|
+
if (parts.includes('third-party') || parts.includes('third-party-repos') || parts.includes('external')) {
|
|
72
|
+
return { class: 'third-party', reason: 'inside third-party path' };
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const gitStat = statSync(join(fullPath, '.git'));
|
|
76
|
+
if (gitStat.isFile()) return { class: 'worktree', reason: '.git is a worktree file' };
|
|
77
|
+
} catch {}
|
|
78
|
+
return { class: 'active', reason: 'regular repo path' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shouldSkipDirName(name, includeIgnored) {
|
|
82
|
+
if (name === '.git' || name === 'node_modules' || name === '.DS_Store') return true;
|
|
83
|
+
if (includeIgnored) return false;
|
|
84
|
+
return ['.worktrees', '_trash', '_sort', '_sunsetted', '_archive', 'archive', 'archived'].includes(name);
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
/**
|
|
62
|
-
* Walk a directory tree and find all git repos (directories containing .git
|
|
63
|
-
* Returns
|
|
88
|
+
* Walk a directory tree and find all git repos (directories containing .git).
|
|
89
|
+
* Returns classified entries: { path, fullPath, class, reason }.
|
|
64
90
|
*/
|
|
65
|
-
export function
|
|
91
|
+
export function walkRepoEntries(rootDir, prefix = '', opts = {}) {
|
|
66
92
|
const results = [];
|
|
67
93
|
if (!existsSync(rootDir)) return results;
|
|
94
|
+
const includeIgnored = opts.includeIgnored || false;
|
|
95
|
+
const classFilter = opts.classFilter || null;
|
|
68
96
|
|
|
69
97
|
const entries = readdirSync(rootDir, { withFileTypes: true });
|
|
70
98
|
for (const entry of entries) {
|
|
71
99
|
if (!entry.isDirectory()) continue;
|
|
72
|
-
if (entry.name
|
|
73
|
-
if (entry.name === '.DS_Store') continue;
|
|
100
|
+
if (shouldSkipDirName(entry.name, includeIgnored)) continue;
|
|
74
101
|
|
|
75
102
|
const fullPath = join(rootDir, entry.name);
|
|
76
103
|
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
77
104
|
|
|
78
105
|
// If this directory has a .git, it's a repo
|
|
79
106
|
if (existsSync(join(fullPath, '.git'))) {
|
|
80
|
-
|
|
107
|
+
const cls = classifyRepoPath(relPath, fullPath);
|
|
108
|
+
if ((!classFilter || cls.class === classFilter) && (includeIgnored || cls.class === 'active')) {
|
|
109
|
+
results.push({ path: relPath, fullPath, ...cls });
|
|
110
|
+
}
|
|
81
111
|
} else {
|
|
82
112
|
// Recurse into non-repo directories (organizational folders)
|
|
83
|
-
results.push(...
|
|
113
|
+
results.push(...walkRepoEntries(fullPath, relPath, opts));
|
|
84
114
|
}
|
|
85
115
|
}
|
|
86
116
|
return results;
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
export function walkRepos(rootDir, prefix = '', opts = {}) {
|
|
120
|
+
return walkRepoEntries(rootDir, prefix, opts).map(r => r.path);
|
|
121
|
+
}
|
|
122
|
+
|
|
89
123
|
/**
|
|
90
124
|
* Check: diff the filesystem against the manifest.
|
|
91
125
|
* Returns { inManifestOnly, onDiskOnly, matched }
|
|
92
126
|
*/
|
|
93
|
-
export function check(manifestPath, reposRoot) {
|
|
127
|
+
export function check(manifestPath, reposRoot, opts = {}) {
|
|
94
128
|
const manifest = loadManifest(manifestPath);
|
|
95
|
-
const
|
|
96
|
-
const
|
|
129
|
+
const includeIgnored = opts.all || opts.includeIgnored || Boolean(opts.classFilter);
|
|
130
|
+
const manifestEntries = Object.keys(manifest.repos)
|
|
131
|
+
.map(path => ({ path, ...classifyRepoPath(path, join(reposRoot, path)) }))
|
|
132
|
+
.filter(entry => {
|
|
133
|
+
if (opts.classFilter) return entry.class === opts.classFilter;
|
|
134
|
+
return includeIgnored || entry.class === 'active';
|
|
135
|
+
});
|
|
136
|
+
const manifestPaths = new Set(manifestEntries.map(e => e.path));
|
|
137
|
+
const allDiskEntries = walkRepoEntries(reposRoot, '', { includeIgnored: true });
|
|
138
|
+
const diskEntries = walkRepoEntries(reposRoot, '', {
|
|
139
|
+
includeIgnored,
|
|
140
|
+
classFilter: opts.classFilter || null,
|
|
141
|
+
});
|
|
142
|
+
const diskPaths = new Set(diskEntries.map(e => e.path));
|
|
97
143
|
|
|
98
144
|
const inManifestOnly = [];
|
|
99
145
|
const onDiskOnly = [];
|
|
146
|
+
const onDiskOnlyDetails = [];
|
|
100
147
|
const matched = [];
|
|
101
148
|
|
|
102
149
|
for (const p of manifestPaths) {
|
|
@@ -110,16 +157,28 @@ export function check(manifestPath, reposRoot) {
|
|
|
110
157
|
for (const p of diskPaths) {
|
|
111
158
|
if (!manifestPaths.has(p)) {
|
|
112
159
|
onDiskOnly.push(p);
|
|
160
|
+
const detail = diskEntries.find(e => e.path === p);
|
|
161
|
+
if (detail) onDiskOnlyDetails.push(detail);
|
|
113
162
|
}
|
|
114
163
|
}
|
|
115
164
|
|
|
165
|
+
const ignored = {};
|
|
166
|
+
for (const entry of allDiskEntries) {
|
|
167
|
+
if (entry.class === 'active') continue;
|
|
168
|
+
ignored[entry.class] = (ignored[entry.class] || 0) + 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
116
171
|
return {
|
|
117
172
|
inManifestOnly: inManifestOnly.sort(),
|
|
118
173
|
onDiskOnly: onDiskOnly.sort(),
|
|
174
|
+
onDiskOnlyDetails: onDiskOnlyDetails.sort((a, b) => a.path.localeCompare(b.path)),
|
|
119
175
|
matched: matched.sort(),
|
|
176
|
+
disk: diskEntries.sort((a, b) => a.path.localeCompare(b.path)),
|
|
177
|
+
ignored,
|
|
120
178
|
total: {
|
|
121
179
|
manifest: manifestPaths.size,
|
|
122
180
|
disk: diskPaths.size,
|
|
181
|
+
diskAll: allDiskEntries.length,
|
|
123
182
|
matched: matched.length,
|
|
124
183
|
},
|
|
125
184
|
};
|
|
@@ -187,6 +246,14 @@ export function executeSync(moves, reposRoot) {
|
|
|
187
246
|
results.push({ ...move, status: 'skipped', reason: 'target exists' });
|
|
188
247
|
continue;
|
|
189
248
|
}
|
|
249
|
+
if (isWorktree(move.fromFull)) {
|
|
250
|
+
results.push({ ...move, status: 'skipped', reason: 'source is a linked worktree' });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (isDirtyRepo(move.fromFull)) {
|
|
254
|
+
results.push({ ...move, status: 'skipped', reason: 'source repo has uncommitted changes' });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
190
257
|
try {
|
|
191
258
|
renameSync(move.fromFull, move.toFull);
|
|
192
259
|
results.push({ ...move, status: 'moved' });
|
|
@@ -197,6 +264,26 @@ export function executeSync(moves, reposRoot) {
|
|
|
197
264
|
return results;
|
|
198
265
|
}
|
|
199
266
|
|
|
267
|
+
export function isWorktree(repoPath) {
|
|
268
|
+
try {
|
|
269
|
+
return statSync(join(repoPath, '.git')).isFile();
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function isDirtyRepo(repoPath) {
|
|
276
|
+
try {
|
|
277
|
+
return execSync('git status --porcelain 2>/dev/null', {
|
|
278
|
+
cwd: repoPath,
|
|
279
|
+
encoding: 'utf8',
|
|
280
|
+
timeout: 3000,
|
|
281
|
+
}).trim().length > 0;
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
200
287
|
/**
|
|
201
288
|
* Add a repo to the manifest.
|
|
202
289
|
*/
|
|
@@ -288,3 +375,119 @@ export function generateReadmeTree(manifestPath) {
|
|
|
288
375
|
render(tree, '', false);
|
|
289
376
|
return lines.join('\n');
|
|
290
377
|
}
|
|
378
|
+
|
|
379
|
+
// ── Compliance checking (Wave 3.3) ──────────────────────────────────
|
|
380
|
+
|
|
381
|
+
const DEFAULT_LICENSE_GUARD = {
|
|
382
|
+
copyright: 'WIP Computer, Inc.',
|
|
383
|
+
license: 'MIT+AGPL',
|
|
384
|
+
spdx: 'MIT AND AGPL-3.0-or-later',
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Check compliance of all repos in the manifest.
|
|
389
|
+
* Returns array of { repoPath, fullPath, issues[], hasPackageJson }
|
|
390
|
+
*/
|
|
391
|
+
export function checkCompliance(manifestPath, reposRoot) {
|
|
392
|
+
const manifest = loadManifest(manifestPath);
|
|
393
|
+
const results = [];
|
|
394
|
+
|
|
395
|
+
for (const [repoPath, info] of Object.entries(manifest.repos)) {
|
|
396
|
+
const fullPath = join(reposRoot, repoPath);
|
|
397
|
+
if (!existsSync(fullPath)) continue;
|
|
398
|
+
if (!existsSync(join(fullPath, 'package.json'))) continue;
|
|
399
|
+
|
|
400
|
+
// Skip _to-privatize, _sort, _sunsetted, _trash
|
|
401
|
+
if (repoPath.includes('_to-privatize') || repoPath.includes('_sort') ||
|
|
402
|
+
repoPath.includes('_sunsetted') || repoPath.includes('_trash')) continue;
|
|
403
|
+
|
|
404
|
+
const issues = [];
|
|
405
|
+
|
|
406
|
+
if (!existsSync(join(fullPath, '.license-guard.json'))) {
|
|
407
|
+
issues.push('missing .license-guard.json');
|
|
408
|
+
}
|
|
409
|
+
if (!existsSync(join(fullPath, 'LICENSE'))) {
|
|
410
|
+
issues.push('missing LICENSE');
|
|
411
|
+
} else {
|
|
412
|
+
const lic = readFileSync(join(fullPath, 'LICENSE'), 'utf8');
|
|
413
|
+
if (!lic.includes('WIP Computer')) issues.push('LICENSE missing copyright');
|
|
414
|
+
if (!lic.includes('AGPL') && !lic.includes('GNU Affero')) issues.push('LICENSE missing AGPL');
|
|
415
|
+
}
|
|
416
|
+
if (!existsSync(join(fullPath, 'CLA.md'))) {
|
|
417
|
+
issues.push('missing CLA.md');
|
|
418
|
+
}
|
|
419
|
+
if (existsSync(join(fullPath, 'ai')) && !existsSync(join(fullPath, '.npmignore'))) {
|
|
420
|
+
issues.push('missing .npmignore (has ai/ directory)');
|
|
421
|
+
} else if (existsSync(join(fullPath, 'ai')) && existsSync(join(fullPath, '.npmignore'))) {
|
|
422
|
+
const npmignore = readFileSync(join(fullPath, '.npmignore'), 'utf8');
|
|
423
|
+
if (!npmignore.includes('ai/')) issues.push('.npmignore does not exclude ai/');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
results.push({ repoPath, fullPath, issues, clean: issues.length === 0 });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return results;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Fix compliance issues for a single repo.
|
|
434
|
+
* Creates missing files with WIP Computer defaults.
|
|
435
|
+
* Returns array of files created.
|
|
436
|
+
*/
|
|
437
|
+
export function fixCompliance(fullPath) {
|
|
438
|
+
const created = [];
|
|
439
|
+
|
|
440
|
+
// .license-guard.json
|
|
441
|
+
if (!existsSync(join(fullPath, '.license-guard.json'))) {
|
|
442
|
+
writeFileSync(
|
|
443
|
+
join(fullPath, '.license-guard.json'),
|
|
444
|
+
JSON.stringify(DEFAULT_LICENSE_GUARD, null, 2) + '\n',
|
|
445
|
+
);
|
|
446
|
+
created.push('.license-guard.json');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// CLA.md
|
|
450
|
+
if (!existsSync(join(fullPath, 'CLA.md'))) {
|
|
451
|
+
writeFileSync(join(fullPath, 'CLA.md'), [
|
|
452
|
+
'# Contributor License Agreement',
|
|
453
|
+
'',
|
|
454
|
+
'By submitting a pull request to this repository, you agree that:',
|
|
455
|
+
'',
|
|
456
|
+
'1. Your contribution is original work or you have the right to submit it.',
|
|
457
|
+
'2. You grant WIP Computer, Inc. a perpetual, worldwide, non-exclusive,',
|
|
458
|
+
' royalty-free license to use, modify, and distribute your contribution',
|
|
459
|
+
' under the terms of this project\'s license.',
|
|
460
|
+
'3. You understand your contribution will be publicly available.',
|
|
461
|
+
'',
|
|
462
|
+
'This CLA is required for all contributions. By opening a PR, you accept these terms.',
|
|
463
|
+
'',
|
|
464
|
+
].join('\n'));
|
|
465
|
+
created.push('CLA.md');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// .npmignore (if ai/ exists)
|
|
469
|
+
if (existsSync(join(fullPath, 'ai'))) {
|
|
470
|
+
const npmignorePath = join(fullPath, '.npmignore');
|
|
471
|
+
if (!existsSync(npmignorePath)) {
|
|
472
|
+
writeFileSync(npmignorePath, 'ai/\n.claude/\n.worktrees/\n.DS_Store\nCLAUDE.md\n');
|
|
473
|
+
created.push('.npmignore');
|
|
474
|
+
} else {
|
|
475
|
+
const content = readFileSync(npmignorePath, 'utf8');
|
|
476
|
+
if (!content.includes('ai/')) {
|
|
477
|
+
writeFileSync(npmignorePath, content.trimEnd() + '\nai/\n');
|
|
478
|
+
created.push('.npmignore (updated)');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return created;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Find repos on disk that are NOT in the manifest.
|
|
488
|
+
* This is the watchdog function for the daily audit.
|
|
489
|
+
*/
|
|
490
|
+
export function findUnmanifested(manifestPath, reposRoot) {
|
|
491
|
+
const result = check(manifestPath, reposRoot);
|
|
492
|
+
return result.onDiskOnly;
|
|
493
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wipcomputer/wip-repos",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.69",
|
|
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",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
".": "./core.mjs"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node cli.mjs --help"
|
|
14
|
+
"test": "node cli.mjs --help && node test.mjs"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"repo-manifest",
|
package/test.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { check, executeSync, planSync } from './core.mjs';
|
|
7
|
+
|
|
8
|
+
function run(cmd, cwd) {
|
|
9
|
+
execSync(cmd, { cwd, stdio: 'ignore' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function initRepo(path, remote) {
|
|
13
|
+
mkdirSync(path, { recursive: true });
|
|
14
|
+
run('git init -q -b main', path);
|
|
15
|
+
run(`git remote add origin git@github.com:${remote}.git`, path);
|
|
16
|
+
writeFileSync(join(path, 'README.md'), '# repo\n');
|
|
17
|
+
run('git add README.md', path);
|
|
18
|
+
run('git -c user.email=t@t -c user.name=t commit -q -m init', path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), 'wip-repos-test-'));
|
|
22
|
+
const manifestPath = join(root, 'repos-manifest.json');
|
|
23
|
+
|
|
24
|
+
initRepo(join(root, 'active/repo-a'), 'wipcomputer/repo-a');
|
|
25
|
+
initRepo(join(root, '_trash/old-repo'), 'wipcomputer/old-repo');
|
|
26
|
+
initRepo(join(root, 'active/misplaced'), 'wipcomputer/right-place');
|
|
27
|
+
mkdirSync(join(root, '.worktrees'), { recursive: true });
|
|
28
|
+
run(`git worktree add -q -b feat ${join(root, '.worktrees/repo-a--feat')}`, join(root, 'active/repo-a'));
|
|
29
|
+
|
|
30
|
+
writeFileSync(manifestPath, JSON.stringify({
|
|
31
|
+
_format: 'v2',
|
|
32
|
+
'active/repo-a': { remote: 'wipcomputer/repo-a' },
|
|
33
|
+
'active/right-place': { remote: 'wipcomputer/right-place' },
|
|
34
|
+
}, null, 2) + '\n');
|
|
35
|
+
|
|
36
|
+
const defaultCheck = check(manifestPath, root);
|
|
37
|
+
if (defaultCheck.onDiskOnly.includes('_trash/old-repo')) {
|
|
38
|
+
throw new Error('default check should ignore _trash repos');
|
|
39
|
+
}
|
|
40
|
+
if (defaultCheck.disk.some(r => r.class !== 'active')) {
|
|
41
|
+
throw new Error('default check should only include active repos');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const allCheck = check(manifestPath, root, { all: true });
|
|
45
|
+
if (!allCheck.disk.some(r => r.class === 'trash')) {
|
|
46
|
+
throw new Error('--all check should include trash class');
|
|
47
|
+
}
|
|
48
|
+
if (!allCheck.disk.some(r => r.class === 'worktree')) {
|
|
49
|
+
throw new Error('--all check should include worktree class');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const worktreeCheck = check(manifestPath, root, { classFilter: 'worktree' });
|
|
53
|
+
if (worktreeCheck.disk.length !== 1 || worktreeCheck.disk[0].class !== 'worktree') {
|
|
54
|
+
throw new Error('--class worktree should return only worktrees');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const moves = planSync(manifestPath, root);
|
|
58
|
+
if (moves.length !== 1 || moves[0].to !== 'active/right-place') {
|
|
59
|
+
throw new Error('planSync should plan the misplaced active repo only');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
writeFileSync(join(root, 'active/misplaced/dirty.txt'), 'dirty\n');
|
|
63
|
+
const results = executeSync(moves, root);
|
|
64
|
+
if (results[0].status !== 'skipped' || !results[0].reason.includes('uncommitted')) {
|
|
65
|
+
throw new Error('executeSync should refuse dirty repo moves');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log('wip-repos regression passed');
|