@litfamily/litgrok 1.0.29 → 1.0.30

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.
@@ -0,0 +1,573 @@
1
+ // The short ground-rules section inside the owned user rule, and the project file lit-init asks for.
2
+ // Only the bytes between the markers are rewritten. A symlink, a non-file, or a broken marker pair is refused.
3
+
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { chmodSync, constants, copyFileSync, lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir, homedir } from 'node:os';
7
+ import { basename, dirname, join, resolve, sep } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ export const GROUND_MAX_BYTES = 1200;
11
+ export const GROUND_RULE = 'rules/00-litgrok.md';
12
+ export const PROJECT_RULE = '10-litgrok-ground.md';
13
+
14
+ const START_PREFIX = '<!-- litfamily:litgrok:start';
15
+ const END_MARKER = '<!-- litfamily:litgrok:end -->';
16
+ const START_LINE = /^<!-- litfamily:litgrok:start v=(\S+)((?: [a-z]+=[0-9a-f]*)*) -->/u;
17
+ const SETTING = 'litgrok-ground.json';
18
+ const SETTING_SCHEMA = 'litgrok.ground/v1';
19
+ const MANIFEST = '.litgrok-install-manifest.json';
20
+ const MANIFEST_SCHEMA = 'litgrok.install-manifest/v1';
21
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
22
+ const GROUND_USAGE = 'Usage: litgrok ground on|off|status\n litgrok ground project [--dir <path>] [--remove] [--dry-run]\n';
23
+
24
+ const PROJECT_BODY = `## LitGrok project rules
25
+
26
+ This repo is worked with LitGrok: lit-plan, start-work and review-work.
27
+
28
+ - Plans live in \`plans/\`. The session handoff lives in \`.handoff/HANDOFF.md\`, or in \`HANDOFF.md\` outside a Git repo. Run notes stay in \`.grok/litgrok/\` and out of commits. Read the handoff and the active plan before resuming.
29
+ - Never push, publish, tag or bump a version unless the user explicitly says yes to that action.
30
+ - Pair a behavior change with a test that failed first, and check the changed surface for real before calling work done.`;
31
+
32
+ export function packageVersion() {
33
+ const version = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
34
+ return typeof version === 'string' && version.length > 0 ? version : '0.0.0';
35
+ }
36
+
37
+ function eolOf(text) {
38
+ return text.includes('\r\n') ? '\r\n' : '\n';
39
+ }
40
+
41
+ function occurrences(text, needle) {
42
+ const found = [];
43
+ for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + needle.length)) found.push(at);
44
+ return found;
45
+ }
46
+
47
+ class GroundMarkError extends Error {}
48
+
49
+ function locate(text) {
50
+ const starts = occurrences(text, START_PREFIX);
51
+ const ends = occurrences(text, END_MARKER);
52
+ if (starts.length === 0 && ends.length === 0) return null;
53
+ if (starts.length !== 1 || ends.length !== 1 || ends[0] < starts[0]) throw new GroundMarkError('malformed ground-rule markers');
54
+ const head = START_LINE.exec(text.slice(starts[0]));
55
+ if (!head || head.index !== 0) throw new GroundMarkError('malformed ground-rule markers');
56
+ const end = ends[0] + END_MARKER.length;
57
+ const attrs = Object.fromEntries(head[2].trim().split(' ').filter(Boolean).map((pair) => pair.split('=')));
58
+ return {
59
+ start: starts[0],
60
+ end,
61
+ version: head[1],
62
+ pre: attrs.pre ? Buffer.from(attrs.pre, 'hex').toString('utf8') : '',
63
+ created: attrs.new === '1',
64
+ };
65
+ }
66
+
67
+ function render({ version, body }, eol, { pre, created }) {
68
+ const preAttr = pre ? ` pre=${Buffer.from(pre, 'utf8').toString('hex')}` : '';
69
+ const newAttr = created ? ' new=1' : '';
70
+ return [
71
+ `${START_PREFIX} v=${version}${preAttr}${newAttr} -->`,
72
+ ...String(body).replace(/\r?\n$/u, '').split(/\r?\n/u),
73
+ END_MARKER,
74
+ ].join(eol);
75
+ }
76
+
77
+ export function inspectGround(text) {
78
+ try {
79
+ const span = locate(text);
80
+ return span
81
+ ? { state: 'present', version: span.version, created: span.created }
82
+ : { state: 'absent', version: null, created: false };
83
+ } catch {
84
+ return { state: 'malformed', version: null, created: false };
85
+ }
86
+ }
87
+
88
+ export function upsertGround(text, { version, body, created = false }) {
89
+ const eol = eolOf(text);
90
+ const span = locate(text);
91
+ if (span) {
92
+ const rendered = render({ version, body }, eol, span);
93
+ const next = `${text.slice(0, span.start)}${rendered}${text.slice(span.end)}`;
94
+ return { text: next, changed: next !== text };
95
+ }
96
+ const pre = text === '' ? '' : `${text.endsWith('\n') || text.endsWith('\r\n') ? '' : eol}${eol}`;
97
+ return { text: `${text}${pre}${render({ version, body }, eol, { pre, created })}${eol}`, changed: true };
98
+ }
99
+
100
+ export function removeGround(text) {
101
+ const span = locate(text);
102
+ if (!span) return { text, changed: false, created: false };
103
+ let before = text.slice(0, span.start);
104
+ let after = text.slice(span.end);
105
+ const eol = after.startsWith('\r\n') ? '\r\n' : after.startsWith('\n') ? '\n' : '';
106
+ after = after.slice(eol.length);
107
+ if (span.pre && before.endsWith(span.pre)) before = before.slice(0, -span.pre.length);
108
+ return { text: `${before}${after}`, changed: true, created: span.created };
109
+ }
110
+
111
+ function extractBody(text, span) {
112
+ const eol = eolOf(text.slice(span.start, span.end));
113
+ const lines = text.slice(span.start, span.end).split(eol);
114
+ return lines.slice(1, -1).join(eol);
115
+ }
116
+
117
+ export function packagedGround() {
118
+ const text = readFileSync(join(PACKAGE_ROOT, '.grok', 'rules', '00-litgrok.md'), 'utf8');
119
+ const span = locate(text);
120
+ if (!span) throw new GroundMarkError('packaged user rule has no ground section');
121
+ return { version: span.version, body: extractBody(text, span) };
122
+ }
123
+
124
+ export function addedGroundBytes(text) {
125
+ const removed = removeGround(text);
126
+ if (!removed.changed) return 0;
127
+ return Buffer.byteLength(text) - Buffer.byteLength(removed.text);
128
+ }
129
+
130
+ function readUtf8(path) {
131
+ const bytes = readFileSync(path);
132
+ if (bytes.includes(0)) return null;
133
+ try {
134
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ function writeAtomic(path, text) {
141
+ let mode = null;
142
+ try {
143
+ const stat = lstatSync(path);
144
+ if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${path}`);
145
+ if (!stat.isFile()) throw new Error(`Refusing non-directory path: ${path}`);
146
+ mode = stat.mode & 0o777;
147
+ } catch (error) {
148
+ if (error?.code !== 'ENOENT') throw error;
149
+ }
150
+ const staging = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
151
+ try {
152
+ writeFileSync(staging, text, { flag: 'wx' });
153
+ if (mode !== null) chmodSync(staging, mode);
154
+ renameSync(staging, path);
155
+ } finally {
156
+ try {
157
+ unlinkSync(staging);
158
+ } catch (error) {
159
+ if (error?.code !== 'ENOENT') throw error;
160
+ }
161
+ }
162
+ }
163
+
164
+ function requireRealDirectory(path, { create }) {
165
+ try {
166
+ const stat = lstatSync(path);
167
+ if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${path}`);
168
+ if (!stat.isDirectory()) throw new Error(`Refusing non-directory path: ${path}`);
169
+ return 'exists';
170
+ } catch (error) {
171
+ if (error?.code !== 'ENOENT') throw error;
172
+ if (!create) throw new Error(`Refusing missing directory: ${path}`);
173
+ return 'missing';
174
+ }
175
+ }
176
+
177
+ function ensureDirectory(path) {
178
+ if (requireRealDirectory(path, { create: true }) === 'missing') {
179
+ requireRealDirectory(dirname(path), { create: false });
180
+ mkdirSync(path);
181
+ }
182
+ }
183
+
184
+ export function backupOnce(target) {
185
+ const backup = `${target}.litgrok-backup`;
186
+ try {
187
+ const stat = lstatSync(backup);
188
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Refusing symbolic link: ${backup}`);
189
+ return backup;
190
+ } catch (error) {
191
+ if (error?.code !== 'ENOENT') throw error;
192
+ }
193
+ copyFileSync(target, backup, constants.COPYFILE_EXCL);
194
+ return backup;
195
+ }
196
+
197
+ export function backupIfSectionAbsent(target) {
198
+ let stat;
199
+ try {
200
+ stat = lstatSync(target);
201
+ } catch (error) {
202
+ if (error?.code === 'ENOENT') return null;
203
+ throw error;
204
+ }
205
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
206
+ const text = readUtf8(target);
207
+ if (text === null || inspectGround(text).state !== 'absent') return null;
208
+ return backupOnce(target);
209
+ }
210
+
211
+ function settingPath(home) {
212
+ return join(home, '.grok', SETTING);
213
+ }
214
+
215
+ function rulePath(home) {
216
+ return join(home, '.grok', 'rules', '00-litgrok.md');
217
+ }
218
+
219
+ export function readGroundMode(home) {
220
+ const root = join(home, '.grok');
221
+ try {
222
+ const rootStat = lstatSync(root);
223
+ if (rootStat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${root}`);
224
+ if (!rootStat.isDirectory()) throw new Error(`Refusing non-directory path: ${root}`);
225
+ } catch (error) {
226
+ if (error?.code === 'ENOENT') return null;
227
+ throw error;
228
+ }
229
+ const path = settingPath(home);
230
+ let stat;
231
+ try {
232
+ stat = lstatSync(path);
233
+ } catch (error) {
234
+ if (error?.code === 'ENOENT') return null;
235
+ throw error;
236
+ }
237
+ if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${path}`);
238
+ if (!stat.isFile()) throw new Error(`Refusing non-directory path: ${path}`);
239
+ let parsed;
240
+ try {
241
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
242
+ } catch {
243
+ throw new Error(`Refusing to use ${SETTING}: file is not valid JSON`);
244
+ }
245
+ if (!parsed || parsed.schema !== SETTING_SCHEMA || parsed.mode !== 'off') {
246
+ throw new Error(`Refusing to use ${SETTING}: schema is invalid`);
247
+ }
248
+ return 'off';
249
+ }
250
+
251
+ function writeGroundMode(home, mode) {
252
+ ensureDirectory(join(home, '.grok'));
253
+ const path = settingPath(home);
254
+ if (mode === null) {
255
+ try {
256
+ const stat = lstatSync(path);
257
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Refusing symbolic link: ${path}`);
258
+ unlinkSync(path);
259
+ } catch (error) {
260
+ if (error?.code !== 'ENOENT') throw error;
261
+ }
262
+ return;
263
+ }
264
+ writeAtomic(path, `${JSON.stringify({ schema: SETTING_SCHEMA, mode }, null, 2)}\n`);
265
+ }
266
+
267
+ export function applyStoredGround(home, afterWrite) {
268
+ if (afterWrite === 'store-off') writeGroundMode(home, 'off');
269
+ else if (afterWrite === 'clear-off') writeGroundMode(home, null);
270
+ }
271
+
272
+ function manifestPath(home) {
273
+ return join(home, '.grok', MANIFEST);
274
+ }
275
+
276
+ function recordRuleHash(home, bytes) {
277
+ const path = manifestPath(home);
278
+ let stat;
279
+ try {
280
+ stat = lstatSync(path);
281
+ } catch (error) {
282
+ if (error?.code === 'ENOENT') return;
283
+ throw error;
284
+ }
285
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Refusing symbolic link: ${path}`);
286
+ let parsed;
287
+ try {
288
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
289
+ } catch {
290
+ throw new Error(`Refusing to use ${MANIFEST}: file is not valid JSON`);
291
+ }
292
+ if (!parsed || parsed.schema !== MANIFEST_SCHEMA || !parsed.files || Array.isArray(parsed.files)) {
293
+ throw new Error(`Refusing to use ${MANIFEST}: schema is invalid`);
294
+ }
295
+ parsed.files[GROUND_RULE] = createHash('sha256').update(bytes).digest('hex');
296
+ parsed.files = Object.fromEntries(Object.entries(parsed.files).sort(([left], [right]) => left.localeCompare(right)));
297
+ writeAtomic(path, `${JSON.stringify(parsed, null, 2)}\n`);
298
+ }
299
+
300
+ export function describeGround({ home }) {
301
+ const rule = rulePath(home);
302
+ let mode;
303
+ try {
304
+ mode = readGroundMode(home);
305
+ } catch (error) {
306
+ return { status: 'malformed', path: error.message.includes(SETTING) ? settingPath(home) : join(home, '.grok'), version: null };
307
+ }
308
+ let kind = 'missing';
309
+ try {
310
+ const stat = lstatSync(rule);
311
+ if (stat.isSymbolicLink() || !stat.isFile()) return { status: 'malformed', path: rule, version: null };
312
+ kind = 'file';
313
+ } catch (error) {
314
+ if (error?.code !== 'ENOENT') return { status: 'malformed', path: rule, version: null };
315
+ }
316
+ if (kind === 'missing') return { status: mode === 'off' ? 'off' : 'absent', path: rule, version: null };
317
+ const text = readUtf8(rule);
318
+ if (text === null) return { status: 'malformed', path: rule, version: null };
319
+ const found = inspectGround(text);
320
+ if (found.state === 'malformed') return { status: 'malformed', path: rule, version: null };
321
+ if (found.state === 'present') {
322
+ return { status: found.version === packageVersion() ? 'present' : 'stale', path: rule, version: found.version };
323
+ }
324
+ return { status: mode === 'off' ? 'off' : 'absent', path: rule, version: null };
325
+ }
326
+
327
+ export function formatGround(result) {
328
+ if (result.status === 'present' || result.status === 'stale') return `GROUND: ${result.status} v=${result.version} ${result.path}`;
329
+ return `GROUND: ${result.status} ${result.path}`;
330
+ }
331
+
332
+ export function groundReport(env) {
333
+ const home = env.HOME || env.USERPROFILE || homedir();
334
+ return formatGround(describeGround({ home }));
335
+ }
336
+
337
+ function ask(stdin, stdout) {
338
+ stdout.write('Add LitGrok ground rules to the user rule (~/.grok/rules/00-litgrok.md)? [y/N] ');
339
+ return new Promise((resolvePromise) => {
340
+ let line = '';
341
+ const onData = (chunk) => {
342
+ line += String(chunk);
343
+ if (!line.includes('\n')) return;
344
+ stdin.off('data', onData);
345
+ resolvePromise(/^\s*y(es)?\s*$/iu.test(line));
346
+ };
347
+ if (typeof stdin.resume === 'function') stdin.resume();
348
+ stdin.on('data', onData);
349
+ });
350
+ }
351
+
352
+ export async function decideGround({ scope, yes, noGround, env, stdin, stdout, home }) {
353
+ const override = String(env.LITGROK_GROUND ?? '').trim().toLowerCase();
354
+ if (override === 'on' || override === 'off') return { include: override === 'on', afterWrite: null };
355
+ if (noGround) return { include: false, afterWrite: null };
356
+ const stored = scope === 'user' ? readGroundMode(home) : null;
357
+ if (yes) return { include: true, afterWrite: stored === 'off' && scope === 'user' ? 'clear-off' : null };
358
+ if (stored === 'off') return { include: false, afterWrite: null };
359
+ if (scope === 'user' && stdin?.isTTY && typeof stdin.on === 'function') {
360
+ const accepted = await ask(stdin, stdout);
361
+ if (!accepted) return { include: false, afterWrite: 'store-off' };
362
+ }
363
+ return { include: true, afterWrite: null };
364
+ }
365
+
366
+ export function ruleSourceForInstall(files) {
367
+ const index = files.findIndex((file) => file.relativePath.split(sep).join('/') === GROUND_RULE);
368
+ if (index < 0) return { files, cleanup() {} };
369
+ const text = removeGround(readFileSync(files[index].source, 'utf8')).text;
370
+ const temp = join(tmpdir(), `litgrok-rule-${randomUUID()}.md`);
371
+ writeFileSync(temp, text, { flag: 'wx' });
372
+ const next = files.slice();
373
+ next[index] = { ...files[index], source: temp };
374
+ return {
375
+ files: next,
376
+ cleanup() {
377
+ try {
378
+ unlinkSync(temp);
379
+ } catch (error) {
380
+ if (error?.code !== 'ENOENT') throw error;
381
+ }
382
+ },
383
+ };
384
+ }
385
+
386
+ function entryKind(path) {
387
+ try {
388
+ const stat = lstatSync(path);
389
+ if (stat.isSymbolicLink()) return 'symlink';
390
+ if (stat.isFile()) return 'file';
391
+ return 'other';
392
+ } catch (error) {
393
+ if (error?.code === 'ENOENT') return 'missing';
394
+ throw error;
395
+ }
396
+ }
397
+
398
+ function fail(stderr, error) {
399
+ stderr.write(`litgrok: ${error instanceof Error ? error.message : String(error)}\n`);
400
+ return 1;
401
+ }
402
+
403
+ function turn(home, mode, stdout, stderr) {
404
+ const rule = rulePath(home);
405
+ try {
406
+ readGroundMode(home);
407
+ const kind = entryKind(rule);
408
+ if (kind === 'symlink') throw new Error(`Refusing symbolic link: ${rule}`);
409
+ if (kind === 'other') throw new Error(`Refusing non-directory path: ${rule}`);
410
+ const packaged = packagedGround();
411
+ if (kind === 'file') {
412
+ const text = readUtf8(rule);
413
+ if (text === null) throw new Error(`Refusing to rewrite a non-UTF-8 file: ${rule}`);
414
+ const found = inspectGround(text);
415
+ if (found.state === 'malformed') throw new Error(`Refusing malformed ground-rule markers: ${rule}`);
416
+ if (mode === 'off') {
417
+ const removed = removeGround(text);
418
+ if (removed.changed) {
419
+ if (removed.text === '' && removed.created) unlinkSync(rule);
420
+ else {
421
+ writeAtomic(rule, removed.text);
422
+ recordRuleHash(home, removed.text);
423
+ }
424
+ }
425
+ } else if (found.state === 'absent') {
426
+ backupOnce(rule);
427
+ const next = upsertGround(text, { version: packaged.version, body: packaged.body, created: false });
428
+ writeAtomic(rule, next.text);
429
+ recordRuleHash(home, next.text);
430
+ } else {
431
+ const next = upsertGround(text, { version: packaged.version, body: packaged.body });
432
+ if (next.changed) {
433
+ writeAtomic(rule, next.text);
434
+ recordRuleHash(home, next.text);
435
+ }
436
+ }
437
+ } else if (mode === 'on') {
438
+ ensureDirectory(join(home, '.grok', 'rules'));
439
+ const created = upsertGround('', { version: packaged.version, body: packaged.body, created: true });
440
+ writeAtomic(rule, created.text);
441
+ }
442
+ writeGroundMode(home, mode === 'off' ? 'off' : null);
443
+ stdout.write(`${formatGround(describeGround({ home }))}\n`);
444
+ return 0;
445
+ } catch (error) {
446
+ return fail(stderr, error);
447
+ }
448
+ }
449
+
450
+ function removeEmpty(path) {
451
+ try {
452
+ const stat = lstatSync(path);
453
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return;
454
+ rmdirSync(path);
455
+ } catch (error) {
456
+ if (['ENOTEMPTY', 'ENOENT', 'ENOTDIR'].includes(error?.code)) return;
457
+ throw error;
458
+ }
459
+ }
460
+
461
+ function projectCommand(rest, { cwd, stdout, stderr }) {
462
+ let dir = cwd;
463
+ let action = 'install';
464
+ let dryRun = false;
465
+ for (let index = 0; index < rest.length; index += 1) {
466
+ const argument = rest[index];
467
+ if (argument === '--remove') {
468
+ action = 'remove';
469
+ continue;
470
+ }
471
+ if (argument === '--dry-run') {
472
+ dryRun = true;
473
+ continue;
474
+ }
475
+ if (argument === '--dir' && rest[index + 1]) {
476
+ dir = resolve(cwd, rest[index + 1]);
477
+ index += 1;
478
+ continue;
479
+ }
480
+ stderr.write(GROUND_USAGE);
481
+ return 1;
482
+ }
483
+ const target = join(dir, '.grok', 'rules', PROJECT_RULE);
484
+ try {
485
+ const dirStat = lstatSync(dir);
486
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) throw new Error(`Refusing non-directory path: ${dir}`);
487
+ if (dryRun) {
488
+ stdout.write(`would ${action === 'remove' ? 'remove' : 'write'} ${target}\n`);
489
+ return 0;
490
+ }
491
+ const rulesDir = join(dir, '.grok', 'rules');
492
+ for (const ancestor of [join(dir, '.grok'), rulesDir]) {
493
+ try {
494
+ const stat = lstatSync(ancestor);
495
+ if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${ancestor}`);
496
+ if (!stat.isDirectory()) throw new Error(`Refusing non-directory path: ${ancestor}`);
497
+ } catch (error) {
498
+ if (error?.code !== 'ENOENT') throw error;
499
+ }
500
+ }
501
+ const version = packageVersion();
502
+ const kind = entryKind(target);
503
+ if (kind === 'symlink') throw new Error(`Refusing symbolic link: ${target}`);
504
+ if (kind === 'other') throw new Error(`Refusing non-directory path: ${target}`);
505
+ if (action === 'remove') {
506
+ if (kind === 'missing') {
507
+ stdout.write(`${formatGround({ status: 'absent', path: target, version: null })}\n`);
508
+ return 0;
509
+ }
510
+ const text = readUtf8(target);
511
+ if (text === null) throw new Error(`Refusing to rewrite a non-UTF-8 file: ${target}`);
512
+ const found = inspectGround(text);
513
+ if (found.state === 'malformed') throw new Error(`Refusing malformed ground-rule markers: ${target}`);
514
+ if (found.state === 'absent') throw new Error(`Refusing to overwrite different existing file: ${target}`);
515
+ const removed = removeGround(text);
516
+ if (removed.text !== '') throw new Error(`Refusing to overwrite different existing file: ${target}`);
517
+ unlinkSync(target);
518
+ removeEmpty(dirname(target));
519
+ removeEmpty(dirname(dirname(target)));
520
+ stdout.write(`${formatGround({ status: 'absent', path: target, version: null })}\n`);
521
+ return 0;
522
+ }
523
+ ensureDirectory(join(dir, '.grok'));
524
+ ensureDirectory(join(dir, '.grok', 'rules'));
525
+ if (kind === 'missing') {
526
+ const created = upsertGround('', { version, body: PROJECT_BODY, created: true });
527
+ if (Buffer.byteLength(created.text) > GROUND_MAX_BYTES) throw new Error('Refusing ground-rule body over 1200 bytes');
528
+ writeAtomic(target, created.text);
529
+ } else {
530
+ const text = readUtf8(target);
531
+ if (text === null) throw new Error(`Refusing to rewrite a non-UTF-8 file: ${target}`);
532
+ const found = inspectGround(text);
533
+ if (found.state !== 'present') throw new Error(`Refusing to overwrite different existing file: ${target}`);
534
+ const removed = removeGround(text);
535
+ if (removed.text !== '') throw new Error(`Refusing to overwrite different existing file: ${target}`);
536
+ const next = upsertGround(text, { version, body: PROJECT_BODY });
537
+ if (!next.changed) {
538
+ stdout.write(`${formatGround({ status: 'present', path: target, version })}\n`);
539
+ return 0;
540
+ }
541
+ backupOnce(target);
542
+ writeAtomic(target, next.text);
543
+ }
544
+ stdout.write(`${formatGround(describeGroundFile(target))}\n`);
545
+ return 0;
546
+ } catch (error) {
547
+ return fail(stderr, error);
548
+ }
549
+ }
550
+
551
+ function describeGroundFile(path) {
552
+ const text = readUtf8(path);
553
+ const found = text === null ? { state: 'malformed', version: null } : inspectGround(text);
554
+ if (found.state !== 'present') return { status: found.state === 'absent' ? 'absent' : 'malformed', path, version: null };
555
+ return { status: found.version === packageVersion() ? 'present' : 'stale', path, version: found.version };
556
+ }
557
+
558
+ export async function runGround(args, context = {}) {
559
+ const stdout = context.stdout ?? process.stdout;
560
+ const stderr = context.stderr ?? process.stderr;
561
+ const env = context.env ?? process.env;
562
+ const cwd = context.cwd ?? process.cwd();
563
+ const home = env.HOME || env.USERPROFILE || homedir();
564
+ const [sub, ...rest] = args;
565
+ if (sub === 'status' && rest.length === 0) {
566
+ stdout.write(`${formatGround(describeGround({ home }))}\n`);
567
+ return 0;
568
+ }
569
+ if ((sub === 'on' || sub === 'off') && rest.length === 0) return turn(home, sub, stdout, stderr);
570
+ if (sub === 'project') return projectCommand(rest, { cwd, stdout, stderr });
571
+ stderr.write(GROUND_USAGE);
572
+ return 1;
573
+ }