multimodel-dev-os 3.1.0 → 3.5.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.
Files changed (102) hide show
  1. package/.ai/policies/registry-policy.yaml +29 -1
  2. package/.ai/registries/trusted-keys.yaml +12 -0
  3. package/.ai/schema/registry-manifest.schema.json +31 -2
  4. package/.ai/schema/registry-policy.schema.json +37 -1
  5. package/.ai/schema/trusted-keys.schema.json +69 -0
  6. package/AGENTS.md +22 -26
  7. package/MEMORY.md +34 -11
  8. package/README.md +2 -1
  9. package/RUNBOOK.md +28 -36
  10. package/TASKS.md +15 -5
  11. package/bin/multimodel-dev-os.js +1366 -548
  12. package/docs/.vitepress/config.js +3 -1
  13. package/docs/architecture.md +3 -1
  14. package/docs/index.md +5 -5
  15. package/docs/npm-publishing.md +5 -5
  16. package/docs/package-safety.md +17 -0
  17. package/docs/public/llms-full.txt +5 -1
  18. package/docs/public/llms.txt +6 -1
  19. package/docs/public/sitemap.xml +15 -0
  20. package/docs/registry-policy.md +29 -1
  21. package/docs/registry-security.md +73 -6
  22. package/docs/registry-signing.md +70 -0
  23. package/docs/registry-sync.md +5 -2
  24. package/docs/registry-trust-store.md +66 -0
  25. package/docs/release-policy.md +6 -5
  26. package/docs/security-threat-model.md +96 -0
  27. package/docs/testing.md +25 -2
  28. package/docs/trusted-registries.md +1 -1
  29. package/docs/v3-roadmap.md +17 -6
  30. package/docs/v3.5.0-readiness.md +46 -0
  31. package/package.json +5 -2
  32. package/scripts/build-cli.js +45 -3
  33. package/scripts/check-build-fresh.js +52 -0
  34. package/scripts/install.ps1 +1 -1
  35. package/scripts/install.sh +1 -1
  36. package/scripts/verify.js +327 -14
  37. package/scripts/verify.sh +10 -0
  38. package/src/catalog/loader.js +117 -0
  39. package/src/cli/args.js +118 -0
  40. package/src/cli/help.js +60 -0
  41. package/src/cli/main.js +6263 -0
  42. package/src/core/globals.js +52 -0
  43. package/src/core/hashes.js +15 -0
  44. package/src/core/policy.js +44 -0
  45. package/src/core/security.js +61 -0
  46. package/src/core/yaml.js +136 -0
  47. package/src/plugin/manifest.js +95 -0
  48. package/src/registry/provenance.js +114 -0
  49. package/src/registry/signing.js +392 -0
  50. package/src/registry/sources.js +40 -0
  51. package/src/registry/trust-store.js +41 -0
  52. package/src/registry/validation.js +45 -0
  53. package/src/registry/verdict.js +51 -0
  54. package/tests/README.md +37 -0
  55. package/tests/fixtures/README.md +22 -0
  56. package/tests/fixtures/custom-template-example/README.md +10 -0
  57. package/tests/fixtures/proposals/approved-append-line.md +28 -0
  58. package/tests/fixtures/proposals/approved-create-file.md +29 -0
  59. package/tests/fixtures/proposals/approved-replace-text.md +30 -0
  60. package/tests/fixtures/proposals/existing-create-file-no-overwrite.md +29 -0
  61. package/tests/fixtures/proposals/no-operations.md +18 -0
  62. package/tests/fixtures/proposals/path-traversal.md +29 -0
  63. package/tests/fixtures/proposals/pending-proposal.md +29 -0
  64. package/tests/fixtures/proposals/protected-path.md +29 -0
  65. package/tests/fixtures/proposals/replace-multiple-without-allow.md +30 -0
  66. package/tests/fixtures/registry-overrides/README.md +20 -0
  67. package/tests/fixtures/signed-registries/README.md +4 -0
  68. package/tests/fixtures/signed-registries/revoked-key/catalog.yaml +8 -0
  69. package/tests/fixtures/signed-registries/revoked-key/expected-verdict.json +7 -0
  70. package/tests/fixtures/signed-registries/revoked-key/registry-manifest.yaml +14 -0
  71. package/tests/fixtures/signed-registries/tampered-manifest/catalog.yaml +8 -0
  72. package/tests/fixtures/signed-registries/tampered-manifest/expected-verdict.json +7 -0
  73. package/tests/fixtures/signed-registries/tampered-manifest/registry-manifest.yaml +14 -0
  74. package/tests/fixtures/signed-registries/trusted-keys.yaml +23 -0
  75. package/tests/fixtures/signed-registries/unsigned-remote-required/catalog.yaml +8 -0
  76. package/tests/fixtures/signed-registries/unsigned-remote-required/expected-verdict.json +7 -0
  77. package/tests/fixtures/signed-registries/unsigned-remote-required/registry-manifest.yaml +9 -0
  78. package/tests/fixtures/signed-registries/unsupported-algorithm/catalog.yaml +8 -0
  79. package/tests/fixtures/signed-registries/unsupported-algorithm/expected-verdict.json +7 -0
  80. package/tests/fixtures/signed-registries/unsupported-algorithm/registry-manifest.yaml +14 -0
  81. package/tests/fixtures/signed-registries/valid-signed-registry/catalog.yaml +8 -0
  82. package/tests/fixtures/signed-registries/valid-signed-registry/expected-verdict.json +7 -0
  83. package/tests/fixtures/signed-registries/valid-signed-registry/registry-manifest.yaml +14 -0
  84. package/tests/fixtures/signed-registries/wrong-key/catalog.yaml +8 -0
  85. package/tests/fixtures/signed-registries/wrong-key/expected-verdict.json +7 -0
  86. package/tests/fixtures/signed-registries/wrong-key/registry-manifest.yaml +14 -0
  87. package/tests/smoke/README.md +37 -0
  88. package/tests/smoke/cli-smoke.md +49 -0
  89. package/tests/unit/build-output.test.js +40 -0
  90. package/tests/unit/catalog-loader.test.js +44 -0
  91. package/tests/unit/path-safety.test.js +62 -0
  92. package/tests/unit/plugin-manifest.test.js +94 -0
  93. package/tests/unit/prepublish-guard.test.js +35 -0
  94. package/tests/unit/registry-e2e-signature-fixtures.test.js +288 -0
  95. package/tests/unit/registry-policy.test.js +52 -0
  96. package/tests/unit/registry-provenance.test.js +185 -0
  97. package/tests/unit/registry-public-signing.test.js +109 -0
  98. package/tests/unit/registry-signature-policy.test.js +100 -0
  99. package/tests/unit/registry-signing.test.js +193 -0
  100. package/tests/unit/registry-trust-store.test.js +133 -0
  101. package/tests/unit/registry-url-validation.test.js +64 -0
  102. package/tests/unit/yaml.test.js +92 -0
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join, resolve, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { parseYaml } from './yaml.js';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ // When bundled, __dirname will resolve to F:\multimodel-dev-os\bin or equivalent
10
+ // So resolving '..' will give the project root directory
11
+ export const sourceRoot = resolve(__dirname, '..');
12
+
13
+ let pkgVersion = '3.0.2';
14
+ try {
15
+ const pkgData = JSON.parse(readFileSync(resolve(sourceRoot, 'package.json'), 'utf8'));
16
+ pkgVersion = pkgData.version;
17
+ } catch (e) {}
18
+
19
+ export const version = pkgVersion;
20
+
21
+ export function loadTemplates(customPath) {
22
+ let path = customPath || join(sourceRoot, '.ai', 'templates', 'registry.yaml');
23
+ try {
24
+ if (existsSync(path)) {
25
+ const templatesRegistry = parseYaml(readFileSync(path, 'utf8'));
26
+ return templatesRegistry.templates || {};
27
+ }
28
+ } catch (e) {}
29
+ return {
30
+ 'general-app': {
31
+ name: 'general-app',
32
+ description: 'Baseline generic fallback profile for standard backend systems.',
33
+ stack: 'Universal backends baseline structure',
34
+ skill: 'example-skill.md',
35
+ skillDesc: 'Generic baseline instructions and coding standards.',
36
+ status: 'stable',
37
+ maturity: 'production-ready',
38
+ required_files: ['AGENTS.md', 'MEMORY.md', 'TASKS.md', 'RUNBOOK.md', '.ai/config.yaml']
39
+ }
40
+ };
41
+ }
42
+
43
+ export function loadAdapters(customPath) {
44
+ let path = customPath || join(sourceRoot, '.ai', 'adapters', 'registry.yaml');
45
+ try {
46
+ if (existsSync(path)) {
47
+ const adaptersRegistry = parseYaml(readFileSync(path, 'utf8'));
48
+ return adaptersRegistry.adapters || {};
49
+ }
50
+ } catch (e) {}
51
+ return {};
52
+ }
@@ -0,0 +1,15 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync } from 'fs';
3
+
4
+ export function computeSHA256(content) {
5
+ return createHash('sha256').update(content, 'utf8').digest('hex');
6
+ }
7
+
8
+ export function hashFile(filePath) {
9
+ try {
10
+ const data = readFileSync(filePath);
11
+ return createHash('sha256').update(data).digest('hex');
12
+ } catch (e) {
13
+ return '';
14
+ }
15
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { sourceRoot } from './globals.js';
4
+ import { parseYaml } from './yaml.js';
5
+
6
+ export function loadRegistryPolicy(targetDir) {
7
+ const defaults = {
8
+ allow_remote_registries: false,
9
+ allow_http_localhost: false,
10
+ require_approval_for_remote_sync: true,
11
+ require_checksum: true,
12
+ require_signature: false,
13
+ require_lockfile_on_verify: false,
14
+ allow_untrusted_install: false,
15
+ allowed_write_roots: ['.ai/', 'adapters/'],
16
+ blocked_paths: ['.env', '.npmrc', '.git/', 'node_modules/', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'],
17
+ max_plugin_files: 20,
18
+ max_plugin_size_kb: 100,
19
+ max_registry_cache_size_kb: 512,
20
+ allowed_file_extensions: ['.md', '.yaml', '.yml', '.json'],
21
+ allow_unsigned_local: true,
22
+ allow_unsigned_bundled: true,
23
+ allow_unsigned_remote: false,
24
+ trusted_keys_file: '.ai/registries/trusted-keys.yaml',
25
+ allowed_signature_algorithms: ['ed25519', 'hmac-sha256'],
26
+ require_trusted_publisher: false,
27
+ provenance_required: true
28
+ };
29
+ const paths = [];
30
+ if (targetDir) {
31
+ paths.push(join(targetDir, '.ai', 'policies', 'registry-policy.yaml'));
32
+ }
33
+ paths.push(join(sourceRoot, '.ai', 'policies', 'registry-policy.yaml'));
34
+
35
+ for (const p of paths) {
36
+ if (existsSync(p)) {
37
+ try {
38
+ const parsed = parseYaml(readFileSync(p, 'utf8'));
39
+ return { ...defaults, ...parsed };
40
+ } catch (e) {}
41
+ }
42
+ }
43
+ return defaults;
44
+ }
@@ -0,0 +1,61 @@
1
+ export function shouldIgnorePath(relPath) {
2
+ const normalized = relPath.replace(/\\/g, '/');
3
+ const segments = normalized.split('/');
4
+
5
+ // Ignored folders
6
+ const ignoredFolders = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage'];
7
+ for (const seg of segments) {
8
+ if (ignoredFolders.includes(seg)) return true;
9
+ }
10
+
11
+ // Special check for docs/.vitepress/dist and docs/.vitepress/cache
12
+ if (normalized.includes('docs/.vitepress/dist') || normalized.includes('docs/.vitepress/cache')) {
13
+ return true;
14
+ }
15
+
16
+ // Ignore generated memory and intelligence runtime files
17
+ if (
18
+ normalized.endsWith('memory.hash.json') ||
19
+ normalized.endsWith('memory.summary.md') ||
20
+ normalized.endsWith('feedback-log.jsonl') ||
21
+ normalized.endsWith('learning-rules.md') ||
22
+ normalized.endsWith('apply-log.jsonl') ||
23
+ normalized.includes('.ai/proposals/')
24
+ ) {
25
+ return true;
26
+ }
27
+
28
+ // Skip secret-like files/patterns
29
+ const lower = normalized.toLowerCase();
30
+ const filePart = segments[segments.length - 1];
31
+ if (
32
+ lower.endsWith('.env') ||
33
+ lower.includes('.env.') ||
34
+ lower.endsWith('.npmrc') ||
35
+ lower.endsWith('.keystore') ||
36
+ lower.endsWith('.jks') ||
37
+ lower.endsWith('.key') ||
38
+ lower.endsWith('.pem') ||
39
+ lower.endsWith('credentials.json') ||
40
+ filePart === 'id_rsa' ||
41
+ filePart === 'id_dsa' ||
42
+ filePart === 'id_ecdsa' ||
43
+ filePart === 'id_ed25519'
44
+ ) {
45
+ return true;
46
+ }
47
+
48
+ return false;
49
+ }
50
+
51
+ export function isSafePath(filePath, policy = {}) {
52
+ const normPath = filePath.replace(/\\/g, '/').trim();
53
+ const allowed_write_roots = policy.allowed_write_roots || ['.ai/', 'adapters/'];
54
+ const blocked_paths = policy.blocked_paths || ['.env', '.npmrc', '.git/', 'node_modules/', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'];
55
+
56
+ const isSafeSubdir = allowed_write_roots.some(prefix => normPath.startsWith(prefix));
57
+ const hasTraversal = normPath.includes('..') || normPath.startsWith('/') || /^[a-zA-Z]:/.test(normPath);
58
+ const isBlacklisted = blocked_paths.some(black => normPath.includes(black) || normPath.split('/').includes(black.replace(/\/$/, '')));
59
+
60
+ return isSafeSubdir && !hasTraversal && !isBlacklisted;
61
+ }
@@ -0,0 +1,136 @@
1
+ export function parseFlowArray(str) {
2
+ const contents = str.slice(1, -1).trim();
3
+ if (!contents) return [];
4
+
5
+ const result = [];
6
+ const regex = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^,\s][^,]*[^,\s]|[^,\s])/g;
7
+ let match;
8
+ while ((match = regex.exec(contents)) !== null) {
9
+ if (match[1] !== undefined) {
10
+ result.push(match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'));
11
+ } else if (match[2] !== undefined) {
12
+ result.push(match[2].replace(/\\'/g, "'").replace(/\\\\/g, '\\'));
13
+ } else if (match[3] !== undefined) {
14
+ let val = match[3].trim();
15
+ if (val === 'true') val = true;
16
+ else if (val === 'false') val = false;
17
+ else if (val === 'null') val = null;
18
+ else if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
19
+ result.push(val);
20
+ }
21
+ }
22
+ return result;
23
+ }
24
+
25
+ export function parseYaml(content) {
26
+ try {
27
+ const root = {};
28
+ const stack = [{ obj: root, indent: -1, key: null, isArray: false }];
29
+
30
+ const lines = content.split(/\r?\n/);
31
+ for (let line of lines) {
32
+ // Find comment index outside quotes
33
+ let commentIdx = -1;
34
+ let insideDouble = false;
35
+ let insideSingle = false;
36
+ for (let i = 0; i < line.length; i++) {
37
+ const char = line[i];
38
+ if (char === '"' && (i === 0 || line[i-1] !== '\\')) {
39
+ insideDouble = !insideDouble;
40
+ } else if (char === "'" && (i === 0 || line[i-1] !== '\\')) {
41
+ insideSingle = !insideSingle;
42
+ } else if (char === '#' && !insideDouble && !insideSingle) {
43
+ commentIdx = i;
44
+ break;
45
+ }
46
+ }
47
+ if (commentIdx !== -1) {
48
+ line = line.substring(0, commentIdx);
49
+ }
50
+ line = line.trimEnd();
51
+ if (!line.trim()) continue;
52
+
53
+ const indent = line.match(/^ */)[0].length;
54
+ let trimmed = line.trim();
55
+
56
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
57
+ stack.pop();
58
+ }
59
+
60
+ const parent = stack[stack.length - 1];
61
+
62
+ if (trimmed.startsWith('-')) {
63
+ trimmed = trimmed.substring(1).trim();
64
+ if (!Array.isArray(parent.obj)) {
65
+ const grandparent = stack[stack.length - 2];
66
+ if (grandparent) {
67
+ grandparent.obj[parent.key] = [];
68
+ parent.obj = grandparent.obj[parent.key];
69
+ }
70
+ }
71
+
72
+ const colonIdx = trimmed.indexOf(':');
73
+ if (colonIdx === -1) {
74
+ let val = trimmed;
75
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
76
+ val = val.substring(1, val.length - 1);
77
+ }
78
+ if (val.startsWith('[') && val.endsWith(']')) {
79
+ val = parseFlowArray(val);
80
+ }
81
+ parent.obj.push(val);
82
+ } else {
83
+ const key = trimmed.substring(0, colonIdx).trim();
84
+ let val = trimmed.substring(colonIdx + 1).trim();
85
+ let isQuoted = false;
86
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
87
+ val = val.substring(1, val.length - 1);
88
+ isQuoted = true;
89
+ }
90
+ if (val.startsWith('[') && val.endsWith(']')) {
91
+ val = parseFlowArray(val);
92
+ } else if (!isQuoted) {
93
+ if (val === 'true') val = true;
94
+ else if (val === 'false') val = false;
95
+ else if (val === 'null') val = null;
96
+ else if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
97
+ }
98
+
99
+ const newObj = { [key]: val };
100
+ parent.obj.push(newObj);
101
+ stack.push({ obj: newObj, indent: indent, key: key, isArray: false });
102
+ }
103
+ } else {
104
+ const colonIdx = trimmed.indexOf(':');
105
+ if (colonIdx === -1) continue;
106
+
107
+ const key = trimmed.substring(0, colonIdx).trim();
108
+ let val = trimmed.substring(colonIdx + 1).trim();
109
+ let isQuoted = false;
110
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
111
+ val = val.substring(1, val.length - 1);
112
+ isQuoted = true;
113
+ }
114
+ if (val.startsWith('[') && val.endsWith(']')) {
115
+ val = parseFlowArray(val);
116
+ } else if (!isQuoted) {
117
+ if (val === 'true') val = true;
118
+ else if (val === 'false') val = false;
119
+ else if (val === 'null') val = null;
120
+ else if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
121
+ }
122
+
123
+ if (val === '') {
124
+ parent.obj[key] = {};
125
+ stack.push({ obj: parent.obj[key], indent: indent, key: key, isArray: false });
126
+ } else {
127
+ parent.obj[key] = val;
128
+ }
129
+ }
130
+ }
131
+ return root;
132
+ } catch (e) {
133
+ console.warn(`\x1b[33m[WARNING] Failed to parse YAML: ${e.message}\x1b[0m`);
134
+ return {};
135
+ }
136
+ }
@@ -0,0 +1,95 @@
1
+ export function validatePluginManifest(manifestObj) {
2
+ const errors = [];
3
+
4
+ const reqKeys = ['name', 'slug', 'version', 'description', 'author'];
5
+ reqKeys.forEach(k => {
6
+ if (manifestObj[k] === undefined || manifestObj[k] === null) {
7
+ errors.push(`Missing required key: ${k}`);
8
+ } else if (typeof manifestObj[k] !== 'string') {
9
+ errors.push(`Key '${k}' must be a string`);
10
+ } else if (k === 'slug') {
11
+ if (!/^[a-z0-9-_]+$/i.test(manifestObj[k])) {
12
+ errors.push(`Key 'slug' must be alphanumeric with dashes or underscores only`);
13
+ }
14
+ }
15
+ });
16
+
17
+ if (manifestObj.allowed_file_patterns !== undefined) {
18
+ if (!Array.isArray(manifestObj.allowed_file_patterns)) {
19
+ errors.push(`allowed_file_patterns must be an array`);
20
+ } else {
21
+ manifestObj.allowed_file_patterns.forEach(pat => {
22
+ if (typeof pat !== 'string') {
23
+ errors.push(`allowed_file_patterns item must be a string: ${pat}`);
24
+ return;
25
+ }
26
+ const normPattern = pat.replace(/\\/g, '/').trim();
27
+ const isSafeSubdir = [
28
+ '.ai/plugins/',
29
+ '.ai/registries/',
30
+ '.ai/templates/',
31
+ '.ai/skills/',
32
+ '.ai/checks/',
33
+ '.ai/prompts/',
34
+ '.ai/adapters/',
35
+ 'adapters/'
36
+ ].some(prefix => normPattern.startsWith(prefix));
37
+
38
+ const hasTraversal = normPattern.includes('..') || normPattern.startsWith('/');
39
+ const isBlacklisted = [
40
+ '.env',
41
+ '.npmrc',
42
+ '.git/',
43
+ 'node_modules/',
44
+ 'package.json',
45
+ 'package-lock.json'
46
+ ].some(black => normPattern.includes(black));
47
+
48
+ if (!isSafeSubdir || hasTraversal || isBlacklisted) {
49
+ errors.push(`File pattern '${pat}' violates safety boundaries`);
50
+ }
51
+ });
52
+ }
53
+ }
54
+
55
+ if (manifestObj.denied_file_patterns !== undefined) {
56
+ if (!Array.isArray(manifestObj.denied_file_patterns)) {
57
+ errors.push(`denied_file_patterns must be an array`);
58
+ } else {
59
+ manifestObj.denied_file_patterns.forEach(pat => {
60
+ if (typeof pat !== 'string') {
61
+ errors.push(`denied_file_patterns item must be a string: ${pat}`);
62
+ }
63
+ });
64
+ }
65
+ }
66
+
67
+ if (manifestObj.workflows !== undefined) {
68
+ if (typeof manifestObj.workflows !== 'object' || Array.isArray(manifestObj.workflows)) {
69
+ errors.push(`workflows must be an object`);
70
+ }
71
+ }
72
+
73
+ if (manifestObj.templates !== undefined) {
74
+ if (typeof manifestObj.templates !== 'object' || Array.isArray(manifestObj.templates)) {
75
+ errors.push(`templates must be an object`);
76
+ }
77
+ }
78
+
79
+ if (manifestObj.adapters !== undefined) {
80
+ if (typeof manifestObj.adapters !== 'object' || Array.isArray(manifestObj.adapters)) {
81
+ errors.push(`adapters must be an object`);
82
+ }
83
+ }
84
+
85
+ if (manifestObj.safety_notes !== undefined) {
86
+ if (typeof manifestObj.safety_notes !== 'string') {
87
+ errors.push(`safety_notes must be a string`);
88
+ }
89
+ }
90
+
91
+ return {
92
+ success: errors.length === 0,
93
+ errors
94
+ };
95
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Registry Provenance — Lockfile I/O
3
+ *
4
+ * Manages `.ai/registry-lock.json`, the tamper-evident record that captures
5
+ * deterministic provenance for every synced registry: URL, catalog/manifest
6
+ * hashes, sync timestamp, and optional HMAC-SHA256 signature.
7
+ *
8
+ * This module is pure I/O + structure. No signing logic lives here.
9
+ * See: src/registry/signing.js for HMAC operations.
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+
15
+ const LOCKFILE_VERSION = '1';
16
+ const LOCKFILE_FILENAME = 'registry-lock.json';
17
+
18
+ /**
19
+ * Return the absolute path to the registry lockfile for a given project dir.
20
+ * @param {string} targetDir Absolute path to the project root.
21
+ * @returns {string}
22
+ */
23
+ export function getLockfilePath(targetDir) {
24
+ return join(targetDir, '.ai', LOCKFILE_FILENAME);
25
+ }
26
+
27
+ /**
28
+ * Load the registry lockfile from disk.
29
+ * Returns a well-formed empty structure if the file does not exist or is invalid.
30
+ *
31
+ * @param {string} targetDir Absolute path to the project root.
32
+ * @returns {{ lockfile_version: string, generated_at: string, entries: Object }}
33
+ */
34
+ export function loadRegistryLockfile(targetDir) {
35
+ const lockfilePath = getLockfilePath(targetDir);
36
+ const empty = { lockfile_version: LOCKFILE_VERSION, generated_at: '', entries: {} };
37
+
38
+ if (!existsSync(lockfilePath)) {
39
+ return empty;
40
+ }
41
+
42
+ try {
43
+ const raw = readFileSync(lockfilePath, 'utf8');
44
+ const parsed = JSON.parse(raw);
45
+ if (!parsed || typeof parsed !== 'object' || !parsed.entries) {
46
+ return empty;
47
+ }
48
+ // Ensure lockfile_version is present
49
+ parsed.lockfile_version = parsed.lockfile_version || LOCKFILE_VERSION;
50
+ return parsed;
51
+ } catch (_e) {
52
+ return empty;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Persist the lockfile to disk.
58
+ * Creates the `.ai/` directory if it does not exist.
59
+ *
60
+ * @param {string} targetDir Absolute path to the project root.
61
+ * @param {{ lockfile_version: string, generated_at: string, entries: Object }} lockfile
62
+ */
63
+ export function saveRegistryLockfile(targetDir, lockfile) {
64
+ const lockfilePath = getLockfilePath(targetDir);
65
+ const lockfileDir = dirname(lockfilePath);
66
+
67
+ if (!existsSync(lockfileDir)) {
68
+ mkdirSync(lockfileDir, { recursive: true });
69
+ }
70
+
71
+ lockfile.generated_at = new Date().toISOString();
72
+ lockfile.lockfile_version = LOCKFILE_VERSION;
73
+
74
+ writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n', 'utf8');
75
+ }
76
+
77
+ /**
78
+ * Upsert a provenance entry for a registry into the lockfile object.
79
+ * Does NOT write to disk — call saveRegistryLockfile() after this.
80
+ *
81
+ * @param {{ entries: Object }} lockfile The lockfile object to mutate.
82
+ * @param {string} name Registry name key.
83
+ * @param {ProvenanceEntry} entry
84
+ *
85
+ * @typedef {Object} ProvenanceEntry
86
+ * @property {string} url Registry URL.
87
+ * @property {string} synced_at ISO 8601 timestamp of the sync.
88
+ * @property {string} catalog_sha256 SHA-256 hex of the downloaded catalog.yaml.
89
+ * @property {string|null} manifest_sha256 SHA-256 hex of manifest.json (null if not downloaded).
90
+ * @property {string|null} signature HMAC-SHA256 hex of catalog_sha256 (null if no key).
91
+ * @property {string} signature_alg Algorithm identifier (e.g. "hmac-sha256").
92
+ */
93
+ export function updateLockfileEntry(lockfile, name, entry) {
94
+ if (!lockfile.entries || typeof lockfile.entries !== 'object') {
95
+ lockfile.entries = {};
96
+ }
97
+ lockfile.entries[name] = {
98
+ url: entry.url,
99
+ synced_at: entry.synced_at || new Date().toISOString(),
100
+ catalog_sha256: entry.catalog_sha256,
101
+ manifest_sha256: entry.manifest_sha256 ?? null,
102
+ signature: entry.signature ?? null,
103
+ signature_alg: entry.signature_alg || 'hmac-sha256',
104
+ public_signature_status: entry.public_signature_status ?? null,
105
+ public_signature_algorithm: entry.public_signature_algorithm ?? null,
106
+ public_signature_key_id: entry.public_signature_key_id ?? null,
107
+ trusted_publisher_status: entry.trusted_publisher_status ?? null,
108
+ trust_store_path: entry.trust_store_path ?? null,
109
+ trust_verdict: entry.trust_verdict ?? null,
110
+ lockfile_verdict: entry.lockfile_verdict ?? null,
111
+ verification_errors: entry.verification_errors ?? [],
112
+ verification_warnings: entry.verification_warnings ?? []
113
+ };
114
+ }