@mriekki/lean 0.1.0 → 0.2.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 (41) hide show
  1. package/README.md +71 -67
  2. package/assets/components.json +33 -33
  3. package/assets/core/distribution.json +52 -51
  4. package/assets/deployment/distribution.json +34 -34
  5. package/assets/generated/ASSEMBLY.json +38 -33
  6. package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
  7. package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
  8. package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
  9. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
  10. package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
  11. package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
  12. package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
  13. package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
  14. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
  15. package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
  16. package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
  17. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
  18. package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
  19. package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
  20. package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
  21. package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
  22. package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
  23. package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
  24. package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
  25. package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
  26. package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
  27. package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
  28. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
  29. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
  30. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
  31. package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
  32. package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
  33. package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
  34. package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
  35. package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
  36. package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
  37. package/bin/lean.mjs +9 -9
  38. package/package.json +33 -33
  39. package/src/cli.mjs +131 -131
  40. package/src/engine.mjs +243 -243
  41. package/src/package-data.mjs +18 -18
package/src/engine.mjs CHANGED
@@ -1,243 +1,243 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { readJson } from './package-data.mjs';
5
-
6
- const CORE_SAFE = new Set(['SAME', 'ADD', 'CORE_UPDATE', 'UNTRACKED_MATCH', 'BASELINE_STALE']);
7
- const DEPLOYMENT_SAFE = new Set(['SAME', 'ADD', 'KIT_UPDATE', 'UNTRACKED_MATCH', 'BASELINE_STALE', 'REMOVE', 'RETIRED_MISSING']);
8
-
9
- export function safeRelative(value, label = 'path') {
10
- if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be a non-empty relative path`);
11
- const normalized = value.replaceAll('\\', '/');
12
- if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) throw new Error(`Unsafe ${label}: ${value}`);
13
- const parts = normalized.split('/');
14
- if (parts.some((part) => !part || part === '.' || part === '..')) throw new Error(`Unsafe ${label}: ${value}`);
15
- return normalized;
16
- }
17
-
18
- export function resolveUnder(root, relative, label = 'path') {
19
- const rel = safeRelative(relative, label);
20
- const base = path.resolve(root);
21
- const candidate = path.resolve(base, ...rel.split('/'));
22
- if (candidate !== base && !candidate.startsWith(base + path.sep)) throw new Error(`${label} escapes root: ${relative}`);
23
- return candidate;
24
- }
25
-
26
- export function sha256File(file) {
27
- return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
28
- }
29
-
30
- function baselineFile(componentId) {
31
- if (componentId === 'core') return 'CORE_BASELINE.json';
32
- if (componentId === 'deployment') return 'DEPLOYMENT_BASELINE.json';
33
- throw new Error(`Unsupported component: ${componentId}`);
34
- }
35
-
36
- function loadOptionalJson(file) {
37
- if (!fs.existsSync(file)) return {};
38
- const value = readJson(file);
39
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid JSON object: ${file}`);
40
- return value;
41
- }
42
-
43
- function baselineMap(baseline, filename) {
44
- const items = baseline.managedPaths ?? [];
45
- if (Object.keys(baseline).length && (!Array.isArray(items) || items.length === 0)) {
46
- throw new Error(`Invalid ${filename}: managedPaths is missing or empty`);
47
- }
48
- const map = new Map();
49
- for (const item of items) {
50
- if (!item?.path || !item?.sha256 || !item?.source) throw new Error(`Invalid managedPaths entry in ${filename}`);
51
- if (map.has(item.path)) throw new Error(`Duplicate managed path in ${filename}: ${item.path}`);
52
- map.set(item.path, item);
53
- }
54
- return map;
55
- }
56
-
57
- export function componentEntries(packageData, componentId) {
58
- const component = packageData.assets.components[componentId];
59
- if (!component) throw new Error(`Package does not define component ${componentId}`);
60
- const descriptor = readJson(resolveUnder(packageData.root, component.descriptor, 'component descriptor'));
61
- const mappings = [];
62
- if (descriptor.profileSource && descriptor.profileDestination) {
63
- mappings.push({ source: descriptor.profileSource, path: descriptor.profileDestination });
64
- }
65
- for (const item of descriptor.mappings ?? []) mappings.push({ source: item.source, path: item.destination });
66
- if (!mappings.length) throw new Error(`${componentId} distribution has no mappings`);
67
- const seen = new Set();
68
- return mappings.map((entry) => {
69
- safeRelative(entry.source, 'source');
70
- safeRelative(entry.path, 'destination');
71
- if (seen.has(entry.path)) throw new Error(`Duplicate ${componentId} destination: ${entry.path}`);
72
- seen.add(entry.path);
73
- const bundlePath = resolveUnder(packageData.root, `${component.sourceRoot}/${entry.source}`, 'bundled source');
74
- if (!fs.existsSync(bundlePath) || !fs.statSync(bundlePath).isFile()) {
75
- throw new Error(`Missing bundled ${componentId} source: ${entry.source}`);
76
- }
77
- return { ...entry, bundlePath };
78
- });
79
- }
80
-
81
- function classifyDesired(componentId, src, dst, baselineItem) {
82
- const desiredHash = sha256File(src);
83
- if (!fs.existsSync(dst)) return { state: baselineItem ? 'LOCAL_MISSING' : 'ADD', desiredHash, currentHash: null };
84
- const stat = fs.lstatSync(dst);
85
- if (!stat.isFile() || stat.isSymbolicLink()) return { state: 'PATH_CONFLICT', desiredHash, currentHash: null };
86
- const currentHash = sha256File(dst);
87
- if (!baselineItem) return { state: currentHash === desiredHash ? 'UNTRACKED_MATCH' : 'UNTRACKED_CONFLICT', desiredHash, currentHash };
88
- const baselineHash = baselineItem.sha256;
89
- const consumerChanged = currentHash !== baselineHash;
90
- const sourceChanged = desiredHash !== baselineHash;
91
- if (!consumerChanged && !sourceChanged) return { state: 'SAME', desiredHash, currentHash };
92
- if (!consumerChanged && sourceChanged) return { state: componentId === 'core' ? 'CORE_UPDATE' : 'KIT_UPDATE', desiredHash, currentHash };
93
- if (consumerChanged && !sourceChanged) return { state: 'LOCAL_DRIFT', desiredHash, currentHash };
94
- if (currentHash === desiredHash) return { state: 'BASELINE_STALE', desiredHash, currentHash };
95
- return { state: 'CONFLICT', desiredHash, currentHash };
96
- }
97
-
98
- function coreMetadataCurrent(baseline, component) {
99
- return baseline.schemaVersion === 1 && baseline.coreRepository === component.repository && baseline.coreVersion === component.version && baseline.coreCommit === component.commit && baseline.distribution === component.distribution && Array.isArray(baseline.managedPaths) && baseline.managedPaths.length > 0;
100
- }
101
-
102
- function deploymentMetadataCurrent(baseline, component) {
103
- return baseline.schemaVersion === 1 && baseline.deploymentKitRepository === component.repository && baseline.deploymentKitCommit === component.commit && baseline.distribution === component.distribution && Array.isArray(baseline.managedPaths) && baseline.managedPaths.length > 0;
104
- }
105
-
106
- export function planComponent(packageData, componentId, targetRoot, acceptedRetired = new Set()) {
107
- const component = packageData.assets.components[componentId];
108
- const filename = baselineFile(componentId);
109
- const baseline = loadOptionalJson(path.join(targetRoot, filename));
110
- if (Object.keys(baseline).length && baseline.schemaVersion !== 1) throw new Error(`Unsupported ${filename} schemaVersion`);
111
- if (componentId === 'core' && baseline.coreRepository && baseline.coreRepository !== component.repository) throw new Error(`${filename} records a different Core repository`);
112
- if (componentId === 'deployment' && baseline.deploymentKitRepository && baseline.deploymentKitRepository !== component.repository) throw new Error(`${filename} records a different Deployment Kit repository`);
113
- if (baseline.distribution && baseline.distribution !== component.distribution) throw new Error(`${filename} records a different distribution`);
114
-
115
- const entries = componentEntries(packageData, componentId);
116
- const baseMap = baselineMap(baseline, filename);
117
- const rows = entries.map((entry) => {
118
- const dst = resolveUnder(targetRoot, entry.path, 'consumer destination');
119
- return { ...entry, ...classifyDesired(componentId, entry.bundlePath, dst, baseMap.get(entry.path)) };
120
- });
121
- const desired = new Set(entries.map((entry) => entry.path));
122
- const retired = [];
123
- for (const [retiredPath, item] of [...baseMap.entries()].sort(([a], [b]) => a.localeCompare(b))) {
124
- if (desired.has(retiredPath)) continue;
125
- const dst = resolveUnder(targetRoot, retiredPath, 'retired destination');
126
- if (componentId === 'core') {
127
- retired.push({ path: retiredPath, item, state: acceptedRetired.has(retiredPath) ? 'RETIRED_ACCEPTED' : 'RETIRED_PATH' });
128
- continue;
129
- }
130
- if (!fs.existsSync(dst)) {
131
- retired.push({ path: retiredPath, item, state: 'RETIRED_MISSING' });
132
- } else {
133
- const stat = fs.lstatSync(dst);
134
- const currentHash = stat.isFile() && !stat.isSymbolicLink() ? sha256File(dst) : null;
135
- retired.push({ path: retiredPath, item, currentHash, state: currentHash === item.sha256 ? 'REMOVE' : 'RETIRED_DRIFT' });
136
- }
137
- }
138
- const safe = componentId === 'core' ? CORE_SAFE : DEPLOYMENT_SAFE;
139
- const unsafe = rows.filter((row) => !safe.has(row.state)).map((row) => ({ state: row.state, path: row.path }));
140
- for (const item of retired) {
141
- const isSafe = componentId === 'core' ? item.state === 'RETIRED_ACCEPTED' : safe.has(item.state);
142
- if (!isSafe) unsafe.push({ state: item.state, path: item.path });
143
- }
144
- return {
145
- componentId,
146
- component,
147
- filename,
148
- baseline,
149
- rows,
150
- retired,
151
- unsafe,
152
- metadataCurrent: componentId === 'core' ? coreMetadataCurrent(baseline, component) : deploymentMetadataCurrent(baseline, component)
153
- };
154
- }
155
-
156
- function buildComponentBaseline(plan, consumer, consumerBaseline) {
157
- const managedPaths = plan.rows.map((row) => ({ source: row.source, path: row.path, sha256: row.desiredHash }));
158
- if (plan.componentId === 'core') {
159
- return {
160
- schemaVersion: 1,
161
- consumer,
162
- consumerBaseline,
163
- coreRepository: plan.component.repository,
164
- coreVersion: plan.component.version,
165
- coreCommit: plan.component.commit,
166
- distribution: plan.component.distribution,
167
- managedPaths
168
- };
169
- }
170
- return {
171
- schemaVersion: 1,
172
- consumer,
173
- consumerBaseline,
174
- deploymentKitRepository: plan.component.repository,
175
- deploymentKitCommit: plan.component.commit,
176
- distribution: plan.component.distribution,
177
- managedPaths
178
- };
179
- }
180
-
181
- export function planProfile(packageData, profileName, targetRoot, acceptedRetired = new Map()) {
182
- const profile = packageData.assets.profiles[profileName];
183
- if (!profile) throw new Error(`Unknown Lean profile: ${profileName}`);
184
- const plans = profile.components.map((id) => planComponent(packageData, id, targetRoot, acceptedRetired.get(id) ?? new Set()));
185
- const destinations = new Map();
186
- for (const plan of plans) {
187
- for (const row of plan.rows) {
188
- const owner = destinations.get(row.path);
189
- if (owner) throw new Error(`Managed-path collision: ${row.path} is owned by both ${owner} and ${plan.componentId}`);
190
- destinations.set(row.path, plan.componentId);
191
- }
192
- }
193
- return { profileName, profile, plans, unsafe: plans.flatMap((plan) => plan.unsafe.map((item) => ({ component: plan.componentId, ...item }))) };
194
- }
195
-
196
- export function applyProfile(packageData, profilePlan, targetRoot, consumer, consumerBaseline) {
197
- if (profilePlan.unsafe.length) throw new Error('Refusing apply: plan contains unsafe drift/conflicts');
198
- for (const plan of profilePlan.plans) {
199
- for (const row of plan.rows) {
200
- if (!['ADD', 'CORE_UPDATE', 'KIT_UPDATE'].includes(row.state)) continue;
201
- const dst = resolveUnder(targetRoot, row.path, 'consumer destination');
202
- fs.mkdirSync(path.dirname(dst), { recursive: true });
203
- fs.copyFileSync(row.bundlePath, dst);
204
- }
205
- if (plan.componentId === 'deployment') {
206
- for (const retired of plan.retired) {
207
- if (retired.state === 'REMOVE') fs.unlinkSync(resolveUnder(targetRoot, retired.path, 'retired destination'));
208
- }
209
- }
210
- }
211
- for (const plan of profilePlan.plans) {
212
- const baseline = buildComponentBaseline(plan, consumer, consumerBaseline);
213
- fs.writeFileSync(path.join(targetRoot, plan.filename), JSON.stringify(baseline, null, 2) + '\n');
214
- }
215
- const leanBaseline = {
216
- schemaVersion: 1,
217
- package: packageData.pkg.name,
218
- version: packageData.pkg.version,
219
- profile: profilePlan.profileName,
220
- consumer,
221
- consumerBaseline,
222
- components: Object.fromEntries(profilePlan.plans.map((plan) => [plan.componentId, {
223
- repository: plan.component.repository,
224
- commit: plan.component.commit,
225
- ...(plan.component.version ? { version: plan.component.version } : {}),
226
- distribution: plan.component.distribution
227
- }]))
228
- };
229
- fs.writeFileSync(path.join(targetRoot, 'LEAN_BASELINE.json'), JSON.stringify(leanBaseline, null, 2) + '\n');
230
- return leanBaseline;
231
- }
232
-
233
- export function checkProfile(packageData, profileName, targetRoot) {
234
- const plan = planProfile(packageData, profileName, targetRoot);
235
- const lean = loadOptionalJson(path.join(targetRoot, 'LEAN_BASELINE.json'));
236
- const leanCurrent = lean.schemaVersion === 1 && lean.package === packageData.pkg.name && lean.version === packageData.pkg.version && lean.profile === profileName;
237
- const componentClean = plan.plans.every((component) => component.metadataCurrent && component.rows.every((row) => row.state === 'SAME') && component.retired.length === 0);
238
- return { clean: leanCurrent && componentClean, leanCurrent, plan, lean };
239
- }
240
-
241
- export function loadLeanBaseline(targetRoot) {
242
- return loadOptionalJson(path.join(targetRoot, 'LEAN_BASELINE.json'));
243
- }
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readJson } from './package-data.mjs';
5
+
6
+ const CORE_SAFE = new Set(['SAME', 'ADD', 'CORE_UPDATE', 'UNTRACKED_MATCH', 'BASELINE_STALE']);
7
+ const DEPLOYMENT_SAFE = new Set(['SAME', 'ADD', 'KIT_UPDATE', 'UNTRACKED_MATCH', 'BASELINE_STALE', 'REMOVE', 'RETIRED_MISSING']);
8
+
9
+ export function safeRelative(value, label = 'path') {
10
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be a non-empty relative path`);
11
+ const normalized = value.replaceAll('\\', '/');
12
+ if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) throw new Error(`Unsafe ${label}: ${value}`);
13
+ const parts = normalized.split('/');
14
+ if (parts.some((part) => !part || part === '.' || part === '..')) throw new Error(`Unsafe ${label}: ${value}`);
15
+ return normalized;
16
+ }
17
+
18
+ export function resolveUnder(root, relative, label = 'path') {
19
+ const rel = safeRelative(relative, label);
20
+ const base = path.resolve(root);
21
+ const candidate = path.resolve(base, ...rel.split('/'));
22
+ if (candidate !== base && !candidate.startsWith(base + path.sep)) throw new Error(`${label} escapes root: ${relative}`);
23
+ return candidate;
24
+ }
25
+
26
+ export function sha256File(file) {
27
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
28
+ }
29
+
30
+ function baselineFile(componentId) {
31
+ if (componentId === 'core') return 'CORE_BASELINE.json';
32
+ if (componentId === 'deployment') return 'DEPLOYMENT_BASELINE.json';
33
+ throw new Error(`Unsupported component: ${componentId}`);
34
+ }
35
+
36
+ function loadOptionalJson(file) {
37
+ if (!fs.existsSync(file)) return {};
38
+ const value = readJson(file);
39
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid JSON object: ${file}`);
40
+ return value;
41
+ }
42
+
43
+ function baselineMap(baseline, filename) {
44
+ const items = baseline.managedPaths ?? [];
45
+ if (Object.keys(baseline).length && (!Array.isArray(items) || items.length === 0)) {
46
+ throw new Error(`Invalid ${filename}: managedPaths is missing or empty`);
47
+ }
48
+ const map = new Map();
49
+ for (const item of items) {
50
+ if (!item?.path || !item?.sha256 || !item?.source) throw new Error(`Invalid managedPaths entry in ${filename}`);
51
+ if (map.has(item.path)) throw new Error(`Duplicate managed path in ${filename}: ${item.path}`);
52
+ map.set(item.path, item);
53
+ }
54
+ return map;
55
+ }
56
+
57
+ export function componentEntries(packageData, componentId) {
58
+ const component = packageData.assets.components[componentId];
59
+ if (!component) throw new Error(`Package does not define component ${componentId}`);
60
+ const descriptor = readJson(resolveUnder(packageData.root, component.descriptor, 'component descriptor'));
61
+ const mappings = [];
62
+ if (descriptor.profileSource && descriptor.profileDestination) {
63
+ mappings.push({ source: descriptor.profileSource, path: descriptor.profileDestination });
64
+ }
65
+ for (const item of descriptor.mappings ?? []) mappings.push({ source: item.source, path: item.destination });
66
+ if (!mappings.length) throw new Error(`${componentId} distribution has no mappings`);
67
+ const seen = new Set();
68
+ return mappings.map((entry) => {
69
+ safeRelative(entry.source, 'source');
70
+ safeRelative(entry.path, 'destination');
71
+ if (seen.has(entry.path)) throw new Error(`Duplicate ${componentId} destination: ${entry.path}`);
72
+ seen.add(entry.path);
73
+ const bundlePath = resolveUnder(packageData.root, `${component.sourceRoot}/${entry.source}`, 'bundled source');
74
+ if (!fs.existsSync(bundlePath) || !fs.statSync(bundlePath).isFile()) {
75
+ throw new Error(`Missing bundled ${componentId} source: ${entry.source}`);
76
+ }
77
+ return { ...entry, bundlePath };
78
+ });
79
+ }
80
+
81
+ function classifyDesired(componentId, src, dst, baselineItem) {
82
+ const desiredHash = sha256File(src);
83
+ if (!fs.existsSync(dst)) return { state: baselineItem ? 'LOCAL_MISSING' : 'ADD', desiredHash, currentHash: null };
84
+ const stat = fs.lstatSync(dst);
85
+ if (!stat.isFile() || stat.isSymbolicLink()) return { state: 'PATH_CONFLICT', desiredHash, currentHash: null };
86
+ const currentHash = sha256File(dst);
87
+ if (!baselineItem) return { state: currentHash === desiredHash ? 'UNTRACKED_MATCH' : 'UNTRACKED_CONFLICT', desiredHash, currentHash };
88
+ const baselineHash = baselineItem.sha256;
89
+ const consumerChanged = currentHash !== baselineHash;
90
+ const sourceChanged = desiredHash !== baselineHash;
91
+ if (!consumerChanged && !sourceChanged) return { state: 'SAME', desiredHash, currentHash };
92
+ if (!consumerChanged && sourceChanged) return { state: componentId === 'core' ? 'CORE_UPDATE' : 'KIT_UPDATE', desiredHash, currentHash };
93
+ if (consumerChanged && !sourceChanged) return { state: 'LOCAL_DRIFT', desiredHash, currentHash };
94
+ if (currentHash === desiredHash) return { state: 'BASELINE_STALE', desiredHash, currentHash };
95
+ return { state: 'CONFLICT', desiredHash, currentHash };
96
+ }
97
+
98
+ function coreMetadataCurrent(baseline, component) {
99
+ return baseline.schemaVersion === 1 && baseline.coreRepository === component.repository && baseline.coreVersion === component.version && baseline.coreCommit === component.commit && baseline.distribution === component.distribution && Array.isArray(baseline.managedPaths) && baseline.managedPaths.length > 0;
100
+ }
101
+
102
+ function deploymentMetadataCurrent(baseline, component) {
103
+ return baseline.schemaVersion === 1 && baseline.deploymentKitRepository === component.repository && baseline.deploymentKitCommit === component.commit && baseline.distribution === component.distribution && Array.isArray(baseline.managedPaths) && baseline.managedPaths.length > 0;
104
+ }
105
+
106
+ export function planComponent(packageData, componentId, targetRoot, acceptedRetired = new Set()) {
107
+ const component = packageData.assets.components[componentId];
108
+ const filename = baselineFile(componentId);
109
+ const baseline = loadOptionalJson(path.join(targetRoot, filename));
110
+ if (Object.keys(baseline).length && baseline.schemaVersion !== 1) throw new Error(`Unsupported ${filename} schemaVersion`);
111
+ if (componentId === 'core' && baseline.coreRepository && baseline.coreRepository !== component.repository) throw new Error(`${filename} records a different Core repository`);
112
+ if (componentId === 'deployment' && baseline.deploymentKitRepository && baseline.deploymentKitRepository !== component.repository) throw new Error(`${filename} records a different Deployment Kit repository`);
113
+ if (baseline.distribution && baseline.distribution !== component.distribution) throw new Error(`${filename} records a different distribution`);
114
+
115
+ const entries = componentEntries(packageData, componentId);
116
+ const baseMap = baselineMap(baseline, filename);
117
+ const rows = entries.map((entry) => {
118
+ const dst = resolveUnder(targetRoot, entry.path, 'consumer destination');
119
+ return { ...entry, ...classifyDesired(componentId, entry.bundlePath, dst, baseMap.get(entry.path)) };
120
+ });
121
+ const desired = new Set(entries.map((entry) => entry.path));
122
+ const retired = [];
123
+ for (const [retiredPath, item] of [...baseMap.entries()].sort(([a], [b]) => a.localeCompare(b))) {
124
+ if (desired.has(retiredPath)) continue;
125
+ const dst = resolveUnder(targetRoot, retiredPath, 'retired destination');
126
+ if (componentId === 'core') {
127
+ retired.push({ path: retiredPath, item, state: acceptedRetired.has(retiredPath) ? 'RETIRED_ACCEPTED' : 'RETIRED_PATH' });
128
+ continue;
129
+ }
130
+ if (!fs.existsSync(dst)) {
131
+ retired.push({ path: retiredPath, item, state: 'RETIRED_MISSING' });
132
+ } else {
133
+ const stat = fs.lstatSync(dst);
134
+ const currentHash = stat.isFile() && !stat.isSymbolicLink() ? sha256File(dst) : null;
135
+ retired.push({ path: retiredPath, item, currentHash, state: currentHash === item.sha256 ? 'REMOVE' : 'RETIRED_DRIFT' });
136
+ }
137
+ }
138
+ const safe = componentId === 'core' ? CORE_SAFE : DEPLOYMENT_SAFE;
139
+ const unsafe = rows.filter((row) => !safe.has(row.state)).map((row) => ({ state: row.state, path: row.path }));
140
+ for (const item of retired) {
141
+ const isSafe = componentId === 'core' ? item.state === 'RETIRED_ACCEPTED' : safe.has(item.state);
142
+ if (!isSafe) unsafe.push({ state: item.state, path: item.path });
143
+ }
144
+ return {
145
+ componentId,
146
+ component,
147
+ filename,
148
+ baseline,
149
+ rows,
150
+ retired,
151
+ unsafe,
152
+ metadataCurrent: componentId === 'core' ? coreMetadataCurrent(baseline, component) : deploymentMetadataCurrent(baseline, component)
153
+ };
154
+ }
155
+
156
+ function buildComponentBaseline(plan, consumer, consumerBaseline) {
157
+ const managedPaths = plan.rows.map((row) => ({ source: row.source, path: row.path, sha256: row.desiredHash }));
158
+ if (plan.componentId === 'core') {
159
+ return {
160
+ schemaVersion: 1,
161
+ consumer,
162
+ consumerBaseline,
163
+ coreRepository: plan.component.repository,
164
+ coreVersion: plan.component.version,
165
+ coreCommit: plan.component.commit,
166
+ distribution: plan.component.distribution,
167
+ managedPaths
168
+ };
169
+ }
170
+ return {
171
+ schemaVersion: 1,
172
+ consumer,
173
+ consumerBaseline,
174
+ deploymentKitRepository: plan.component.repository,
175
+ deploymentKitCommit: plan.component.commit,
176
+ distribution: plan.component.distribution,
177
+ managedPaths
178
+ };
179
+ }
180
+
181
+ export function planProfile(packageData, profileName, targetRoot, acceptedRetired = new Map()) {
182
+ const profile = packageData.assets.profiles[profileName];
183
+ if (!profile) throw new Error(`Unknown Lean profile: ${profileName}`);
184
+ const plans = profile.components.map((id) => planComponent(packageData, id, targetRoot, acceptedRetired.get(id) ?? new Set()));
185
+ const destinations = new Map();
186
+ for (const plan of plans) {
187
+ for (const row of plan.rows) {
188
+ const owner = destinations.get(row.path);
189
+ if (owner) throw new Error(`Managed-path collision: ${row.path} is owned by both ${owner} and ${plan.componentId}`);
190
+ destinations.set(row.path, plan.componentId);
191
+ }
192
+ }
193
+ return { profileName, profile, plans, unsafe: plans.flatMap((plan) => plan.unsafe.map((item) => ({ component: plan.componentId, ...item }))) };
194
+ }
195
+
196
+ export function applyProfile(packageData, profilePlan, targetRoot, consumer, consumerBaseline) {
197
+ if (profilePlan.unsafe.length) throw new Error('Refusing apply: plan contains unsafe drift/conflicts');
198
+ for (const plan of profilePlan.plans) {
199
+ for (const row of plan.rows) {
200
+ if (!['ADD', 'CORE_UPDATE', 'KIT_UPDATE'].includes(row.state)) continue;
201
+ const dst = resolveUnder(targetRoot, row.path, 'consumer destination');
202
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
203
+ fs.copyFileSync(row.bundlePath, dst);
204
+ }
205
+ if (plan.componentId === 'deployment') {
206
+ for (const retired of plan.retired) {
207
+ if (retired.state === 'REMOVE') fs.unlinkSync(resolveUnder(targetRoot, retired.path, 'retired destination'));
208
+ }
209
+ }
210
+ }
211
+ for (const plan of profilePlan.plans) {
212
+ const baseline = buildComponentBaseline(plan, consumer, consumerBaseline);
213
+ fs.writeFileSync(path.join(targetRoot, plan.filename), JSON.stringify(baseline, null, 2) + '\n');
214
+ }
215
+ const leanBaseline = {
216
+ schemaVersion: 1,
217
+ package: packageData.pkg.name,
218
+ version: packageData.pkg.version,
219
+ profile: profilePlan.profileName,
220
+ consumer,
221
+ consumerBaseline,
222
+ components: Object.fromEntries(profilePlan.plans.map((plan) => [plan.componentId, {
223
+ repository: plan.component.repository,
224
+ commit: plan.component.commit,
225
+ ...(plan.component.version ? { version: plan.component.version } : {}),
226
+ distribution: plan.component.distribution
227
+ }]))
228
+ };
229
+ fs.writeFileSync(path.join(targetRoot, 'LEAN_BASELINE.json'), JSON.stringify(leanBaseline, null, 2) + '\n');
230
+ return leanBaseline;
231
+ }
232
+
233
+ export function checkProfile(packageData, profileName, targetRoot) {
234
+ const plan = planProfile(packageData, profileName, targetRoot);
235
+ const lean = loadOptionalJson(path.join(targetRoot, 'LEAN_BASELINE.json'));
236
+ const leanCurrent = lean.schemaVersion === 1 && lean.package === packageData.pkg.name && lean.version === packageData.pkg.version && lean.profile === profileName;
237
+ const componentClean = plan.plans.every((component) => component.metadataCurrent && component.rows.every((row) => row.state === 'SAME') && component.retired.length === 0);
238
+ return { clean: leanCurrent && componentClean, leanCurrent, plan, lean };
239
+ }
240
+
241
+ export function loadLeanBaseline(targetRoot) {
242
+ return loadOptionalJson(path.join(targetRoot, 'LEAN_BASELINE.json'));
243
+ }
@@ -1,18 +1,18 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
-
5
- export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
-
7
- export function readJson(file) {
8
- return JSON.parse(fs.readFileSync(file, 'utf8'));
9
- }
10
-
11
- export function loadPackageData(root = PACKAGE_ROOT) {
12
- const pkg = readJson(path.join(root, 'package.json'));
13
- const assets = readJson(path.join(root, 'assets', 'components.json'));
14
- if (pkg.name !== assets.package || pkg.version !== assets.version) {
15
- throw new Error('package.json and assets/components.json version metadata do not match');
16
- }
17
- return { root, pkg, assets };
18
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
+
7
+ export function readJson(file) {
8
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
9
+ }
10
+
11
+ export function loadPackageData(root = PACKAGE_ROOT) {
12
+ const pkg = readJson(path.join(root, 'package.json'));
13
+ const assets = readJson(path.join(root, 'assets', 'components.json'));
14
+ if (pkg.name !== assets.package || pkg.version !== assets.version) {
15
+ throw new Error('package.json and assets/components.json version metadata do not match');
16
+ }
17
+ return { root, pkg, assets };
18
+ }