litgrok-ai 0.2.5 → 0.2.7
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/.grok/skills/autoconference/references/_canonical-corpus/manifest.json +4 -4
- package/.grok/skills/autoconference/scripts/init_conference.py +28 -2
- package/.grok/skills/autoconference/scripts/verify-canonical-corpus.mjs +3 -3
- package/.grok/skills/autoresearch/references/_canonical-corpus/manifest.json +4 -4
- package/.grok/skills/autoresearch/scripts/init_research.py +28 -2
- package/.grok/skills/autoresearch/scripts/verify-canonical-corpus.mjs +3 -3
- package/.grok/skills/skill-observer/scripts/skill-loop.mjs +151 -43
- package/CHANGELOG.md +25 -0
- package/README.md +10 -10
- package/README_ko-KR.md +10 -10
- package/bin/litgrok.mjs +193 -15
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"selection": "25 companion files retained; SKILL.md is a Grok-native adapter"
|
|
7
7
|
},
|
|
8
8
|
"file_count": 25,
|
|
9
|
-
"byte_count":
|
|
10
|
-
"aggregate_sha256": "
|
|
9
|
+
"byte_count": 55711,
|
|
10
|
+
"aggregate_sha256": "406a7ca96354e3b863577cdbcb783122031fd451f967d0a2bfadfcdd1949fd34",
|
|
11
11
|
"files": [
|
|
12
12
|
{
|
|
13
13
|
"path": "assets/conference_template.md",
|
|
@@ -101,8 +101,8 @@
|
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
"path": "scripts/init_conference.py",
|
|
104
|
-
"size":
|
|
105
|
-
"sha256": "
|
|
104
|
+
"size": 21865,
|
|
105
|
+
"sha256": "58aaaa91d3f31bdacfccac4c3a736efb62cb89563f7d37281be0e49f84651187"
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
"path": "templates/code-performance.md",
|
|
@@ -209,6 +209,23 @@ def preflight_capabilities() -> None:
|
|
|
209
209
|
)
|
|
210
210
|
|
|
211
211
|
|
|
212
|
+
def same_directory_identity(left: os.stat_result, right: os.stat_result) -> bool:
|
|
213
|
+
"""Compare path identity, including ctime to reject recreated directories."""
|
|
214
|
+
return (
|
|
215
|
+
left.st_dev,
|
|
216
|
+
left.st_ino,
|
|
217
|
+
left.st_mode,
|
|
218
|
+
left.st_uid,
|
|
219
|
+
left.st_ctime_ns,
|
|
220
|
+
) == (
|
|
221
|
+
right.st_dev,
|
|
222
|
+
right.st_ino,
|
|
223
|
+
right.st_mode,
|
|
224
|
+
right.st_uid,
|
|
225
|
+
right.st_ctime_ns,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
212
229
|
def open_safe_directory(path: Path) -> Tuple[int, Path]:
|
|
213
230
|
"""Create/open path one component at a time without following symlinks."""
|
|
214
231
|
absolute = Path(os.path.abspath(os.fspath(path)))
|
|
@@ -233,7 +250,16 @@ def open_safe_directory(path: Path) -> Tuple[int, Path]:
|
|
|
233
250
|
fail("UNSAFE_OUTPUT_SYMLINK", f"output component is a symlink: {absolute}")
|
|
234
251
|
if not stat.S_ISDIR(info.st_mode):
|
|
235
252
|
fail("UNSAFE_OUTPUT_PATH", f"output component is not a directory: {absolute}")
|
|
236
|
-
next_fd =
|
|
253
|
+
next_fd = None
|
|
254
|
+
try:
|
|
255
|
+
next_fd = os.open(part, flags | nofollow, dir_fd=fd)
|
|
256
|
+
opened = os.fstat(next_fd)
|
|
257
|
+
if not same_directory_identity(info, opened):
|
|
258
|
+
fail("UNSAFE_OUTPUT_SWAP", f"output component identity changed: {absolute}")
|
|
259
|
+
except BaseException:
|
|
260
|
+
if next_fd is not None:
|
|
261
|
+
os.close(next_fd)
|
|
262
|
+
raise
|
|
237
263
|
os.close(fd)
|
|
238
264
|
fd = next_fd
|
|
239
265
|
verify_directory_identity(absolute, fd)
|
|
@@ -251,7 +277,7 @@ def verify_directory_identity(path: Path, fd: int) -> None:
|
|
|
251
277
|
opened = os.fstat(fd)
|
|
252
278
|
if stat.S_ISLNK(named.st_mode) or not stat.S_ISDIR(named.st_mode):
|
|
253
279
|
fail("UNSAFE_OUTPUT_SYMLINK", f"output directory is no longer a real directory: {path}")
|
|
254
|
-
if (named
|
|
280
|
+
if not same_directory_identity(named, opened):
|
|
255
281
|
fail("UNSAFE_OUTPUT_SWAP", f"output directory identity changed: {path}")
|
|
256
282
|
|
|
257
283
|
|
|
@@ -11,8 +11,8 @@ const VERIFIER_RELATIVE = 'scripts/verify-canonical-corpus.mjs';
|
|
|
11
11
|
const EXPECTED = Object.freeze({
|
|
12
12
|
"schemaVersion": "litgrok.autoconference-corpus/v1",
|
|
13
13
|
"fileCount": 25,
|
|
14
|
-
"byteCount":
|
|
15
|
-
"aggregateSha256": "
|
|
14
|
+
"byteCount": 55711,
|
|
15
|
+
"aggregateSha256": "406a7ca96354e3b863577cdbcb783122031fd451f967d0a2bfadfcdd1949fd34",
|
|
16
16
|
"files": {
|
|
17
17
|
"assets/conference_template.md": "c1b0a1bbfb52080384e9bf6d725a91d2165a2105a4f055820660a607c913c7e7",
|
|
18
18
|
"assets/report_template.md": "73a00032dc143f34f67a97a257f76e7a5acd9365d367e0b27d0c72e439a336ab",
|
|
@@ -32,7 +32,7 @@ const EXPECTED = Object.freeze({
|
|
|
32
32
|
"references/modes/survey.md": "93866084c2abe5d7a1c6ef7251e5594e03044545ddda5e80c1ca42ef450769d0",
|
|
33
33
|
"references/results-logging.md": "829714f30af623aa78a4ebc9e13a0ba7135b2c876af3443e1c1274af8857fe96",
|
|
34
34
|
"references/visualization-guide.md": "4f7e705735b948038193c5a9c111e2db85672f5d97bb1c570aad217843868331",
|
|
35
|
-
"scripts/init_conference.py": "
|
|
35
|
+
"scripts/init_conference.py": "58aaaa91d3f31bdacfccac4c3a736efb62cb89563f7d37281be0e49f84651187",
|
|
36
36
|
"templates/code-performance.md": "a90d1425477d9aec002778ba695299cfabb593ccd3dbab82ed0d76038e17a4d5",
|
|
37
37
|
"templates/debate-mode.md": "9c74435dc7eb2e2d24d32b0a80381aadc62890f4674d916022ce6ef220aab966",
|
|
38
38
|
"templates/prompt-optimization.md": "8d78c233529533bb742986769941347eebc460fdb2a2dac63952e65883398e10",
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"selection": "27 companion files retained; SKILL.md is a Grok-native adapter"
|
|
7
7
|
},
|
|
8
8
|
"file_count": 27,
|
|
9
|
-
"byte_count":
|
|
10
|
-
"aggregate_sha256": "
|
|
9
|
+
"byte_count": 38805,
|
|
10
|
+
"aggregate_sha256": "0296ca6e52fb7cb68a37f3e17f148248146955ad9624e37e449e60b56eeeb1d3",
|
|
11
11
|
"files": [
|
|
12
12
|
{
|
|
13
13
|
"path": "assets/report_template.md",
|
|
@@ -136,8 +136,8 @@
|
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
138
|
"path": "scripts/init_research.py",
|
|
139
|
-
"size":
|
|
140
|
-
"sha256": "
|
|
139
|
+
"size": 14161,
|
|
140
|
+
"sha256": "b675ea22abf0794e32dbbf59b95248f20878bbf3d071fa10d37b5a004a7f880c"
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
"path": "scripts/style_presets.py",
|
|
@@ -103,6 +103,23 @@ def preflight_capabilities() -> None:
|
|
|
103
103
|
)
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
def same_directory_identity(left: os.stat_result, right: os.stat_result) -> bool:
|
|
107
|
+
"""Compare path identity, including ctime to reject recreated directories."""
|
|
108
|
+
return (
|
|
109
|
+
left.st_dev,
|
|
110
|
+
left.st_ino,
|
|
111
|
+
left.st_mode,
|
|
112
|
+
left.st_uid,
|
|
113
|
+
left.st_ctime_ns,
|
|
114
|
+
) == (
|
|
115
|
+
right.st_dev,
|
|
116
|
+
right.st_ino,
|
|
117
|
+
right.st_mode,
|
|
118
|
+
right.st_uid,
|
|
119
|
+
right.st_ctime_ns,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
106
123
|
def open_safe_directory(path: Path) -> Tuple[int, Path]:
|
|
107
124
|
"""Create/open path one component at a time without following symlinks."""
|
|
108
125
|
absolute = Path(os.path.abspath(os.fspath(path)))
|
|
@@ -127,7 +144,16 @@ def open_safe_directory(path: Path) -> Tuple[int, Path]:
|
|
|
127
144
|
fail("UNSAFE_OUTPUT_SYMLINK", f"output component is a symlink: {absolute}")
|
|
128
145
|
if not stat.S_ISDIR(info.st_mode):
|
|
129
146
|
fail("UNSAFE_OUTPUT_PATH", f"output component is not a directory: {absolute}")
|
|
130
|
-
next_fd =
|
|
147
|
+
next_fd = None
|
|
148
|
+
try:
|
|
149
|
+
next_fd = os.open(part, flags | nofollow, dir_fd=fd)
|
|
150
|
+
opened = os.fstat(next_fd)
|
|
151
|
+
if not same_directory_identity(info, opened):
|
|
152
|
+
fail("UNSAFE_OUTPUT_SWAP", f"output component identity changed: {absolute}")
|
|
153
|
+
except BaseException:
|
|
154
|
+
if next_fd is not None:
|
|
155
|
+
os.close(next_fd)
|
|
156
|
+
raise
|
|
131
157
|
os.close(fd)
|
|
132
158
|
fd = next_fd
|
|
133
159
|
verify_directory_identity(absolute, fd)
|
|
@@ -146,7 +172,7 @@ def verify_directory_identity(path: Path, fd: int) -> None:
|
|
|
146
172
|
opened = os.fstat(fd)
|
|
147
173
|
if stat.S_ISLNK(named.st_mode) or not stat.S_ISDIR(named.st_mode):
|
|
148
174
|
fail("UNSAFE_OUTPUT_SYMLINK", f"output directory is no longer a real directory: {path}")
|
|
149
|
-
if (named
|
|
175
|
+
if not same_directory_identity(named, opened):
|
|
150
176
|
fail("UNSAFE_OUTPUT_SWAP", f"output directory identity changed: {path}")
|
|
151
177
|
|
|
152
178
|
|
|
@@ -11,8 +11,8 @@ const VERIFIER_RELATIVE = 'scripts/verify-canonical-corpus.mjs';
|
|
|
11
11
|
const EXPECTED = Object.freeze({
|
|
12
12
|
"schemaVersion": "litgrok.autoresearch-corpus/v1",
|
|
13
13
|
"fileCount": 27,
|
|
14
|
-
"byteCount":
|
|
15
|
-
"aggregateSha256": "
|
|
14
|
+
"byteCount": 38805,
|
|
15
|
+
"aggregateSha256": "0296ca6e52fb7cb68a37f3e17f148248146955ad9624e37e449e60b56eeeb1d3",
|
|
16
16
|
"files": {
|
|
17
17
|
"assets/report_template.md": "b6b7607488736f24d3bacea8f3ec8563da40ff782cc1d85c064ab53b2488a4f9",
|
|
18
18
|
"assets/research_template.md": "726d175cd2db92dfe58a738117d9c6cc88211e519d3be8fb7cb05b66ba34c050",
|
|
@@ -39,7 +39,7 @@ const EXPECTED = Object.freeze({
|
|
|
39
39
|
"references/modes/ship/type-checklists.md": "6bea500594cad1e23bdeee2df34f6dd079d02b279773b20f914d313cabd630b3",
|
|
40
40
|
"references/results-logging.md": "4671c19df6d389b42091f2f117fd90726cb32f3082b44dbc9a895dc3625b9832",
|
|
41
41
|
"references/visualization-guide.md": "2ca249d74b30b41e7660c8b7ba91a59b0c33a1e74fc18b2ec2f867441a6e4b27",
|
|
42
|
-
"scripts/init_research.py": "
|
|
42
|
+
"scripts/init_research.py": "b675ea22abf0794e32dbbf59b95248f20878bbf3d071fa10d37b5a004a7f880c",
|
|
43
43
|
"scripts/style_presets.py": "b1c7d495016b0737b622684f4381f87990dfaae77934b3acae44e19742ebdfd5"
|
|
44
44
|
}
|
|
45
45
|
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
fstatSync,
|
|
11
11
|
fsyncSync,
|
|
12
12
|
lstatSync,
|
|
13
|
+
linkSync,
|
|
13
14
|
mkdirSync,
|
|
14
15
|
openSync,
|
|
15
16
|
readFileSync,
|
|
@@ -301,10 +302,119 @@ function validateLockFileSecurity(path, identity) {
|
|
|
301
302
|
return status;
|
|
302
303
|
}
|
|
303
304
|
|
|
305
|
+
function sameLockIdentity(left, right) {
|
|
306
|
+
return left.isFile() === right.isFile()
|
|
307
|
+
&& left.isSymbolicLink() === right.isSymbolicLink()
|
|
308
|
+
&& left.dev === right.dev
|
|
309
|
+
&& left.ino === right.ino
|
|
310
|
+
&& left.uid === right.uid
|
|
311
|
+
&& left.mode === right.mode
|
|
312
|
+
&& left.nlink === right.nlink
|
|
313
|
+
&& left.size === right.size
|
|
314
|
+
&& left.ctimeMs === right.ctimeMs
|
|
315
|
+
&& left.birthtimeMs === right.birthtimeMs;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function sameLockHandle(left, right) {
|
|
319
|
+
return left.isFile() === right.isFile()
|
|
320
|
+
&& left.isSymbolicLink() === right.isSymbolicLink()
|
|
321
|
+
&& left.dev === right.dev
|
|
322
|
+
&& left.ino === right.ino
|
|
323
|
+
&& left.uid === right.uid
|
|
324
|
+
&& left.mode === right.mode
|
|
325
|
+
&& left.nlink === right.nlink
|
|
326
|
+
&& left.size === right.size;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function sameLockLocation(left, right) {
|
|
330
|
+
return left.isFile() === right.isFile()
|
|
331
|
+
&& left.isSymbolicLink() === right.isSymbolicLink()
|
|
332
|
+
&& left.dev === right.dev
|
|
333
|
+
&& left.ino === right.ino
|
|
334
|
+
&& left.uid === right.uid
|
|
335
|
+
&& left.nlink === right.nlink;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function inspectLockAgainstDescriptor(path, descriptor, identity, secure) {
|
|
339
|
+
const named = secure ? validateLockFileSecurity(path, identity) : lstatSync(path);
|
|
340
|
+
if (!named.isFile() || named.isSymbolicLink() || named.size > 4096 || named.nlink !== 1) fail('STATE_LOCK_INVALID', path);
|
|
341
|
+
const held = fstatSync(descriptor);
|
|
342
|
+
if (held.size > 4096) fail('STATE_LOCK_INVALID', path);
|
|
343
|
+
let reader;
|
|
344
|
+
try {
|
|
345
|
+
reader = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
346
|
+
const opened = fstatSync(reader);
|
|
347
|
+
if (!sameLockLocation(opened, held) || opened.size !== named.size) fail('STATE_LOCK_RACE', path);
|
|
348
|
+
const bytes = readFileSync(reader);
|
|
349
|
+
const current = secure ? validateLockFileSecurity(path, identity) : lstatSync(path);
|
|
350
|
+
if (!sameLockLocation(current, held) || current.size !== opened.size) fail('STATE_LOCK_RACE', path);
|
|
351
|
+
return { bytes, digest: createHash('sha256').update(bytes).digest('hex'), status: current };
|
|
352
|
+
} finally {
|
|
353
|
+
if (reader !== undefined) closeSync(reader);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function restoreMovedLock(path, tombstone) {
|
|
358
|
+
try {
|
|
359
|
+
linkSync(tombstone, path);
|
|
360
|
+
unlinkSync(tombstone);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function removePinnedLock(path, descriptor, identity, expected, expectedBytes, code, {
|
|
367
|
+
secure = false,
|
|
368
|
+
tokenWritten = true,
|
|
369
|
+
bytesWritten = Buffer.byteLength(expectedBytes, 'utf8'),
|
|
370
|
+
faultInjector,
|
|
371
|
+
phase,
|
|
372
|
+
} = {}) {
|
|
373
|
+
const bytes = Buffer.from(expectedBytes, 'utf8');
|
|
374
|
+
const prefix = bytes.subarray(0, bytesWritten);
|
|
375
|
+
const matches = (entry, beforeRename) => {
|
|
376
|
+
const identityMatches = secure ? sameLockHandle(entry.status, expected) : sameLockLocation(entry.status, expected);
|
|
377
|
+
if (!identityMatches) return false;
|
|
378
|
+
if (tokenWritten ? !entry.bytes.equals(bytes) : !entry.bytes.equals(prefix)) return false;
|
|
379
|
+
return !beforeRename || tokenWritten || bytesWritten > 0 || sameLockIdentity(entry.status, expected);
|
|
380
|
+
};
|
|
381
|
+
let checked;
|
|
382
|
+
try {
|
|
383
|
+
checked = inspectLockAgainstDescriptor(path, descriptor, identity, secure);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (error?.code === 'ENOENT') return 'vanished';
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
if (!matches(checked, true)) fail(code, path);
|
|
389
|
+
faultInjector?.(phase, { path, descriptor, expectedBytes });
|
|
390
|
+
checked = inspectLockAgainstDescriptor(path, descriptor, identity, secure);
|
|
391
|
+
if (!matches(checked, true)) fail(code, path);
|
|
392
|
+
const tombstone = `${path}.cleanup-${process.pid}-${randomUUID()}`;
|
|
393
|
+
try {
|
|
394
|
+
renameSync(path, tombstone);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error?.code === 'ENOENT') return 'vanished';
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
let moved;
|
|
400
|
+
try {
|
|
401
|
+
moved = inspectLockAgainstDescriptor(tombstone, descriptor, identity, secure);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
restoreMovedLock(path, tombstone);
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
if (!matches(moved, false)) {
|
|
407
|
+
restoreMovedLock(path, tombstone);
|
|
408
|
+
fail(code, path);
|
|
409
|
+
}
|
|
410
|
+
unlinkSync(tombstone);
|
|
411
|
+
return 'removed';
|
|
412
|
+
}
|
|
413
|
+
|
|
304
414
|
function readSecureLock(path, identity, options = {}) {
|
|
305
415
|
let expected;
|
|
306
416
|
try {
|
|
307
|
-
expected = validateLockFileSecurity(path, identity);
|
|
417
|
+
expected = options.expectedStatus ?? validateLockFileSecurity(path, identity);
|
|
308
418
|
} catch (error) {
|
|
309
419
|
if (options.missingLock === 'propagate' && error?.code === 'ENOENT') throw error;
|
|
310
420
|
throw error instanceof SkillLoopError ? error : new SkillLoopError('STATE_LOCK_INVALID', path);
|
|
@@ -313,12 +423,15 @@ function readSecureLock(path, identity, options = {}) {
|
|
|
313
423
|
try {
|
|
314
424
|
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
315
425
|
const opened = fstatSync(descriptor);
|
|
316
|
-
if (!opened.isFile() || opened.size > 4096 || opened.nlink !== 1
|
|
317
|
-
|
|
318
|
-
|
|
426
|
+
if (!opened.isFile() || opened.size > 4096 || opened.nlink !== 1 || !sameLockIdentity(opened, expected)) {
|
|
427
|
+
fail('STATE_LOCK_INVALID', path);
|
|
428
|
+
}
|
|
429
|
+
const bytes = readFileSync(descriptor);
|
|
430
|
+
const contents = bytes.toString('utf8').trim();
|
|
431
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
319
432
|
const current = validateLockFileSecurity(path, identity);
|
|
320
|
-
if (current
|
|
321
|
-
return { contents, status: current };
|
|
433
|
+
if (!sameLockIdentity(current, opened)) fail('STATE_LOCK_RACE', path);
|
|
434
|
+
return { contents, digest, status: current };
|
|
322
435
|
} catch (error) {
|
|
323
436
|
if (error instanceof SkillLoopError) throw error;
|
|
324
437
|
if (options.missingLock === 'propagate' && error?.code === 'ENOENT') throw error;
|
|
@@ -344,12 +457,12 @@ function reclaimStaleLock(path, identity, options = {}) {
|
|
|
344
457
|
options.faultInjector?.('reclaim-read', { path });
|
|
345
458
|
let secureRead;
|
|
346
459
|
try {
|
|
347
|
-
secureRead = readSecureLock(path, identity, { missingLock: 'propagate' });
|
|
460
|
+
secureRead = readSecureLock(path, identity, { missingLock: 'propagate', expectedStatus: initial });
|
|
348
461
|
} catch (error) {
|
|
349
462
|
if (error?.code === 'ENOENT') return 'vanished';
|
|
350
463
|
throw error;
|
|
351
464
|
}
|
|
352
|
-
const { contents, status } = secureRead;
|
|
465
|
+
const { contents, digest, status } = secureRead;
|
|
353
466
|
let record;
|
|
354
467
|
if (/^[1-9][0-9]{0,14}$/.test(contents)) {
|
|
355
468
|
if (identity.platform === 'win32') fail('STATE_LOCK_INVALID', 'legacy owner unavailable');
|
|
@@ -381,57 +494,46 @@ function reclaimStaleLock(path, identity, options = {}) {
|
|
|
381
494
|
throw error;
|
|
382
495
|
}
|
|
383
496
|
const moved = readSecureLock(tombstone, identity);
|
|
384
|
-
if (moved.status
|
|
385
|
-
|
|
497
|
+
if (!sameLockHandle(moved.status, status) || moved.contents !== contents || moved.digest !== digest) {
|
|
498
|
+
restoreMovedLock(path, tombstone);
|
|
386
499
|
fail('STATE_LOCK_RACE', path);
|
|
387
500
|
}
|
|
388
501
|
unlinkSync(tombstone);
|
|
389
502
|
return 'reclaimed';
|
|
390
503
|
}
|
|
391
504
|
|
|
392
|
-
function cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten) {
|
|
393
|
-
try {
|
|
394
|
-
closeSync(descriptor);
|
|
395
|
-
} catch {
|
|
396
|
-
fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'descriptor');
|
|
397
|
-
}
|
|
398
|
-
let current;
|
|
505
|
+
function cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten, bytesWritten, identity, options = {}) {
|
|
399
506
|
try {
|
|
400
|
-
current = lstatSync(path);
|
|
401
|
-
} catch {
|
|
402
|
-
fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'missing');
|
|
403
|
-
}
|
|
404
|
-
if (!current.isFile() || current.isSymbolicLink() || current.dev !== created.dev || current.ino !== created.ino
|
|
405
|
-
|| current.nlink !== 1 || current.size > 4096) fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'identity');
|
|
406
|
-
let reader;
|
|
407
|
-
if (tokenWritten) {
|
|
408
507
|
try {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
508
|
+
removePinnedLock(path, descriptor, identity, created, expectedBytes, 'STATE_LOCK_ACQUISITION_CLEANUP_FAILED', {
|
|
509
|
+
tokenWritten,
|
|
510
|
+
bytesWritten,
|
|
511
|
+
faultInjector: options.faultInjector,
|
|
512
|
+
phase: 'before-acquisition-cleanup-rename',
|
|
513
|
+
});
|
|
413
514
|
} catch (error) {
|
|
414
|
-
if (error
|
|
415
|
-
fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', '
|
|
416
|
-
}
|
|
417
|
-
|
|
515
|
+
if (error?.code === 'STATE_LOCK_ACQUISITION_CLEANUP_FAILED') throw error;
|
|
516
|
+
fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', error?.code ?? 'retargeted');
|
|
517
|
+
}
|
|
518
|
+
} finally {
|
|
519
|
+
try {
|
|
520
|
+
closeSync(descriptor);
|
|
521
|
+
} catch {
|
|
522
|
+
fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'descriptor');
|
|
418
523
|
}
|
|
419
524
|
}
|
|
420
|
-
const final = lstatSync(path);
|
|
421
|
-
if (final.dev !== created.dev || final.ino !== created.ino || final.nlink !== 1) {
|
|
422
|
-
fail('STATE_LOCK_ACQUISITION_CLEANUP_FAILED', 'retargeted');
|
|
423
|
-
}
|
|
424
|
-
unlinkSync(path);
|
|
425
525
|
}
|
|
426
526
|
|
|
427
527
|
export function withStateLock(path, operation, attempts = 200, options = {}) {
|
|
428
528
|
safeMkdir(dirname(path));
|
|
429
529
|
const identity = lockIdentity(path);
|
|
430
530
|
let descriptor;
|
|
531
|
+
let ownerBytes;
|
|
431
532
|
for (let index = 0; index < attempts; index += 1) {
|
|
432
533
|
let created;
|
|
433
534
|
let expectedBytes;
|
|
434
535
|
let tokenWritten = false;
|
|
536
|
+
let bytesWritten = 0;
|
|
435
537
|
try {
|
|
436
538
|
descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
437
539
|
created = fstatSync(descriptor);
|
|
@@ -442,12 +544,15 @@ export function withStateLock(path, operation, attempts = 200, options = {}) {
|
|
|
442
544
|
createdAt: new Date().toISOString(),
|
|
443
545
|
nonce: randomUUID(),
|
|
444
546
|
})}\n`;
|
|
547
|
+
ownerBytes = expectedBytes;
|
|
445
548
|
const bytes = Buffer.from(expectedBytes, 'utf8');
|
|
446
549
|
let offset = 0;
|
|
447
550
|
while (offset < bytes.length) {
|
|
551
|
+
options.faultInjector?.('before-token-write', { descriptor, path });
|
|
448
552
|
const written = writeSync(descriptor, bytes, offset, bytes.length - offset);
|
|
449
553
|
if (!Number.isSafeInteger(written) || written < 1) fail('STATE_LOCK_WRITE_FAILED');
|
|
450
554
|
offset += written;
|
|
555
|
+
bytesWritten = offset;
|
|
451
556
|
}
|
|
452
557
|
tokenWritten = true;
|
|
453
558
|
fsyncSync(descriptor);
|
|
@@ -456,7 +561,7 @@ export function withStateLock(path, operation, attempts = 200, options = {}) {
|
|
|
456
561
|
break;
|
|
457
562
|
} catch (error) {
|
|
458
563
|
if (descriptor !== undefined) {
|
|
459
|
-
cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten);
|
|
564
|
+
cleanupFailedLockAcquisition(path, descriptor, created, expectedBytes, tokenWritten, bytesWritten, identity, options);
|
|
460
565
|
descriptor = undefined;
|
|
461
566
|
}
|
|
462
567
|
if (error?.code !== 'EEXIST') throw error;
|
|
@@ -474,13 +579,16 @@ export function withStateLock(path, operation, attempts = 200, options = {}) {
|
|
|
474
579
|
try {
|
|
475
580
|
return operation();
|
|
476
581
|
} finally {
|
|
477
|
-
closeSync(descriptor);
|
|
478
582
|
try {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
583
|
+
removePinnedLock(path, descriptor, identity, owned, ownerBytes, 'STATE_LOCK_OWNERSHIP_LOST', {
|
|
584
|
+
secure: true,
|
|
585
|
+
faultInjector: options.faultInjector,
|
|
586
|
+
phase: 'before-release-rename',
|
|
587
|
+
});
|
|
482
588
|
} catch (error) {
|
|
483
589
|
if (error?.code !== 'ENOENT') throw error;
|
|
590
|
+
} finally {
|
|
591
|
+
closeSync(descriptor);
|
|
484
592
|
}
|
|
485
593
|
}
|
|
486
594
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.7 (pending) — 2026-09-02
|
|
4
|
+
|
|
5
|
+
- Repair payload upgrades across the ownership-manifest transition: installs now
|
|
6
|
+
record a schema-checked `.grok/.litgrok-install-manifest.json` of SHA-256
|
|
7
|
+
payload hashes, classify files matching recorded hashes as installer-owned,
|
|
8
|
+
and atomically replace owned files during upgrades. Uninstall removes owned
|
|
9
|
+
files and the manifest only after payload removal succeeds.
|
|
10
|
+
- Migrate a pristine manifest-free 0.2.5 payload through the bundled seven-file
|
|
11
|
+
legacy hash snapshot, while malformed manifests and modified or unrecognized
|
|
12
|
+
files still fail closed before payload writes.
|
|
13
|
+
- Add regression coverage for historical migration, legacy user edits,
|
|
14
|
+
malformed ownership metadata, and manifest cleanup; enable full-history CI
|
|
15
|
+
checkout for the historical archive fixture and document the ownership
|
|
16
|
+
behavior in both READMEs.
|
|
17
|
+
|
|
18
|
+
## 0.2.6 (pending) — 2026-09-02
|
|
19
|
+
|
|
20
|
+
- Harden inode-reuse handling across skill-loop state locks and the
|
|
21
|
+
autoconference/autoresearch output scaffolders: lock reclaim, failed-acquisition
|
|
22
|
+
cleanup, and release stay bound to descriptor identity, ctime/birthtime, and
|
|
23
|
+
exact bytes through tombstone checks, while opened output directories are
|
|
24
|
+
revalidated against full stat identity before publication. Add same-inode race
|
|
25
|
+
regressions and regenerate the affected canonical manifests so replacements fail
|
|
26
|
+
closed while authorized rotations remain valid.
|
|
27
|
+
|
|
3
28
|
## 0.2.5 (pending) — 2026-09-01
|
|
4
29
|
|
|
5
30
|
- Add the shared stacked LitFamily ASCII wordmark to the installer and lead both
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
</p>
|
|
23
23
|
<p align="center">
|
|
24
24
|
<img src="https://img.shields.io/badge/npm-litgrok--ai-cb3837" alt="npm: litgrok-ai" />
|
|
25
|
-
<img src="https://img.shields.io/badge/version-0.2.
|
|
25
|
+
<img src="https://img.shields.io/badge/version-0.2.7-2ea44f" alt="version 0.2.7" />
|
|
26
26
|
<img src="https://img.shields.io/badge/Grok%20Build-skills%20%2B%20rules-111827" alt="Grok Build skills and project rules" />
|
|
27
27
|
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" />
|
|
28
28
|
</p>
|
|
@@ -105,7 +105,7 @@ npx --yes litgrok-ai@latest install
|
|
|
105
105
|
Pin a version when you need a reproducible install:
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
-
npx --yes litgrok-ai@0.2.
|
|
108
|
+
npx --yes litgrok-ai@0.2.7 install
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
Preview without writing files:
|
|
@@ -120,7 +120,7 @@ Default install writes the whole payload into the current project's `.grok/` tre
|
|
|
120
120
|
npx --yes litgrok-ai@latest install --user
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
`--user` targets `~/.grok/skills`, `~/.grok/rules`, and `~/.grok/hooks`. Dry-run, `NO_COLOR`, and non-interactive runs do not mutate files.
|
|
123
|
+
`--user` targets `~/.grok/skills`, `~/.grok/rules`, and `~/.grok/hooks`. Dry-run, `NO_COLOR`, and non-interactive runs do not mutate files. The installer records SHA-256 ownership in `.grok/.litgrok-install-manifest.json`: byte-identical files stay untouched, and files matching a previously recorded payload hash may be replaced during an upgrade. A user-modified or otherwise unrecognized file refuses the whole install rather than being overwritten. A pristine pre-manifest 0.2.5 payload is recognized through the bundled migration snapshot while no manifest exists and receives a new manifest after the upgrade.
|
|
124
124
|
|
|
125
125
|
### Install output
|
|
126
126
|
|
|
@@ -146,7 +146,7 @@ npx --yes litgrok-ai@latest uninstall
|
|
|
146
146
|
npx --yes litgrok-ai@latest uninstall --user
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
If any installed payload file was changed, uninstall refuses the whole removal. Reinstall by running the matching `install` command again; byte-identical files are left untouched.
|
|
149
|
+
If any installed payload file was changed, uninstall refuses the whole removal. Files recorded as installer-owned are removed too, and the ownership manifest is removed only after the payload removal succeeds. Reinstall by running the matching `install` command again; byte-identical files are left untouched.
|
|
150
150
|
|
|
151
151
|
## First use
|
|
152
152
|
|
|
@@ -198,12 +198,12 @@ Review is proposal-only. `autoApply` is always false, transcript text is inert d
|
|
|
198
198
|
From an installed project, use the package CLI directly when you want a replayable foreground command:
|
|
199
199
|
|
|
200
200
|
```bash
|
|
201
|
-
npx --yes litgrok-ai@0.2.
|
|
202
|
-
npx --yes litgrok-ai@0.2.
|
|
203
|
-
npx --yes litgrok-ai@0.2.
|
|
204
|
-
npx --yes litgrok-ai@0.2.
|
|
205
|
-
npx --yes litgrok-ai@0.2.
|
|
206
|
-
npx --yes litgrok-ai@0.2.
|
|
201
|
+
npx --yes litgrok-ai@0.2.7 skill-loop review <session-id>
|
|
202
|
+
npx --yes litgrok-ai@0.2.7 skill-loop list
|
|
203
|
+
npx --yes litgrok-ai@0.2.7 skill-loop apply <proposal-id>
|
|
204
|
+
npx --yes litgrok-ai@0.2.7 skill-loop reject <proposal-id>
|
|
205
|
+
npx --yes litgrok-ai@0.2.7 skill-loop rollback <ledger-id>
|
|
206
|
+
npx --yes litgrok-ai@0.2.7 skill-loop curator
|
|
207
207
|
```
|
|
208
208
|
|
|
209
209
|
`apply` prints the ledger id needed for rollback. The deterministic curator is also user-invoked: by default it runs at most once every seven days, requires at least two idle hours, marks eligible unpinned agent-owned skills stale after 30 idle days, and archives them outside discovery after 90 idle days. It makes a full backup before a transition and never deletes a skill package; `--force` bypasses only the seven-day run interval for an explicit test or maintenance invocation.
|
package/README_ko-KR.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
</p>
|
|
23
23
|
<p align="center">
|
|
24
24
|
<img src="https://img.shields.io/badge/npm-litgrok--ai-cb3837" alt="npm: litgrok-ai" />
|
|
25
|
-
<img src="https://img.shields.io/badge/version-0.2.
|
|
25
|
+
<img src="https://img.shields.io/badge/version-0.2.7-2ea44f" alt="version 0.2.7" />
|
|
26
26
|
<img src="https://img.shields.io/badge/Grok%20Build-skills%20%2B%20rules-111827" alt="Grok Build skills and project rules" />
|
|
27
27
|
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" />
|
|
28
28
|
</p>
|
|
@@ -105,7 +105,7 @@ npx --yes litgrok-ai@latest install
|
|
|
105
105
|
재현 가능한 설치는 버전을 고정합니다.
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
-
npx --yes litgrok-ai@0.2.
|
|
108
|
+
npx --yes litgrok-ai@0.2.7 install
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
파일을 쓰지 않고 미리보기:
|
|
@@ -120,7 +120,7 @@ npx --yes litgrok-ai@latest install --dry-run
|
|
|
120
120
|
npx --yes litgrok-ai@latest install --user
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
`--user`는 `~/.grok/skills`, `~/.grok/rules`, `~/.grok/hooks`를 대상으로 합니다. dry-run, `NO_COLOR`, non-interactive는 파일을 바꾸지 않습니다. byte가 같은 payload
|
|
123
|
+
`--user`는 `~/.grok/skills`, `~/.grok/rules`, `~/.grok/hooks`를 대상으로 합니다. dry-run, `NO_COLOR`, non-interactive는 파일을 바꾸지 않습니다. installer는 `.grok/.litgrok-install-manifest.json`에 SHA-256 소유권을 기록합니다. byte가 같은 파일은 다시 쓰지 않고, 이전 payload hash와 일치하는 installer 소유 파일은 업그레이드에서 교체할 수 있습니다. 사용자가 바꿨거나 소유권을 확인할 수 없는 파일이 있으면 전체 설치를 거절하고 덮어쓰지 않습니다. manifest가 없을 때 0.2.5의 pristine payload는 내장 migration snapshot으로 인식하며 업그레이드 뒤 새 manifest를 기록합니다.
|
|
124
124
|
|
|
125
125
|
### 설치 출력
|
|
126
126
|
|
|
@@ -146,7 +146,7 @@ npx --yes litgrok-ai@latest uninstall
|
|
|
146
146
|
npx --yes litgrok-ai@latest uninstall --user
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
설치된 payload 파일 하나라도 바뀌었다면 uninstall은 전체 삭제를 거절합니다. 재설치는 같은 `install` 명령을 다시 실행합니다. byte가 같은 파일은 다시 쓰지 않습니다.
|
|
149
|
+
설치된 payload 파일 하나라도 바뀌었다면 uninstall은 전체 삭제를 거절합니다. installer 소유로 기록된 파일도 삭제하며, payload 삭제가 성공한 뒤에만 ownership manifest를 삭제합니다. 재설치는 같은 `install` 명령을 다시 실행합니다. byte가 같은 파일은 다시 쓰지 않습니다.
|
|
150
150
|
|
|
151
151
|
## 처음 사용하기
|
|
152
152
|
|
|
@@ -198,12 +198,12 @@ Review는 proposal만 만듭니다. `autoApply`는 항상 false이고 transcript
|
|
|
198
198
|
설치된 project에서는 다음 package CLI를 foreground command로 실행할 수 있습니다.
|
|
199
199
|
|
|
200
200
|
```bash
|
|
201
|
-
npx --yes litgrok-ai@0.2.
|
|
202
|
-
npx --yes litgrok-ai@0.2.
|
|
203
|
-
npx --yes litgrok-ai@0.2.
|
|
204
|
-
npx --yes litgrok-ai@0.2.
|
|
205
|
-
npx --yes litgrok-ai@0.2.
|
|
206
|
-
npx --yes litgrok-ai@0.2.
|
|
201
|
+
npx --yes litgrok-ai@0.2.7 skill-loop review <session-id>
|
|
202
|
+
npx --yes litgrok-ai@0.2.7 skill-loop list
|
|
203
|
+
npx --yes litgrok-ai@0.2.7 skill-loop apply <proposal-id>
|
|
204
|
+
npx --yes litgrok-ai@0.2.7 skill-loop reject <proposal-id>
|
|
205
|
+
npx --yes litgrok-ai@0.2.7 skill-loop rollback <ledger-id>
|
|
206
|
+
npx --yes litgrok-ai@0.2.7 skill-loop curator
|
|
207
207
|
```
|
|
208
208
|
|
|
209
209
|
`apply`는 rollback에 필요한 ledger id를 출력합니다. Deterministic curator도 user-invoked입니다. 기본적으로 7일에 한 번만 실행되고, 마지막 activity 뒤 최소 2시간이 지나야 하며, pin되지 않은 agent-owned skill을 30일 idle 뒤 stale로 표시하고 90일 idle 뒤 discovery 밖으로 archive합니다. Transition 전에는 전체 backup을 만들고 skill package를 삭제하지 않습니다. `--force`는 명시적인 test 또는 maintenance 실행에서 7일 interval만 건너뜁니다.
|
package/bin/litgrok.mjs
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants, copyFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
|
-
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
|
|
8
9
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
10
|
const PAYLOAD_ROOT = join(PACKAGE_ROOT, '.grok');
|
|
11
|
+
const INSTALL_MANIFEST_NAME = '.litgrok-install-manifest.json';
|
|
12
|
+
const INSTALL_MANIFEST_SCHEMA = 'litgrok.install-manifest/v1';
|
|
13
|
+
const PACKAGE_NAME = 'litgrok-ai';
|
|
14
|
+
|
|
15
|
+
// 0.2.5 was the last release before ownership manifests existed. These are the
|
|
16
|
+
// only payload hashes changed by the current security release, so a pristine
|
|
17
|
+
// 0.2.5 install can migrate while no manifest exists without treating its
|
|
18
|
+
// seven security-fix files as user conflicts.
|
|
19
|
+
const PRE_MANIFEST_PAYLOAD_HASHES = Object.freeze({
|
|
20
|
+
'skills/autoconference/references/_canonical-corpus/manifest.json': '4554ee4041b73577a844abc27a97db91e941b8412f1888209203251dc1fee500',
|
|
21
|
+
'skills/autoconference/scripts/init_conference.py': 'cb83623c1338b7e3a5be7a7beac7a17b8ae362fc24fbc01d1caff634db290dfc',
|
|
22
|
+
'skills/autoconference/scripts/verify-canonical-corpus.mjs': '4a4afb7dc0b58519a4c521c987cb8998495afc09b89fb87cafb0f1db0047c28d',
|
|
23
|
+
'skills/autoresearch/references/_canonical-corpus/manifest.json': '9667f852081d4877c579d14e63c0759ebbe863e4170a9c3d5e6487cdb1f0c323',
|
|
24
|
+
'skills/autoresearch/scripts/init_research.py': 'a5434711928eb4d0487d933df5e1b374f8553140004c6ab8e38fe577ae074d05',
|
|
25
|
+
'skills/autoresearch/scripts/verify-canonical-corpus.mjs': '80a85ed4ba0c168bd7f7abef5754c4089a629975a827b6ddb3f01cd813602274',
|
|
26
|
+
'skills/skill-observer/scripts/skill-loop.mjs': '3f3f5265928c0d71175b0f23f71b836d1dc05e7efba407bfdc2d263c6aaed0ce',
|
|
27
|
+
});
|
|
10
28
|
const USAGE = 'Usage: litgrok [install|uninstall] [--user|--project] [--dry-run] [--no-color] [--yes]\n litgrok skill-loop <review|propose|list|apply|reject|rollback|curator> ...\n';
|
|
11
29
|
const BRAILLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
12
30
|
const SPINNER_INTERVAL_MS = 80;
|
|
@@ -235,17 +253,160 @@ function reportFailure(error, { stderr, stdout, options, root, colors, step }) {
|
|
|
235
253
|
stdout.write(failureCard(options, root, colors, error?.step ?? step, errorText(error)));
|
|
236
254
|
}
|
|
237
255
|
|
|
238
|
-
function
|
|
256
|
+
function normalizedRelativePath(relativePath) {
|
|
257
|
+
return relativePath.split(sep).join('/');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sha256File(filePath) {
|
|
261
|
+
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function ownershipManifestFailure(reason) {
|
|
265
|
+
const message = `Refusing to use ${INSTALL_MANIFEST_NAME}: ${reason}`;
|
|
266
|
+
const error = new Error(message);
|
|
267
|
+
error.step = 'OWNERSHIP';
|
|
268
|
+
error.stderrLines = [message];
|
|
269
|
+
return error;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function ownershipManifestPath(root) {
|
|
273
|
+
return join(root, INSTALL_MANIFEST_NAME);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readOwnershipManifest(root) {
|
|
277
|
+
const target = ownershipManifestPath(root);
|
|
278
|
+
let stat;
|
|
279
|
+
try {
|
|
280
|
+
stat = lstatSync(target);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error?.code === 'ENOENT') return null;
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw ownershipManifestFailure('path is not a regular file');
|
|
286
|
+
|
|
287
|
+
let parsed;
|
|
288
|
+
try {
|
|
289
|
+
parsed = JSON.parse(readFileSync(target, 'utf8'));
|
|
290
|
+
} catch {
|
|
291
|
+
throw ownershipManifestFailure('file is not valid JSON');
|
|
292
|
+
}
|
|
293
|
+
if (
|
|
294
|
+
!parsed ||
|
|
295
|
+
parsed.schema !== INSTALL_MANIFEST_SCHEMA ||
|
|
296
|
+
parsed.package !== PACKAGE_NAME ||
|
|
297
|
+
typeof parsed.version !== 'string' ||
|
|
298
|
+
parsed.version.length === 0 ||
|
|
299
|
+
!parsed.files ||
|
|
300
|
+
Array.isArray(parsed.files) ||
|
|
301
|
+
typeof parsed.files !== 'object'
|
|
302
|
+
) {
|
|
303
|
+
throw ownershipManifestFailure('schema is invalid');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const hashes = new Map();
|
|
307
|
+
for (const [relativePath, hash] of Object.entries(parsed.files)) {
|
|
308
|
+
const segments = relativePath.split('/');
|
|
309
|
+
if (
|
|
310
|
+
relativePath.length === 0 ||
|
|
311
|
+
relativePath.startsWith('/') ||
|
|
312
|
+
relativePath.includes('\\') ||
|
|
313
|
+
segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')
|
|
314
|
+
) {
|
|
315
|
+
throw ownershipManifestFailure(`path is invalid: ${relativePath}`);
|
|
316
|
+
}
|
|
317
|
+
if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/u.test(hash)) {
|
|
318
|
+
throw ownershipManifestFailure(`hash is invalid: ${relativePath}`);
|
|
319
|
+
}
|
|
320
|
+
hashes.set(relativePath, hash);
|
|
321
|
+
}
|
|
322
|
+
return { path: target, hashes };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function targetState(source, target, relativePath, manifest) {
|
|
239
326
|
try {
|
|
240
327
|
const targetStat = lstatSync(target);
|
|
241
328
|
if (!targetStat.isFile()) return 'conflict';
|
|
242
|
-
|
|
329
|
+
const targetHash = sha256File(target);
|
|
330
|
+
if (targetHash === sha256File(source)) return 'current';
|
|
331
|
+
const previousHash = manifest?.hashes.get(normalizedRelativePath(relativePath)) ??
|
|
332
|
+
(manifest === null ? PRE_MANIFEST_PAYLOAD_HASHES[normalizedRelativePath(relativePath)] : undefined);
|
|
333
|
+
return targetHash === previousHash ? 'owned' : 'conflict';
|
|
243
334
|
} catch (error) {
|
|
244
335
|
if (error?.code === 'ENOENT') return 'missing';
|
|
245
336
|
throw error;
|
|
246
337
|
}
|
|
247
338
|
}
|
|
248
339
|
|
|
340
|
+
function manifestContent(files) {
|
|
341
|
+
const hashes = Object.fromEntries(
|
|
342
|
+
files
|
|
343
|
+
.map(({ relativePath, source }) => [normalizedRelativePath(relativePath), sha256File(source)])
|
|
344
|
+
.sort(([left], [right]) => left.localeCompare(right)),
|
|
345
|
+
);
|
|
346
|
+
return `${JSON.stringify({
|
|
347
|
+
schema: INSTALL_MANIFEST_SCHEMA,
|
|
348
|
+
package: PACKAGE_NAME,
|
|
349
|
+
version: packageVersion(),
|
|
350
|
+
files: hashes,
|
|
351
|
+
}, null, 2)}\n`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function writeOwnershipManifest(root, files, previousManifest) {
|
|
355
|
+
const target = ownershipManifestPath(root);
|
|
356
|
+
const content = manifestContent(files);
|
|
357
|
+
let existing;
|
|
358
|
+
try {
|
|
359
|
+
const stat = lstatSync(target);
|
|
360
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw ownershipManifestFailure('path is not a regular file');
|
|
361
|
+
existing = readFileSync(target, 'utf8');
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
364
|
+
}
|
|
365
|
+
if (existing === content) return false;
|
|
366
|
+
if (previousManifest === null && existing !== undefined) {
|
|
367
|
+
throw ownershipManifestFailure('path appeared during installation');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const temporary = join(root, `.${basename(target)}.${randomUUID()}.tmp`);
|
|
371
|
+
try {
|
|
372
|
+
writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
|
|
373
|
+
renameSync(temporary, target);
|
|
374
|
+
} finally {
|
|
375
|
+
try {
|
|
376
|
+
unlinkSync(temporary);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function replaceOwnedFile(source, target) {
|
|
385
|
+
const temporary = join(dirname(target), `.${basename(target)}.${randomUUID()}.tmp`);
|
|
386
|
+
try {
|
|
387
|
+
copyFileSync(source, temporary, constants.COPYFILE_EXCL);
|
|
388
|
+
renameSync(temporary, target);
|
|
389
|
+
} finally {
|
|
390
|
+
try {
|
|
391
|
+
unlinkSync(temporary);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function removeOwnershipManifest(root, previousManifest) {
|
|
399
|
+
if (previousManifest === null) return;
|
|
400
|
+
const target = ownershipManifestPath(root);
|
|
401
|
+
try {
|
|
402
|
+
const stat = lstatSync(target);
|
|
403
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw ownershipManifestFailure('path is not a regular file');
|
|
404
|
+
unlinkSync(target);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
249
410
|
function discoverPayloadFiles(root = PAYLOAD_ROOT) {
|
|
250
411
|
const files = [];
|
|
251
412
|
|
|
@@ -383,10 +544,12 @@ export async function run(args, context = {}) {
|
|
|
383
544
|
|
|
384
545
|
step = 'OWNERSHIP';
|
|
385
546
|
const states = runStep(progress, 'OWNERSHIP', () => {
|
|
386
|
-
const
|
|
547
|
+
const manifest = readOwnershipManifest(root);
|
|
548
|
+
const owned = files.map(({ relativePath, source, target }) => targetState(source, target, relativePath, manifest));
|
|
387
549
|
const counts = {
|
|
388
550
|
missing: owned.filter((state) => state === 'missing').length,
|
|
389
551
|
current: owned.filter((state) => state === 'current').length,
|
|
552
|
+
owned: owned.filter((state) => state === 'owned').length,
|
|
390
553
|
conflicts: owned.filter((state) => state === 'conflict').length,
|
|
391
554
|
};
|
|
392
555
|
if (counts.conflicts > 0) {
|
|
@@ -394,14 +557,14 @@ export async function run(args, context = {}) {
|
|
|
394
557
|
for (let index = 0; index < files.length; index += 1) {
|
|
395
558
|
if (owned[index] === 'conflict') stderrLines.push(`Refusing to ${options.command === 'install' ? 'overwrite' : 'remove'} different existing file: ${files[index].target}`);
|
|
396
559
|
}
|
|
397
|
-
const error = new Error(`${counts.missing} missing · ${counts.current} already-current · ${counts.conflicts} conflicts`);
|
|
560
|
+
const error = new Error(`${counts.missing} missing · ${counts.current} already-current · ${counts.owned} owned · ${counts.conflicts} conflicts`);
|
|
398
561
|
error.step = 'OWNERSHIP';
|
|
399
562
|
error.stderrLines = stderrLines;
|
|
400
563
|
throw error;
|
|
401
564
|
}
|
|
402
565
|
return {
|
|
403
|
-
value: { states: owned, counts },
|
|
404
|
-
detail: `${counts.missing} missing · ${counts.current} already-current · ${counts.conflicts} conflicts`,
|
|
566
|
+
value: { states: owned, counts, manifest },
|
|
567
|
+
detail: `${counts.missing} missing · ${counts.current} already-current · ${counts.owned} owned · ${counts.conflicts} conflicts`,
|
|
405
568
|
};
|
|
406
569
|
});
|
|
407
570
|
|
|
@@ -410,12 +573,19 @@ export async function run(args, context = {}) {
|
|
|
410
573
|
const writeStarted = process.hrtime.bigint();
|
|
411
574
|
progress.start('WRITE');
|
|
412
575
|
let removed = 0;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
576
|
+
try {
|
|
577
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
578
|
+
if (!['current', 'owned'].includes(states.states[index])) continue;
|
|
579
|
+
unlinkSync(files[index].target);
|
|
580
|
+
removed += 1;
|
|
581
|
+
}
|
|
582
|
+
removeOwnershipManifest(root, states.manifest);
|
|
583
|
+
progress.finish('ok', 'WRITE', `${removed} removed · ${states.counts.missing} already absent`, elapsedSince(writeStarted));
|
|
584
|
+
} catch (error) {
|
|
585
|
+
progress.finish('failed', 'WRITE', errorText(error), elapsedSince(writeStarted));
|
|
586
|
+
if (error && typeof error === 'object') error.step = 'WRITE';
|
|
587
|
+
throw error;
|
|
417
588
|
}
|
|
418
|
-
progress.finish('ok', 'WRITE', `${removed} removed · ${states.counts.missing} already absent`, elapsedSince(writeStarted));
|
|
419
589
|
const receipt = receiptCard(options, displayRoot, colors, { status: 'Removed', written: 0, current: states.counts.current });
|
|
420
590
|
progress.close();
|
|
421
591
|
stdout.write(receipt);
|
|
@@ -423,6 +593,12 @@ export async function run(args, context = {}) {
|
|
|
423
593
|
}
|
|
424
594
|
|
|
425
595
|
if (states.states.every((state) => state === 'current')) {
|
|
596
|
+
try {
|
|
597
|
+
writeOwnershipManifest(root, files, states.manifest);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (error && typeof error === 'object') error.step = 'WRITE';
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
426
602
|
skipStep(progress, 'WRITE', `SKIPPED · 0 written · ${states.counts.current} already-current`);
|
|
427
603
|
const receipt = receiptCard(options, displayRoot, colors, { status: 'Already current', written: 0, current: states.counts.current });
|
|
428
604
|
progress.close();
|
|
@@ -438,10 +614,12 @@ export async function run(args, context = {}) {
|
|
|
438
614
|
if (states.states[index] === 'current') continue;
|
|
439
615
|
const { source, target } = files[index];
|
|
440
616
|
mkdirSync(dirname(target), { recursive: true });
|
|
441
|
-
|
|
617
|
+
if (states.states[index] === 'owned') replaceOwnedFile(source, target);
|
|
618
|
+
else copyFileSync(source, target, constants.COPYFILE_EXCL);
|
|
442
619
|
installed += 1;
|
|
443
620
|
}
|
|
444
|
-
|
|
621
|
+
writeOwnershipManifest(root, files, states.manifest);
|
|
622
|
+
progress.finish('ok', 'WRITE', `${installed} written · ${states.counts.current} already-current · ${states.counts.owned} owned`, elapsedSince(writeStarted));
|
|
445
623
|
} catch (error) {
|
|
446
624
|
progress.finish('failed', 'WRITE', errorText(error), elapsedSince(writeStarted));
|
|
447
625
|
if (error && typeof error === 'object') error.step = 'WRITE';
|
package/package.json
CHANGED