@tenphi/harnesspm 0.0.0-snapshot.376310a

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 (46) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -0
  4. package/dist/added.js +189 -0
  5. package/dist/check.js +160 -0
  6. package/dist/cli.js +139 -0
  7. package/dist/commands.js +414 -0
  8. package/dist/config.js +43 -0
  9. package/dist/exec-outputs.js +360 -0
  10. package/dist/exec-units.js +144 -0
  11. package/dist/fetch.js +93 -0
  12. package/dist/frontmatter.js +35 -0
  13. package/dist/homes.js +17 -0
  14. package/dist/lock.js +102 -0
  15. package/dist/manifest.js +154 -0
  16. package/dist/new.js +223 -0
  17. package/dist/outputs.js +453 -0
  18. package/dist/render.js +119 -0
  19. package/dist/resolve.js +396 -0
  20. package/dist/run.js +83 -0
  21. package/dist/sections.js +121 -0
  22. package/dist/user-outputs.js +195 -0
  23. package/dist/util.js +140 -0
  24. package/dist/workspaces.js +61 -0
  25. package/docs/spec/01-overview.md +130 -0
  26. package/docs/spec/02-package-format.md +238 -0
  27. package/docs/spec/03-resolution.md +104 -0
  28. package/docs/spec/04-outputs.md +109 -0
  29. package/docs/spec/05-executables.md +158 -0
  30. package/docs/spec/06-trust.md +72 -0
  31. package/docs/spec/07-added.md +104 -0
  32. package/docs/spec/08-improving.md +77 -0
  33. package/docs/spec/09-cloud.md +47 -0
  34. package/docs/spec/10-cli.md +60 -0
  35. package/docs/spec/11-standards.md +49 -0
  36. package/docs/spec/12-dogfooding.md +51 -0
  37. package/docs/spec/13-open-questions.md +85 -0
  38. package/docs/spec/README.md +33 -0
  39. package/harness/dev.harnesspm/rules/generated-files.md +4 -0
  40. package/harness/dev.harnesspm/rules/harnesspm-config.md +11 -0
  41. package/harness/plugin.json +13 -0
  42. package/harness/skills/harnesspm-export/SKILL.md +39 -0
  43. package/harness/skills/harnesspm-import/SKILL.md +35 -0
  44. package/harness/skills/harnesspm-improve/SKILL.md +38 -0
  45. package/package.json +60 -0
  46. package/schema/harnesspm.schema.json +31 -0
