owlrunkit 0.13.0 → 0.15.0

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,805 @@
1
+ import {
2
+ closeSync,
3
+ fstatSync,
4
+ lstatSync,
5
+ openSync,
6
+ readSync,
7
+ realpathSync,
8
+ } from "node:fs";
9
+ import path from "node:path";
10
+
11
+ import {
12
+ CORE_VERSION,
13
+ currentCoreIdentity,
14
+ validateProjectConfigV2,
15
+ } from "./core-contract.mjs";
16
+ import {
17
+ expectedRegistryTarballUrl,
18
+ isCanonicalSha512Integrity,
19
+ validateStoredRegistryAdoptionEvidence,
20
+ } from "./registry-adoption-gate.mjs";
21
+
22
+ const PACKAGE_NAME = "owlrunkit";
23
+ const EXPECTED_VERSION = CORE_VERSION;
24
+ const MAX_JSON_BYTES = 1_048_576;
25
+ const MAX_JSON_DEPTH = 64;
26
+ const MAX_JSON_NODES = 50_000;
27
+
28
+ class StrictJsonParser {
29
+ constructor(text) {
30
+ this.text = text;
31
+ this.index = 0;
32
+ this.nodes = 0;
33
+ }
34
+
35
+ parse() {
36
+ const value = this.parseValue(0);
37
+ this.skipWhitespace();
38
+ if (this.index !== this.text.length) this.fail("unexpected trailing content");
39
+ return value;
40
+ }
41
+
42
+ parseValue(depth) {
43
+ this.skipWhitespace();
44
+ this.nodes += 1;
45
+ if (this.nodes > MAX_JSON_NODES) throw new Error("JSON input exceeds the node limit");
46
+ const character = this.text[this.index];
47
+ if (character === "{") return this.parseObject(depth);
48
+ if (character === "[") return this.parseArray(depth);
49
+ if (character === '"') return this.parseString();
50
+ if (character === "t") return this.parseLiteral("true", true);
51
+ if (character === "f") return this.parseLiteral("false", false);
52
+ if (character === "n") return this.parseLiteral("null", null);
53
+ if (character === "-" || (character >= "0" && character <= "9")) {
54
+ return this.parseNumber();
55
+ }
56
+ this.fail("expected a JSON value");
57
+ }
58
+
59
+ parseObject(depth) {
60
+ const childDepth = this.enter(depth);
61
+ this.index += 1;
62
+ this.skipWhitespace();
63
+ const value = {};
64
+ const keys = new Set();
65
+ if (this.text[this.index] === "}") {
66
+ this.index += 1;
67
+ return value;
68
+ }
69
+ while (true) {
70
+ if (this.text[this.index] !== '"') this.fail("expected an object key");
71
+ const key = this.parseString();
72
+ if (keys.has(key)) throw new Error(`duplicate object key: ${key}`);
73
+ keys.add(key);
74
+ this.skipWhitespace();
75
+ if (this.text[this.index] !== ":") this.fail("expected ':' after an object key");
76
+ this.index += 1;
77
+ Object.defineProperty(value, key, {
78
+ value: this.parseValue(childDepth),
79
+ enumerable: true,
80
+ configurable: true,
81
+ writable: true,
82
+ });
83
+ this.skipWhitespace();
84
+ if (this.text[this.index] === "}") {
85
+ this.index += 1;
86
+ return value;
87
+ }
88
+ if (this.text[this.index] !== ",") this.fail("expected ',' or '}' in an object");
89
+ this.index += 1;
90
+ this.skipWhitespace();
91
+ }
92
+ }
93
+
94
+ parseArray(depth) {
95
+ const childDepth = this.enter(depth);
96
+ this.index += 1;
97
+ this.skipWhitespace();
98
+ const value = [];
99
+ if (this.text[this.index] === "]") {
100
+ this.index += 1;
101
+ return value;
102
+ }
103
+ while (true) {
104
+ value.push(this.parseValue(childDepth));
105
+ this.skipWhitespace();
106
+ if (this.text[this.index] === "]") {
107
+ this.index += 1;
108
+ return value;
109
+ }
110
+ if (this.text[this.index] !== ",") this.fail("expected ',' or ']' in an array");
111
+ this.index += 1;
112
+ }
113
+ }
114
+
115
+ parseString() {
116
+ const start = this.index;
117
+ this.index += 1;
118
+ while (this.index < this.text.length) {
119
+ const character = this.text[this.index];
120
+ if (character === '"') {
121
+ this.index += 1;
122
+ try {
123
+ return JSON.parse(this.text.slice(start, this.index));
124
+ } catch {
125
+ this.fail("invalid JSON string");
126
+ }
127
+ }
128
+ if (character.charCodeAt(0) < 0x20) this.fail("unescaped control character");
129
+ if (character === "\\") {
130
+ this.index += 1;
131
+ const escape = this.text[this.index];
132
+ if (escape === "u") {
133
+ const digits = this.text.slice(this.index + 1, this.index + 5);
134
+ if (!/^[0-9a-fA-F]{4}$/.test(digits)) this.fail("invalid Unicode escape");
135
+ this.index += 4;
136
+ } else if (!'"\\/bfnrt'.includes(escape)) {
137
+ this.fail("invalid escape");
138
+ }
139
+ }
140
+ this.index += 1;
141
+ }
142
+ this.fail("unterminated JSON string");
143
+ }
144
+
145
+ parseLiteral(token, value) {
146
+ if (this.text.slice(this.index, this.index + token.length) !== token) {
147
+ this.fail(`invalid literal: expected ${token}`);
148
+ }
149
+ this.index += token.length;
150
+ return value;
151
+ }
152
+
153
+ parseNumber() {
154
+ const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(
155
+ this.text.slice(this.index),
156
+ );
157
+ if (!match) this.fail("invalid JSON number");
158
+ this.index += match[0].length;
159
+ const value = Number(match[0]);
160
+ if (!Number.isFinite(value)) this.fail("JSON number is outside the finite range");
161
+ return value;
162
+ }
163
+
164
+ enter(depth) {
165
+ const nextDepth = depth + 1;
166
+ if (nextDepth > MAX_JSON_DEPTH) throw new Error("JSON input exceeds the nesting limit");
167
+ return nextDepth;
168
+ }
169
+
170
+ skipWhitespace() {
171
+ while (this.index < this.text.length && [" ", "\t", "\n", "\r"].includes(this.text[this.index])) {
172
+ this.index += 1;
173
+ }
174
+ }
175
+
176
+ fail(message) {
177
+ throw new Error(`${message} at byte offset ${this.index}`);
178
+ }
179
+ }
180
+
181
+ function parseJsonStrict(text) {
182
+ if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) {
183
+ throw new Error("JSON input exceeds the input-byte limit");
184
+ }
185
+ return new StrictJsonParser(text).parse();
186
+ }
187
+
188
+ export function resolveBoundedWorkspaceRoot(workspaceRoot) {
189
+ const requested = path.resolve(workspaceRoot);
190
+ const stat = lstatSync(requested);
191
+ if (
192
+ stat.isSymbolicLink()
193
+ || !stat.isDirectory()
194
+ || realpathSync(requested) !== requested
195
+ ) {
196
+ throw new Error("workspace root must be a real directory without symlink ancestors");
197
+ }
198
+ return requested;
199
+ }
200
+
201
+ function identityMatches(left, right) {
202
+ return left.dev === right.dev
203
+ && left.ino === right.ino
204
+ && left.size === right.size
205
+ && left.mtimeMs === right.mtimeMs;
206
+ }
207
+
208
+ export function readWorkspaceFileBounded(root, relativePath, { optional = false } = {}) {
209
+ const target = path.resolve(root, relativePath);
210
+ if (!strictDescendant(root, target)) throw new Error("workspace file escaped the workspace root");
211
+ const segments = path.relative(root, target).split(path.sep);
212
+ let current = root;
213
+ for (let index = 0; index < segments.length; index += 1) {
214
+ current = path.join(current, segments[index]);
215
+ let stat;
216
+ try {
217
+ stat = lstatSync(current);
218
+ } catch (error) {
219
+ if (optional && error?.code === "ENOENT") return null;
220
+ throw error;
221
+ }
222
+ if (stat.isSymbolicLink()) throw new Error("workspace file path contains a symlink");
223
+ const leaf = index === segments.length - 1;
224
+ if ((!leaf && !stat.isDirectory()) || (leaf && !stat.isFile())) {
225
+ throw new Error("workspace file path has an invalid entry type");
226
+ }
227
+ }
228
+ const realTarget = realpathSync(target);
229
+ if (!strictDescendant(root, realTarget)) throw new Error("workspace file escaped the workspace root");
230
+
231
+ const descriptor = openSync(target, "r");
232
+ try {
233
+ const before = fstatSync(descriptor);
234
+ if (!before.isFile() || before.size > MAX_JSON_BYTES) {
235
+ throw new Error("workspace file exceeds the input-byte limit");
236
+ }
237
+ const buffer = Buffer.allocUnsafe(MAX_JSON_BYTES + 1);
238
+ let offset = 0;
239
+ while (offset <= MAX_JSON_BYTES) {
240
+ const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, offset);
241
+ if (bytesRead === 0) break;
242
+ offset += bytesRead;
243
+ }
244
+ if (offset > MAX_JSON_BYTES) throw new Error("workspace file exceeds the input-byte limit");
245
+ const after = fstatSync(descriptor);
246
+ const currentStat = lstatSync(target);
247
+ if (
248
+ currentStat.isSymbolicLink()
249
+ || !currentStat.isFile()
250
+ || !identityMatches(before, after)
251
+ || !identityMatches(after, currentStat)
252
+ || realpathSync(target) !== realTarget
253
+ ) {
254
+ throw new Error("workspace file identity changed while reading");
255
+ }
256
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
257
+ } finally {
258
+ closeSync(descriptor);
259
+ }
260
+ }
261
+
262
+ export function readWorkspaceJsonBounded(root, relativePath, options) {
263
+ const text = readWorkspaceFileBounded(root, relativePath, options);
264
+ return text === null ? null : parseJsonStrict(text);
265
+ }
266
+
267
+ async function readResponseJsonBounded(response) {
268
+ if (!response.body) throw new Error("registry response body is missing");
269
+ const reader = response.body.getReader();
270
+ const chunks = [];
271
+ let total = 0;
272
+ try {
273
+ while (true) {
274
+ const { done, value } = await reader.read();
275
+ if (done) break;
276
+ total += value.byteLength;
277
+ if (total > MAX_JSON_BYTES) {
278
+ await reader.cancel();
279
+ throw new Error("registry response exceeds the input-byte limit");
280
+ }
281
+ chunks.push(Buffer.from(value));
282
+ }
283
+ } finally {
284
+ reader.releaseLock();
285
+ }
286
+ return parseJsonStrict(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
287
+ }
288
+
289
+ function strictDescendant(root, candidate) {
290
+ const relative = path.relative(root, candidate);
291
+ return relative !== ""
292
+ && relative !== ".."
293
+ && !relative.startsWith(`..${path.sep}`)
294
+ && !path.isAbsolute(relative);
295
+ }
296
+
297
+ function assertPathWithoutSymlinks(root, target, { targetType }) {
298
+ const realRoot = realpathSync(root);
299
+ const relative = path.relative(realRoot, target);
300
+ if (!strictDescendant(realRoot, target)) {
301
+ throw new Error("installed_package_path_invalid");
302
+ }
303
+ let current = realRoot;
304
+ for (const segment of relative.split(path.sep)) {
305
+ current = path.join(current, segment);
306
+ const stat = lstatSync(current);
307
+ if (stat.isSymbolicLink()) throw new Error("installed_package_symlink_rejected");
308
+ }
309
+ const finalStat = lstatSync(target);
310
+ if (
311
+ (targetType === "file" && !finalStat.isFile())
312
+ || (targetType === "directory" && !finalStat.isDirectory())
313
+ ) {
314
+ throw new Error("installed_package_path_invalid");
315
+ }
316
+ const realTarget = realpathSync(target);
317
+ if (!strictDescendant(realRoot, realTarget)) {
318
+ throw new Error("installed_package_path_invalid");
319
+ }
320
+ }
321
+
322
+ function dependencyDeclarations(manifest) {
323
+ return ["dependencies", "devDependencies", "optionalDependencies"]
324
+ .filter((section) => Object.hasOwn(manifest[section] ?? {}, PACKAGE_NAME))
325
+ .map((section) => ({ section, spec: manifest[section][PACKAGE_NAME] }));
326
+ }
327
+
328
+ export function readLocalInstallBinding({ workspaceRoot } = {}) {
329
+ const root = resolveBoundedWorkspaceRoot(workspaceRoot);
330
+ const manifestPath = path.join(root, "package.json");
331
+ const lockPath = path.join(root, "package-lock.json");
332
+ const installedRoot = path.join(root, "node_modules", PACKAGE_NAME);
333
+ const installedManifestPath = path.join(installedRoot, "package.json");
334
+ try {
335
+ for (const [target, targetType] of [
336
+ [installedRoot, "directory"],
337
+ ]) {
338
+ assertPathWithoutSymlinks(root, target, { targetType });
339
+ }
340
+ const manifest = readWorkspaceJsonBounded(root, "package.json");
341
+ const lock = readWorkspaceJsonBounded(root, "package-lock.json");
342
+ const installedManifest = readWorkspaceJsonBounded(
343
+ root,
344
+ path.join("node_modules", PACKAGE_NAME, "package.json"),
345
+ );
346
+ const declarations = dependencyDeclarations(manifest);
347
+ if (declarations.length !== 1) {
348
+ return {
349
+ status: "mismatch",
350
+ issueCodes: ["registry_install_binding_ambiguous"],
351
+ };
352
+ }
353
+ const requestedSpec = declarations[0].spec;
354
+ const lockBinding = lock.packages?.[`node_modules/${PACKAGE_NAME}`];
355
+ if (
356
+ requestedSpec !== EXPECTED_VERSION
357
+ || lockBinding?.version !== EXPECTED_VERSION
358
+ || installedManifest.name !== PACKAGE_NAME
359
+ || installedManifest.version !== EXPECTED_VERSION
360
+ || typeof lockBinding.resolved !== "string"
361
+ || typeof lockBinding.integrity !== "string"
362
+ ) {
363
+ return {
364
+ status: "mismatch",
365
+ issueCodes: ["registry_install_binding_mismatch"],
366
+ };
367
+ }
368
+ return {
369
+ status: "bound",
370
+ requestedSpec: `${PACKAGE_NAME}@${requestedSpec}`,
371
+ packageName: PACKAGE_NAME,
372
+ version: EXPECTED_VERSION,
373
+ resolved: lockBinding.resolved,
374
+ integrity: lockBinding.integrity,
375
+ };
376
+ } catch (error) {
377
+ return {
378
+ status: "mismatch",
379
+ issueCodes: [
380
+ error instanceof Error && error.message.startsWith("installed_package_")
381
+ ? error.message
382
+ : "registry_install_binding_mismatch",
383
+ ],
384
+ };
385
+ }
386
+ }
387
+
388
+ export async function readRegistryExact({
389
+ registryUrl,
390
+ packageName = PACKAGE_NAME,
391
+ version = EXPECTED_VERSION,
392
+ timeoutMs = 1_500,
393
+ } = {}) {
394
+ const base = registryUrl.replace(/\/+$/, "");
395
+ const url = `${base}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
396
+ let response;
397
+ try {
398
+ response = await fetch(url, {
399
+ headers: {
400
+ accept: "application/json",
401
+ },
402
+ redirect: "manual",
403
+ signal: AbortSignal.timeout(timeoutMs),
404
+ });
405
+ } catch {
406
+ return {
407
+ status: "unavailable",
408
+ issueCodes: ["registry_dns_or_timeout"],
409
+ };
410
+ }
411
+ if (response.status === 401 || response.status === 403) {
412
+ return {
413
+ status: "unauthorized",
414
+ issueCodes: ["registry_auth_failed"],
415
+ };
416
+ }
417
+ if (response.status === 404) {
418
+ return {
419
+ status: "version_missing",
420
+ issueCodes: ["registry_version_missing"],
421
+ };
422
+ }
423
+ if (response.status >= 300 && response.status < 400) {
424
+ return {
425
+ status: "invalid",
426
+ issueCodes: ["registry_redirect_rejected"],
427
+ };
428
+ }
429
+ if (!response.ok) {
430
+ return {
431
+ status: "unavailable",
432
+ issueCodes: [`registry_http_${response.status}`],
433
+ };
434
+ }
435
+ try {
436
+ const metadata = await readResponseJsonBounded(response);
437
+ const dist = metadata.dist;
438
+ if (
439
+ metadata.name !== packageName
440
+ || metadata.version !== version
441
+ || !/^[0-9a-f]{40}$/i.test(dist?.shasum ?? "")
442
+ || !isCanonicalSha512Integrity(dist?.integrity)
443
+ || dist?.tarball !== expectedRegistryTarballUrl(packageName, version)
444
+ ) {
445
+ return {
446
+ status: "invalid",
447
+ issueCodes: ["registry_release_evidence_invalid"],
448
+ };
449
+ }
450
+ return {
451
+ schemaVersion: "OwlCodaRunKitRegistryReleaseEvidenceV1",
452
+ status: "registry_verified",
453
+ registry: base,
454
+ packageName,
455
+ version,
456
+ shasum: dist.shasum,
457
+ integrity: dist.integrity,
458
+ tarballUrl: dist.tarball,
459
+ authorizationGranted: false,
460
+ };
461
+ } catch {
462
+ return {
463
+ status: "invalid",
464
+ issueCodes: ["registry_release_evidence_invalid"],
465
+ };
466
+ }
467
+ }
468
+
469
+ function readProjectScripts(root) {
470
+ try {
471
+ const manifest = readWorkspaceJsonBounded(root, "package.json");
472
+ return Object.keys(manifest.scripts ?? {}).sort();
473
+ } catch {
474
+ return [];
475
+ }
476
+ }
477
+
478
+ function ignoreFileCoversRuntime(root, ignoreFile) {
479
+ let text;
480
+ try {
481
+ text = readWorkspaceFileBounded(root, ignoreFile, { optional: true });
482
+ } catch {
483
+ return false;
484
+ }
485
+ if (text === null) return false;
486
+ let ignored = false;
487
+ for (const rawLine of text.split(/\r?\n/)) {
488
+ const line = rawLine.trim();
489
+ if (line.length === 0 || line.startsWith("#")) continue;
490
+ const negated = line.startsWith("!");
491
+ const pattern = (negated ? line.slice(1) : line)
492
+ .replace(/^\/+/, "")
493
+ .replace(/^\*\*\//, "")
494
+ .replace(/\/\*\*$/, "")
495
+ .replace(/\/+$/, "");
496
+ if (pattern === ".owlcoda" || pattern === ".owlcoda/runkit") {
497
+ ignored = !negated;
498
+ } else if (negated && pattern.startsWith(".owlcoda/runkit/")) {
499
+ ignored = false;
500
+ }
501
+ }
502
+ return ignored;
503
+ }
504
+
505
+ const BROAD_COLLECTION_TOOLS = Object.freeze([
506
+ {
507
+ tool: "biome",
508
+ command: /\bbiome\s+(?:check|format|lint)\b/,
509
+ ignoreFile: "biome.json",
510
+ ignoreFileSupported: false,
511
+ },
512
+ {
513
+ tool: "eslint",
514
+ command: /\beslint\b/,
515
+ ignoreFile: ".eslintignore",
516
+ ignoreFileSupported: true,
517
+ },
518
+ {
519
+ tool: "jest",
520
+ command: /\bjest\b/,
521
+ ignoreFile: "jest.config.*",
522
+ ignoreFileSupported: false,
523
+ },
524
+ {
525
+ tool: "markdownlint",
526
+ command: /\bmarkdownlint(?:-cli2)?\b/,
527
+ ignoreFile: ".markdownlintignore",
528
+ ignoreFileSupported: true,
529
+ },
530
+ {
531
+ tool: "prettier",
532
+ command: /\bprettier\b/,
533
+ ignoreFile: ".prettierignore",
534
+ ignoreFileSupported: true,
535
+ },
536
+ {
537
+ tool: "stylelint",
538
+ command: /\bstylelint\b/,
539
+ ignoreFile: ".stylelintignore",
540
+ ignoreFileSupported: true,
541
+ },
542
+ {
543
+ tool: "vitest",
544
+ command: /\bvitest\b/,
545
+ ignoreFile: "vitest.config.*",
546
+ ignoreFileSupported: false,
547
+ },
548
+ ]);
549
+
550
+ function hasBroadDotTarget(command) {
551
+ return /(?:^|\s|&&|\|\||;)(?:["']?\.\/?["']?)(?=\s|$|&&|\|\||;)/.test(command);
552
+ }
553
+
554
+ function runtimeIsolationCheck(root) {
555
+ let manifest;
556
+ try {
557
+ manifest = readWorkspaceJsonBounded(root, "package.json");
558
+ } catch {
559
+ return {
560
+ status: "unknown",
561
+ affectedScripts: [],
562
+ recommendedIgnore: ".owlcoda/runkit/",
563
+ };
564
+ }
565
+ const affectedScripts = [];
566
+ for (const [script, value] of Object.entries(manifest.scripts ?? {}).sort(([left], [right]) =>
567
+ left.localeCompare(right))) {
568
+ if (typeof value !== "string" || !hasBroadDotTarget(value)) continue;
569
+ for (const descriptor of BROAD_COLLECTION_TOOLS) {
570
+ if (!descriptor.command.test(value)) continue;
571
+ if (
572
+ descriptor.ignoreFileSupported
573
+ && ignoreFileCoversRuntime(root, descriptor.ignoreFile)
574
+ ) {
575
+ continue;
576
+ }
577
+ affectedScripts.push({
578
+ ignoreFile: descriptor.ignoreFile,
579
+ script,
580
+ tool: descriptor.tool,
581
+ });
582
+ }
583
+ }
584
+ return {
585
+ status: affectedScripts.length === 0 ? "ok" : "warning",
586
+ affectedScripts,
587
+ recommendedIgnore: ".owlcoda/runkit/",
588
+ };
589
+ }
590
+
591
+ function configCheck(root) {
592
+ try {
593
+ const config = readWorkspaceJsonBounded(
594
+ root,
595
+ path.join(".owlcoda", "runkit", "config.json"),
596
+ { optional: true },
597
+ );
598
+ if (config === null) return { status: "missing" };
599
+ const gate = validateProjectConfigV2(config);
600
+ return gate.valid ? { status: "ok" } : { status: "invalid", issues: gate.issues };
601
+ } catch {
602
+ return { status: "invalid" };
603
+ }
604
+ }
605
+
606
+ function profilesCheck(root) {
607
+ try {
608
+ const profiles = readWorkspaceJsonBounded(
609
+ root,
610
+ path.join(".owlcoda", "runkit", "profiles.json"),
611
+ { optional: true },
612
+ );
613
+ if (profiles === null) return { status: "missing" };
614
+ if (
615
+ profiles.schemaVersion !== "OwlCodaRunKitProfilesV1"
616
+ || !Array.isArray(profiles.profiles)
617
+ ) {
618
+ return { status: "invalid" };
619
+ }
620
+ const launcherWarnings = [];
621
+ for (const profile of profiles.profiles) {
622
+ for (const command of profile?.commands ?? []) {
623
+ const identity = `${profile?.id ?? "unknown"}:${command?.id ?? "unknown"}`;
624
+ if (typeof command?.executable !== "string" || !path.isAbsolute(command.executable)) {
625
+ launcherWarnings.push(`profile_launcher_not_absolute:${identity}`);
626
+ continue;
627
+ }
628
+ try {
629
+ const stat = lstatSync(command.executable);
630
+ if (stat.isSymbolicLink()) {
631
+ launcherWarnings.push(`profile_launcher_symlink_rejected:${identity}`);
632
+ } else if (!stat.isFile() || realpathSync(command.executable) !== path.resolve(command.executable)) {
633
+ launcherWarnings.push(`profile_launcher_not_regular_file:${identity}`);
634
+ }
635
+ } catch {
636
+ launcherWarnings.push(`profile_launcher_unavailable:${identity}`);
637
+ }
638
+ }
639
+ }
640
+ return {
641
+ status: "ok",
642
+ count: profiles.profiles.length,
643
+ launcherWarnings: [...new Set(launcherWarnings)].sort(),
644
+ };
645
+ } catch {
646
+ return { status: "invalid" };
647
+ }
648
+ }
649
+
650
+ export function readProfileLauncherReadiness({ workspaceRoot } = {}) {
651
+ const profiles = profilesCheck(resolveBoundedWorkspaceRoot(workspaceRoot));
652
+ const launcherWarnings = profiles.launcherWarnings ?? [];
653
+ return {
654
+ status: profiles.status,
655
+ launcherWarnings,
656
+ ready: profiles.status === "ok" && launcherWarnings.length === 0,
657
+ };
658
+ }
659
+
660
+ function adoptionCheck(root, localInstall = readLocalInstallBinding({ workspaceRoot: root })) {
661
+ try {
662
+ const evidence = readWorkspaceJsonBounded(
663
+ root,
664
+ path.join(".owlcoda", "runkit", "adoption", `${PACKAGE_NAME}-${EXPECTED_VERSION}.json`),
665
+ { optional: true },
666
+ );
667
+ if (evidence === null) return { status: "missing", version: EXPECTED_VERSION };
668
+ const validation = validateStoredRegistryAdoptionEvidence({
669
+ evidence,
670
+ packageName: PACKAGE_NAME,
671
+ version: EXPECTED_VERSION,
672
+ localInstall,
673
+ });
674
+ return {
675
+ status: validation.valid ? "ok" : "invalid",
676
+ version: EXPECTED_VERSION,
677
+ issueCodes: validation.issueCodes,
678
+ };
679
+ } catch {
680
+ return { status: "invalid", version: EXPECTED_VERSION };
681
+ }
682
+ }
683
+
684
+ export function readAdoptionReadiness({ workspaceRoot } = {}) {
685
+ const root = resolveBoundedWorkspaceRoot(workspaceRoot);
686
+ return adoptionCheck(root);
687
+ }
688
+
689
+ export async function runDoctor({
690
+ workspaceRoot,
691
+ registryUrl,
692
+ timeoutMs = 1_500,
693
+ registryClient,
694
+ } = {}) {
695
+ const root = resolveBoundedWorkspaceRoot(workspaceRoot);
696
+ const core = currentCoreIdentity();
697
+ const installed = readLocalInstallBinding({ workspaceRoot: root });
698
+ const registry = registryClient
699
+ ? await registryClient.readExact({ packageName: PACKAGE_NAME, version: EXPECTED_VERSION })
700
+ : await readRegistryExact({
701
+ registryUrl,
702
+ packageName: PACKAGE_NAME,
703
+ version: EXPECTED_VERSION,
704
+ timeoutMs,
705
+ });
706
+ const nodeOk = Number(process.versions.node.split(".")[0]) >= 20;
707
+ const config = configCheck(root);
708
+ const profiles = profilesCheck(root);
709
+ const adoption = adoptionCheck(root, installed);
710
+ const runtimeIsolation = runtimeIsolationCheck(root);
711
+ const checks = {
712
+ node: {
713
+ status: nodeOk ? "ok" : "unsupported",
714
+ version: process.versions.node,
715
+ },
716
+ core: {
717
+ status: core.coreVersion === EXPECTED_VERSION ? "ok" : "mismatch",
718
+ version: core.coreVersion,
719
+ manifestSha256: core.coreManifestSha256,
720
+ },
721
+ config,
722
+ installedPackage: installed.status === "bound"
723
+ ? { status: "ok", version: installed.version }
724
+ : { status: "mismatch" },
725
+ registry: registry.status === "registry_verified"
726
+ ? { status: "ok", version: registry.version }
727
+ : { status: "issue", issueCodes: registry.issueCodes ?? [] },
728
+ profiles,
729
+ adoption,
730
+ projectScripts: {
731
+ status: "observed",
732
+ detected: readProjectScripts(root),
733
+ },
734
+ runtimeIsolation,
735
+ };
736
+ const issueCodes = [...(registry.issueCodes ?? [])];
737
+ if (!nodeOk) issueCodes.push("node_version_unsupported");
738
+ if (checks.core.status !== "ok") issueCodes.push("core_version_mismatch");
739
+ if (config.status === "missing") issueCodes.push("runkit_config_missing");
740
+ if (config.status === "invalid") issueCodes.push("runkit_config_invalid");
741
+ if (profiles.status === "missing") issueCodes.push("profiles_missing");
742
+ if (profiles.status === "invalid") issueCodes.push("profiles_invalid");
743
+ if (installed.status !== "bound") {
744
+ issueCodes.push(...(installed.issueCodes ?? ["registry_install_binding_mismatch"]));
745
+ }
746
+ if (
747
+ registry.status === "registry_verified"
748
+ && (
749
+ installed.status !== "bound"
750
+ || installed.version !== registry.version
751
+ || installed.integrity !== registry.integrity
752
+ || installed.resolved !== registry.tarballUrl
753
+ )
754
+ ) {
755
+ issueCodes.push("registry_install_binding_mismatch");
756
+ }
757
+ const uniqueIssues = [...new Set(issueCodes)].sort();
758
+ const warningCodes = [
759
+ ...runtimeIsolation.affectedScripts
760
+ .map(({ script, tool }) => `runkit_runtime_collection_risk:${tool}:${script}`),
761
+ ...(profiles.launcherWarnings ?? []),
762
+ ].sort();
763
+ const nextAllowedAction = uniqueIssues.length === 0
764
+ ? (profiles.launcherWarnings ?? []).length > 0
765
+ ? "review_profile_launchers"
766
+ : adoption.status === "ok"
767
+ ? "plan_new_execution"
768
+ : "adopt_current_release"
769
+ : uniqueIssues.some((code) => code === "node_version_unsupported")
770
+ ? "upgrade_node"
771
+ : uniqueIssues.some((code) => code === "core_version_mismatch")
772
+ ? "install_matching_core"
773
+ : uniqueIssues.some((code) => code.startsWith("runkit_config_"))
774
+ ? "run_init_or_repair_config"
775
+ : uniqueIssues.some((code) => code.startsWith("profiles_"))
776
+ ? "detect_or_repair_profiles"
777
+ : "repair_registry_binding";
778
+ return {
779
+ schemaVersion: "OwlCodaRunKitDoctorReportV1",
780
+ status: uniqueIssues.length === 0 ? "ready" : "issues_found",
781
+ checks,
782
+ issueCodes: uniqueIssues,
783
+ warningCodes,
784
+ nextAllowedAction,
785
+ authorizationGranted: false,
786
+ };
787
+ }
788
+
789
+ export function formatDoctorHuman(report) {
790
+ return [
791
+ `Doctor: ${report.status}`,
792
+ `Node: ${report.checks.node.status} (${report.checks.node.version})`,
793
+ `Core: ${report.checks.core.status} (${report.checks.core.version})`,
794
+ `Installed package: ${report.checks.installedPackage.status}`,
795
+ `Registry: ${report.checks.registry.status}`,
796
+ `Profiles: ${report.checks.profiles.status}`,
797
+ `Adoption: ${report.checks.adoption.status}`,
798
+ ...(report.issueCodes.length > 0 ? [`Issues: ${report.issueCodes.join(", ")}`] : []),
799
+ ...(report.warningCodes.length > 0 ? [`Warnings: ${report.warningCodes.join(", ")}`] : []),
800
+ `Next: ${report.nextAllowedAction === "adopt_current_release"
801
+ ? `owlrunkit adopt --workspace . --exact ${PACKAGE_NAME}@${EXPECTED_VERSION}`
802
+ : report.nextAllowedAction}`,
803
+ "",
804
+ ].join("\n");
805
+ }