@@ -0,0 +1,414 @@
1
+ // CLI commands (spec chapter 10).
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { caller, configFileFor, fetchSource, loadAdded, parseSource, readList, updateSource, writeList, } from './added.js';
5
+ import { checkHarness } from './check.js';
6
+ import { CONFIG_FILE, CONFIG_SCHEMA } from './config.js';
7
+ import { loadHarnessPackage } from './manifest.js';
8
+ import { heldEntries } from './exec-outputs.js';
9
+ import { buildLock, LOCK_FILE, readLock, serializeLock } from './lock.js';
10
+ import { applyPlan, checkPlan, computePlan, repoStateFile, STATE_DIR } from './outputs.js';
11
+ import { fetchLocked } from './fetch.js';
12
+ import { createPatch, createUnit, KINDS, resolveTarget } from './new.js';
13
+ import { runMcp, runScript } from './run.js';
14
+ import { alwaysOnText, withClaudeImport } from './render.js';
15
+ import { resolve, resolvePackageDir } from './resolve.js';
16
+ import { writeUserOutputs, writeUserSettings } from './user-outputs.js';
17
+ import { HarnessError, NPX_ANY, gitExcludeFile, isDir, isFile, isRecord, readJson, readJsonIfExists, readTextIfExists, relPosix, writeIfChanged } from './util.js';
18
+ import { spawnSync } from 'node:child_process';
19
+ const printWarnings = (out, warnings) => {
20
+ for (const w of [...new Set(warnings)])
21
+ out.warn(`⚠️ ${w}`);
22
+ };
23
+ const plural = (count, word, many = `${word}s`) => `${count} ${count === 1 ? word : many}`;
24
+ function printPending(out, report) {
25
+ for (const line of [...new Set(report.pending)])
26
+ out.log(`⏳ pending: ${line}`);
27
+ if (report.held.length > 0) {
28
+ out.log(`⏳ held back: ${plural(report.held.length, 'entry', 'entries')} that an agent cannot grant (${report.held.slice(0, 3).join('; ')}${report.held.length > 3 ? '; …' : ''}).`);
29
+ out.log(` Ask the person to run \`${NPX_ANY} sync\` or \`${NPX_ANY} approve\` in their own terminal.`);
30
+ }
31
+ }
32
+ /** The repository to work on: the nearest folder with harnesspm.json, else the git root, else `cwd`. */
33
+ export function findProjectDir(cwd) {
34
+ for (let dir = cwd;; dir = path.dirname(dir)) {
35
+ if (isFile(path.join(dir, CONFIG_FILE)))
36
+ return dir;
37
+ if (path.dirname(dir) === dir)
38
+ break;
39
+ }
40
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
41
+ return top.status === 0 ? top.stdout.trim() : cwd;
42
+ }
43
+ /** True when every direct dependency that is not optional is installed. */
44
+ function dependenciesInstalled(projectDir) {
45
+ const pkg = readJsonIfExists(path.join(projectDir, 'package.json'));
46
+ if (!isRecord(pkg))
47
+ return true;
48
+ if (!isDir(path.join(projectDir, 'node_modules'))) {
49
+ const any = ['dependencies', 'devDependencies'].some((f) => isRecord(pkg[f]) && Object.keys(pkg[f]).length > 0);
50
+ return !any;
51
+ }
52
+ for (const field of ['dependencies', 'devDependencies']) {
53
+ const deps = pkg[field];
54
+ if (!isRecord(deps))
55
+ continue;
56
+ for (const name of Object.keys(deps))
57
+ if (!resolvePackageDir(name, projectDir))
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+ function resolveAll(projectDir, options) {
63
+ const warnings = [];
64
+ const usesHarnesspm = isFile(path.join(projectDir, CONFIG_FILE));
65
+ const installed = usesHarnesspm && dependenciesInstalled(projectDir);
66
+ if (usesHarnesspm && !installed)
67
+ warnings.push('the dependencies are not installed, so the committed outputs are left as they are');
68
+ const inRepo = gitExcludeFile(projectDir) !== undefined;
69
+ const added = options.withAdded && process.env['CI'] !== 'true' ? loadAdded(inRepo ? projectDir : undefined, warnings) : [];
70
+ const team = installed ? resolve(projectDir) : undefined;
71
+ // Outside a git repository only user-scope harnesses load, and no repository outputs are written.
72
+ const full = resolve(projectDir, { team: installed, added });
73
+ const mode = installed ? 'full' : inRepo && added.length > 0 ? 'added-only' : 'none';
74
+ return { ...(team ? { team } : {}), full, added, mode, warnings: [...warnings, ...(team?.warnings ?? []), ...full.warnings] };
75
+ }
76
+ export function sync(cwd, out, options = {}) {
77
+ const projectDir = findProjectDir(cwd);
78
+ if (options.check) {
79
+ const { team, full, warnings } = resolveAll(projectDir, { withAdded: false });
80
+ const plan = computePlan(team, full);
81
+ printWarnings(out, [...warnings, ...plan.warnings]);
82
+ const problems = checkPlan(projectDir, plan);
83
+ for (const p of problems)
84
+ out.log(`❌ ${p}`);
85
+ if (problems.length > 0) {
86
+ out.log('Run `npx harnesspm sync` and commit the result.');
87
+ return 1;
88
+ }
89
+ out.log('✅ harnesspm outputs are up to date');
90
+ return 0;
91
+ }
92
+ const resolved = resolveAll(projectDir, { withAdded: true });
93
+ const { full, mode } = resolved;
94
+ const warnings = [...resolved.warnings];
95
+ const changed = [];
96
+ const report = { pending: [], held: [] };
97
+ if (mode !== 'none') {
98
+ const plan = computePlan(resolved.team, full);
99
+ warnings.push(...plan.warnings);
100
+ const applied = applyPlan(projectDir, plan);
101
+ changed.push(...applied.changed);
102
+ report.held.push(...applied.held);
103
+ report.pending.push(...(plan.exec.team?.pending ?? []), ...plan.exec.added.pending);
104
+ }
105
+ if (process.env['CI'] !== 'true')
106
+ changed.push(...writeUserOutputs(resolved.added, warnings, report));
107
+ printWarnings(out, warnings);
108
+ printPending(out, report);
109
+ if (options.quiet)
110
+ return 0;
111
+ const counts = `${plural(full.rules.length, 'rule')}, ${plural(full.skills.length, 'skill')}, ${plural(full.docs.length, 'doc')} from ${plural(full.packages.length, 'package')}`;
112
+ const what = mode === 'none' ? 'Nothing to do in this folder' : mode === 'added-only' ? `Added harnesses only: ${counts}` : counts;
113
+ out.log(changed.length > 0 ? `harnesspm: ${what}. Updated ${plural(changed.length, 'file')}.` : `harnesspm: ${what}. Nothing changed.`);
114
+ return 0;
115
+ }
116
+ export function init(projectDir, out) {
117
+ const pkgFile = path.join(projectDir, 'package.json');
118
+ if (!isFile(pkgFile))
119
+ throw new HarnessError('No package.json here. Run `npm init` first.');
120
+ const configFile = path.join(projectDir, CONFIG_FILE);
121
+ if (!isFile(configFile)) {
122
+ writeIfChanged(configFile, `${JSON.stringify({ $schema: CONFIG_SCHEMA }, null, 2)}\n`);
123
+ out.log(`Created ${CONFIG_FILE}`);
124
+ }
125
+ const raw = readTextIfExists(pkgFile);
126
+ const pkg = readJson(pkgFile);
127
+ if (!isRecord(pkg))
128
+ throw new HarnessError('package.json must be a JSON object');
129
+ // A private package is never installed by others, so `postinstall` is safe there, and Yarn 2+ runs only
130
+ // `postinstall`. A published package uses `prepare`, which does not run when others install it.
131
+ const scripts = isRecord(pkg['scripts']) ? pkg['scripts'] : {};
132
+ const hookName = pkg['private'] === true ? 'postinstall' : 'prepare';
133
+ const has = (name) => typeof scripts[name] === 'string' && scripts[name].includes('harnesspm sync');
134
+ if (!has('prepare') && !has('postinstall')) {
135
+ const current = typeof scripts[hookName] === 'string' ? scripts[hookName] : undefined;
136
+ scripts[hookName] = current === undefined ? 'harnesspm sync' : `${current} && harnesspm sync`;
137
+ pkg['scripts'] = scripts;
138
+ const indent = /^\{\r?\n(\s+)/.exec(raw)?.[1] ?? ' ';
139
+ writeIfChanged(pkgFile, `${JSON.stringify(pkg, null, indent)}\n`);
140
+ out.log(`Added \`harnesspm sync\` to the "${hookName}" script`);
141
+ }
142
+ const claudeFile = path.join(projectDir, 'CLAUDE.md');
143
+ if (writeIfChanged(claudeFile, withClaudeImport(readTextIfExists(claudeFile))))
144
+ out.log('CLAUDE.md now imports AGENTS.md');
145
+ return sync(projectDir, out);
146
+ }
147
+ /** Directories of editable added harnesses, which agents may write to (spec 8.5). */
148
+ function writableDirs() {
149
+ const dirs = new Set();
150
+ for (const spec of readList(configFileFor('user', ''))) {
151
+ const source = parseSource(spec);
152
+ if (source.editable)
153
+ dirs.add(source.dir);
154
+ }
155
+ return [...dirs].sort();
156
+ }
157
+ /** Writes the hook and write access, unless the agent's model runs harnesspm (spec 6.2, rule 4). */
158
+ function settingsAfterChange(out, hook) {
159
+ if (caller() === 'model') {
160
+ out.log('⏳ The SessionStart hook and write access to the harness folder are pending: an agent cannot grant them.');
161
+ out.log(' Ask the person to run the same command in their own terminal.');
162
+ return;
163
+ }
164
+ const anyUser = readList(configFileFor('user', '')).length > 0;
165
+ writeUserSettings(hook && anyUser, writableDirs());
166
+ }
167
+ function printAddedRules(projectDir, spec, out) {
168
+ const warnings = [];
169
+ const harness = loadAdded(gitExcludeFile(projectDir) ? projectDir : undefined, warnings).find((h) => h.source.spec === spec);
170
+ if (!harness)
171
+ return;
172
+ const res = resolve(projectDir, { team: false, added: [harness] });
173
+ const always = res.rules.filter((r) => r.activation === 'always');
174
+ if (always.length === 0)
175
+ return;
176
+ out.log('');
177
+ out.log(`Rules from ${spec}, to follow from now on in this session:`);
178
+ out.log('');
179
+ for (const rule of always)
180
+ out.log(`${alwaysOnText(rule)}\n`);
181
+ }
182
+ export function add(cwd, spec, out, options = {}) {
183
+ const projectDir = findProjectDir(cwd);
184
+ const scope = options.local ? 'local' : 'user';
185
+ if (scope === 'local' && !gitExcludeFile(projectDir))
186
+ throw new HarnessError('--local needs a git repository');
187
+ const source = parseSource(spec, cwd);
188
+ const note = fetchSource(source);
189
+ if (note !== undefined)
190
+ out.log(note);
191
+ const pkg = loadHarnessPackage(source.dir, 'added');
192
+ if (!pkg)
193
+ throw new HarnessError(`${source.spec} has no harness/plugin.json`);
194
+ const file = configFileFor(scope, projectDir);
195
+ const list = readList(file);
196
+ if (!list.includes(source.spec)) {
197
+ writeList(file, [...list, source.spec]);
198
+ out.log(`Added ${pkg.plugin} (${source.spec}) with ${scope} scope`);
199
+ }
200
+ else
201
+ out.log(`${source.spec} is already added with ${scope} scope`);
202
+ if (scope === 'local') {
203
+ const exclude = gitExcludeFile(projectDir);
204
+ const text = readTextIfExists(exclude) ?? '';
205
+ if (!text.split('\n').includes('/harnesspm.local.json'))
206
+ writeIfChanged(exclude, `${text.replace(/\s*$/, '')}${text.trim() ? '\n' : ''}/harnesspm.local.json\n`);
207
+ }
208
+ sync(projectDir, out, { quiet: true });
209
+ settingsAfterChange(out, options.hook !== false);
210
+ printAddedRules(projectDir, source.spec, out);
211
+ return 0;
212
+ }
213
+ export function remove(cwd, spec, out, options = {}) {
214
+ const projectDir = findProjectDir(cwd);
215
+ const scope = options.local ? 'local' : 'user';
216
+ const file = configFileFor(scope, projectDir);
217
+ const list = readList(file);
218
+ const target = parseSource(spec, cwd).spec;
219
+ const next = list.filter((s) => s !== spec && s !== target);
220
+ if (next.length === list.length)
221
+ throw new HarnessError(`${spec} is not added with ${scope} scope`);
222
+ writeList(file, next);
223
+ out.log(`Removed ${spec} (${scope} scope)`);
224
+ sync(projectDir, out, { quiet: true });
225
+ settingsAfterChange(out, true);
226
+ return 0;
227
+ }
228
+ export function update(cwd, out) {
229
+ const projectDir = findProjectDir(cwd);
230
+ const specs = [...readList(configFileFor('user', projectDir)), ...(gitExcludeFile(projectDir) ? readList(configFileFor('local', projectDir)) : [])];
231
+ for (const spec of specs)
232
+ out.log(updateSource(parseSource(spec)));
233
+ return sync(projectDir, out);
234
+ }
235
+ function describeAdded(h) {
236
+ return `${h.pkg.plugin} (${h.source.spec}, ${h.scope})${h.entry ? ', with an entry for this repository' : ''}`;
237
+ }
238
+ export function status(cwd, out) {
239
+ const projectDir = findProjectDir(cwd);
240
+ const resolved = resolveAll(projectDir, { withAdded: true });
241
+ const res = resolved.team ?? resolved.full;
242
+ const deps = res.packages.filter((p) => p.layer === 'dependencies');
243
+ const describe = (name, version) => `${name} ${version}`;
244
+ const tops = deps.filter((p) => p.direct);
245
+ const parts = tops.map((p) => {
246
+ const below = deps.filter((d) => p.closure.has(d.name)).map((d) => describe(d.name, d.version));
247
+ return below.length > 0 ? `${describe(p.name, p.version)} → ${below.join(', ')}` : describe(p.name, p.version);
248
+ });
249
+ parts.push(...deps.filter((d) => !d.direct && !tops.some((t) => t.closure.has(d.name))).map((d) => describe(d.name, d.version)));
250
+ out.log(`📦 dependencies ${parts.length > 0 ? parts.join(' · ') : 'none'}`);
251
+ const own = res.packages.find((p) => p.layer === 'project');
252
+ const config = res.config;
253
+ const counts = [
254
+ config.exclude.length > 0 ? `${config.exclude.length} exclude` : '',
255
+ Object.keys(config.replace).length > 0 ? `${Object.keys(config.replace).length} replace` : '',
256
+ Object.keys(config.patch).length > 0 ? `${Object.keys(config.patch).length} patch` : '',
257
+ ].filter(Boolean);
258
+ const projectParts = isFile(path.join(projectDir, CONFIG_FILE)) ? [`${CONFIG_FILE}: ${counts.length > 0 ? counts.join(' · ') : 'no changes'}`] : ['does not use harnesspm'];
259
+ if (own)
260
+ projectParts.push(`harness/ (plugin ${own.plugin})`);
261
+ out.log(`🏢 project ${projectParts.join(' · ')}`);
262
+ out.log(`➕ added ${resolved.added.length > 0 ? resolved.added.map(describeAdded).join(' · ') : 'none'}`);
263
+ const plan = resolved.mode !== 'none' ? computePlan(resolved.team, resolved.full) : undefined;
264
+ if (plan) {
265
+ const pending = [...(plan.exec.team?.pending ?? []), ...plan.exec.added.pending];
266
+ const stateFile = repoStateFile(projectDir);
267
+ const held = [...(plan.exec.team ? heldEntries('team', plan.exec.team, stateFile) : []), ...heldEntries('added', plan.exec.added, stateFile)];
268
+ printPending(out, { pending, held });
269
+ }
270
+ if (resolved.team) {
271
+ const changes = resolved.team.rules.filter((r) => resolved.full.rules.find((f) => f.id === r.id)?.text !== r.text).map((r) => r.id);
272
+ if (changes.length > 0)
273
+ out.log(`✏️ changed here ${changes.join(', ')} (by added harnesses; see CLAUDE.local.md)`);
274
+ const agents = plan.committed.agentsMd;
275
+ const bytes = Buffer.byteLength(agents);
276
+ const claudeLines = agents.split('\n').length + (plan.localMd ?? '').split('\n').length;
277
+ out.log(`📏 always-on Codex ${(bytes / 1024).toFixed(1)} KiB of 32 KiB · Claude Code ${claudeLines} lines`);
278
+ if (bytes > 28 * 1024)
279
+ out.warn("⚠️ AGENTS.md is close to Codex's 32 KiB limit. Move details into on-demand rules or docs.");
280
+ }
281
+ printWarnings(out, resolved.warnings);
282
+ return 0;
283
+ }
284
+ export function why(cwd, id, out) {
285
+ const projectDir = findProjectDir(cwd);
286
+ const { full: res } = resolveAll(projectDir, { withAdded: true });
287
+ const row = (label, value) => out.log(`${label.padEnd(11)} ${value}`);
288
+ const rel = (file) => {
289
+ for (const pkg of res.packages) {
290
+ if (pkg.layer === 'dependencies' && file.startsWith(pkg.dir + path.sep)) {
291
+ return path.posix.join(STATE_DIR.split(path.sep).join('/'), 'pkgs', pkg.name, relPosix(pkg.dir, file));
292
+ }
293
+ }
294
+ return file.startsWith(res.projectDir + path.sep) ? relPosix(res.projectDir, file) : file;
295
+ };
296
+ const excluded = res.excludedUnits.find((u) => u.id === id);
297
+ const rule = res.rules.find((r) => r.id === id);
298
+ const skill = res.skills.find((s) => s.id === id);
299
+ const docs = res.docs.find((d) => d.id === id);
300
+ const pkg = rule?.pkg ?? skill?.pkg ?? docs?.pkg;
301
+ if (!pkg) {
302
+ if (excluded) {
303
+ row('excluded', `by ${excluded.by}`);
304
+ return 0;
305
+ }
306
+ const plugin = id.split(':')[0];
307
+ const near = [...res.rules.map((r) => r.id), ...res.skills.map((s) => s.id)].filter((x) => x.startsWith(`${plugin}:`)).slice(0, 5);
308
+ throw new HarnessError(`No unit ${id}.${near.length > 0 ? ` Units of ${plugin}: ${near.join(', ')}` : ''}`);
309
+ }
310
+ if (pkg.layer === 'project')
311
+ row('owner', `project ${pkg.name} (harness/)`);
312
+ else if (pkg.layer === 'added')
313
+ row('owner', `added ${pkg.added.source.spec} (${pkg.scope} scope${pkg.entry ? ', entry for this repository' : ''})`);
314
+ else
315
+ row('owner', `dependency ${pkg.name} ${pkg.version}${pkg.via !== undefined ? ` (via ${pkg.via})` : ''}`);
316
+ const source = rule?.source ?? (skill ? path.join(skill.dir, 'SKILL.md') : docs.dir);
317
+ row('source', rel(source));
318
+ if (rule?.replacedBy)
319
+ row('replaced', `by ${rule.replacedBy.by}: ${rel(rule.replacedBy.file)}`);
320
+ if (rule && rule.original !== rule.source)
321
+ row('original', rel(rule.original));
322
+ for (const p of rule?.patches ?? [])
323
+ row('patch', `${p.patch} (from ${p.by})`);
324
+ if (pkg.layer === 'added') {
325
+ const rules = ['AGENTS.md', 'CONTRIBUTING.md'].map((f) => path.join(pkg.added.source.dir, f)).filter((f) => fs.existsSync(f));
326
+ if (rules.length > 0)
327
+ row('rules', rules.join(', '));
328
+ }
329
+ if (pkg.repository !== undefined)
330
+ row('repository', pkg.repository);
331
+ if (pkg.layer === 'project')
332
+ row('editable', 'yes. Edit the source, then run `npx harnesspm sync`.');
333
+ else if (pkg.layer === 'added') {
334
+ row('editable', pkg.added.source.editable ? "yes. Edit the source, then save it the way that harness's rules say." : 'no: it comes from npm. Change it in its own repository.');
335
+ }
336
+ else
337
+ row('editable', `no. Add a \`patch\`, \`replace\` or \`exclude\` in ${CONFIG_FILE}, or propose the change upstream.`);
338
+ return 0;
339
+ }
340
+ export function run(cwd, ref, args, agent) {
341
+ const projectDir = findProjectDir(cwd);
342
+ return runScript(resolveAll(projectDir, { withAdded: true }).full, ref, args, agent);
343
+ }
344
+ export function mcp(cwd, ref, agent) {
345
+ const projectDir = findProjectDir(cwd);
346
+ return runMcp(resolveAll(projectDir, { withAdded: true }).full, ref, agent);
347
+ }
348
+ /** Approves new versions of "pin" packages and writes what a model run left pending (spec 6.2). */
349
+ export function approve(cwd, name, out) {
350
+ if (caller() === 'model')
351
+ throw new HarnessError('`harnesspm approve` is for a person. Ask the person to run it in their own terminal.');
352
+ const projectDir = findProjectDir(cwd);
353
+ const resolved = resolveAll(projectDir, { withAdded: false });
354
+ if (resolved.team) {
355
+ const lock = readLock(projectDir) ?? buildLock(resolved.team, undefined);
356
+ let approved = 0;
357
+ for (const pkg of resolved.team.packages) {
358
+ if (resolved.team.config.trust[pkg.name] !== 'pin' || (name !== undefined && pkg.name !== name))
359
+ continue;
360
+ const entry = lock.packages[pkg.name];
361
+ if (entry && entry.approved !== pkg.version) {
362
+ entry.version = pkg.version;
363
+ entry.approved = pkg.version;
364
+ approved++;
365
+ out.log(`✅ approved ${pkg.name} ${pkg.version}`);
366
+ }
367
+ }
368
+ if (name !== undefined && approved === 0 && resolved.team.config.trust[name] !== 'pin')
369
+ throw new HarnessError(`${name} is not a "pin" package in ${CONFIG_FILE}`);
370
+ if (approved > 0)
371
+ writeIfChanged(path.join(projectDir, LOCK_FILE), serializeLock(lock));
372
+ }
373
+ return sync(projectDir, out);
374
+ }
375
+ export function newUnit(cwd, kind, name, options, out) {
376
+ const projectDir = findProjectDir(cwd);
377
+ const show = (file) => (file.startsWith(projectDir + path.sep) ? relPosix(projectDir, file) : file);
378
+ if (kind === 'patch') {
379
+ const { full } = resolveAll(projectDir, { withAdded: true });
380
+ const result = createPatch(projectDir, full, name, options);
381
+ out.log(`Wrote ${show(result.file)}, registered in ${show(result.registeredIn)}.`);
382
+ out.log('Keep only the sections you change and edit them. Delete the others. To drop a section, list its heading in `remove:` frontmatter.');
383
+ }
384
+ else {
385
+ if (!KINDS.includes(kind))
386
+ throw new HarnessError(`Unknown kind "${kind}". Use one of: ${KINDS.join(', ')}, patch.`);
387
+ const target = resolveTarget(projectDir, options);
388
+ const files = createUnit(target, kind, name);
389
+ out.log(`Created in ${target.label}:`);
390
+ for (const file of files)
391
+ out.log(` ${show(file)}`);
392
+ out.log('Edit it, then save it the way that harness\'s owner wants (`npx harnesspm why <ID>` names the rules).');
393
+ }
394
+ sync(projectDir, out, { quiet: true });
395
+ return 0;
396
+ }
397
+ export function fetch(cwd, out) {
398
+ const projectDir = findProjectDir(cwd);
399
+ const report = fetchLocked(projectDir);
400
+ printWarnings(out, report.warnings);
401
+ out.log(`harnesspm: fetched ${plural(report.fetched.length, 'package')}${report.fetched.length > 0 ? ` (${report.fetched.join(', ')})` : ''}, reused ${report.reused.length}.`);
402
+ return 0;
403
+ }
404
+ export function check(target, out) {
405
+ const result = checkHarness(target);
406
+ printWarnings(out, result.warnings);
407
+ for (const e of result.errors)
408
+ out.log(`❌ ${e}`);
409
+ out.log(`📏 always-on text of ${result.plugin}: ${(result.alwaysOnBytes / 1024).toFixed(1)} KiB`);
410
+ if (result.errors.length > 0)
411
+ return 1;
412
+ out.log('✅ harness is valid');
413
+ return 0;
414
+ }
package/dist/config.js ADDED
@@ -0,0 +1,43 @@
1
+ // harnesspm.json (spec 3.7).
2
+ import * as path from 'node:path';
3
+ import { stringList } from './frontmatter.js';
4
+ import { HarnessError, PACKAGE_NAME, isRecord, readJsonIfExists } from './util.js';
5
+ export const CONFIG_FILE = 'harnesspm.json';
6
+ export const CONFIG_SCHEMA = `https://unpkg.com/${PACKAGE_NAME}/schema/harnesspm.schema.json`;
7
+ const FIELDS = new Set(['$schema', 'exclude', 'replace', 'patch', 'trust']);
8
+ export function emptyConfig() {
9
+ return { exclude: [], replace: {}, patch: {}, trust: {}, warnings: [] };
10
+ }
11
+ export function readConfig(projectDir) {
12
+ const file = path.join(projectDir, CONFIG_FILE);
13
+ const raw = readJsonIfExists(file) ?? {};
14
+ if (!isRecord(raw))
15
+ throw new HarnessError(`${file} must be a JSON object`);
16
+ const warnings = [];
17
+ for (const key of Object.keys(raw))
18
+ if (!FIELDS.has(key))
19
+ warnings.push(`${CONFIG_FILE}: unknown field "${key}"`);
20
+ const map = (field) => {
21
+ const value = raw[field];
22
+ if (value === undefined)
23
+ return {};
24
+ if (isRecord(value) && Object.values(value).every((v) => typeof v === 'string'))
25
+ return value;
26
+ throw new HarnessError(`${CONFIG_FILE}: "${field}" must map strings to strings`);
27
+ };
28
+ const exclude = raw['exclude'] === undefined ? [] : stringList(raw['exclude']);
29
+ if (!exclude)
30
+ throw new HarnessError(`${CONFIG_FILE}: "exclude" must be an array of strings`);
31
+ const trust = {};
32
+ if (raw['trust'] !== undefined) {
33
+ if (!isRecord(raw['trust']))
34
+ throw new HarnessError(`${CONFIG_FILE}: "trust" must be an object`);
35
+ for (const [name, value] of Object.entries(raw['trust'])) {
36
+ if (value === true || value === 'pin')
37
+ trust[name] = value;
38
+ else
39
+ throw new HarnessError(`${CONFIG_FILE}: trust for "${name}" must be true or "pin"`);
40
+ }
41
+ }
42
+ return { exclude, replace: map('replace'), patch: map('patch'), trust, warnings };
43
+ }