@topogram/cli 0.3.39 → 0.3.40
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/package.json +1 -1
- package/src/cli.js +2 -2
- package/src/new-project.js +42 -0
- package/src/template-trust.js +48 -4
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -6170,7 +6170,7 @@ function buildTemplateCheckPayload(templateSpec) {
|
|
|
6170
6170
|
configDir: projectConfigInfo.configDir
|
|
6171
6171
|
}
|
|
6172
6172
|
: null;
|
|
6173
|
-
if (implementationInfo && implementationRequiresTrust(implementationInfo)) {
|
|
6173
|
+
if (implementationInfo && implementationRequiresTrust(implementationInfo, projectConfigInfo.config)) {
|
|
6174
6174
|
const trustStatus = getTemplateTrustStatus(implementationInfo, projectConfigInfo.config);
|
|
6175
6175
|
const trustDiagnostics = trustStatus.issues.map((issue) => templateCheckDiagnostic({
|
|
6176
6176
|
code: "template_trust_invalid",
|
|
@@ -7796,7 +7796,7 @@ try {
|
|
|
7796
7796
|
configPath: projectConfigInfo.configPath,
|
|
7797
7797
|
configDir: projectConfigInfo.configDir
|
|
7798
7798
|
};
|
|
7799
|
-
if (implementationRequiresTrust(implementationInfo)) {
|
|
7799
|
+
if (implementationRequiresTrust(implementationInfo, projectConfigInfo.config)) {
|
|
7800
7800
|
const trustRecord = writeTemplateTrustRecord(projectConfigInfo.configDir, projectConfigInfo.config);
|
|
7801
7801
|
console.log(`Wrote ${TEMPLATE_TRUST_FILE} for ${trustRecord.implementation.module}.`);
|
|
7802
7802
|
if (trustRecord.template.id) {
|
package/src/new-project.js
CHANGED
|
@@ -331,6 +331,34 @@ function readTemplateManifest(templateRoot) {
|
|
|
331
331
|
return validateTemplateManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/**
|
|
335
|
+
* @param {string} root
|
|
336
|
+
* @param {string} currentDir
|
|
337
|
+
* @param {string} label
|
|
338
|
+
* @param {string} templateId
|
|
339
|
+
* @returns {void}
|
|
340
|
+
*/
|
|
341
|
+
function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
|
|
342
|
+
const rootStat = fs.lstatSync(currentDir);
|
|
343
|
+
const relativeRoot = path.relative(root, currentDir).replace(/\\/g, "/") || label;
|
|
344
|
+
if (rootStat.isSymbolicLink()) {
|
|
345
|
+
throw new Error(`Template '${templateId}' contains unsupported symlink '${relativeRoot}'.`);
|
|
346
|
+
}
|
|
347
|
+
if (!rootStat.isDirectory()) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
351
|
+
const entryPath = path.join(currentDir, entry.name);
|
|
352
|
+
const relativePath = path.relative(root, entryPath).replace(/\\/g, "/");
|
|
353
|
+
if (entry.isSymbolicLink()) {
|
|
354
|
+
throw new Error(`Template '${templateId}' contains unsupported symlink '${relativePath}'.`);
|
|
355
|
+
}
|
|
356
|
+
if (entry.isDirectory()) {
|
|
357
|
+
assertTemplateTreeHasNoSymlinks(root, entryPath, label, templateId);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
334
362
|
/**
|
|
335
363
|
* @param {string} templateRoot
|
|
336
364
|
* @returns {TemplateManifest}
|
|
@@ -339,19 +367,30 @@ function validateTemplateRoot(templateRoot) {
|
|
|
339
367
|
const manifest = readTemplateManifest(templateRoot);
|
|
340
368
|
const topogramRoot = path.join(templateRoot, "topogram");
|
|
341
369
|
const projectConfigPath = path.join(templateRoot, "topogram.project.json");
|
|
370
|
+
if (fs.existsSync(topogramRoot) && fs.lstatSync(topogramRoot).isSymbolicLink()) {
|
|
371
|
+
throw new Error(`Template '${manifest.id}' contains unsupported symlink 'topogram'.`);
|
|
372
|
+
}
|
|
373
|
+
if (fs.existsSync(projectConfigPath) && fs.lstatSync(projectConfigPath).isSymbolicLink()) {
|
|
374
|
+
throw new Error(`Template '${manifest.id}' contains unsupported symlink 'topogram.project.json'.`);
|
|
375
|
+
}
|
|
342
376
|
if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
|
|
343
377
|
throw new Error(`Template '${manifest.id}' is missing topogram/.`);
|
|
344
378
|
}
|
|
345
379
|
if (!fs.existsSync(projectConfigPath) || !fs.statSync(projectConfigPath).isFile()) {
|
|
346
380
|
throw new Error(`Template '${manifest.id}' is missing topogram.project.json.`);
|
|
347
381
|
}
|
|
382
|
+
assertTemplateTreeHasNoSymlinks(templateRoot, topogramRoot, "topogram", manifest.id);
|
|
348
383
|
if (manifest.includesExecutableImplementation) {
|
|
349
384
|
const implementationRoot = path.join(templateRoot, "implementation");
|
|
385
|
+
if (fs.existsSync(implementationRoot) && fs.lstatSync(implementationRoot).isSymbolicLink()) {
|
|
386
|
+
throw new Error(`Template '${manifest.id}' contains unsupported symlink 'implementation'.`);
|
|
387
|
+
}
|
|
350
388
|
if (!fs.existsSync(implementationRoot) || !fs.statSync(implementationRoot).isDirectory()) {
|
|
351
389
|
throw new Error(
|
|
352
390
|
`Template '${manifest.id}' declares executable implementation code but is missing implementation/.`
|
|
353
391
|
);
|
|
354
392
|
}
|
|
393
|
+
assertTemplateTreeHasNoSymlinks(templateRoot, implementationRoot, "implementation", manifest.id);
|
|
355
394
|
} else {
|
|
356
395
|
const implementationRoot = path.join(templateRoot, "implementation");
|
|
357
396
|
if (fs.existsSync(implementationRoot) && fs.statSync(implementationRoot).isDirectory()) {
|
|
@@ -1129,6 +1168,9 @@ function collectFiles(root, currentDir, files) {
|
|
|
1129
1168
|
continue;
|
|
1130
1169
|
}
|
|
1131
1170
|
const entryPath = path.join(currentDir, entry.name);
|
|
1171
|
+
if (entry.isSymbolicLink()) {
|
|
1172
|
+
throw new Error(`Template-owned files cannot include symlink '${path.relative(root, entryPath).replace(/\\/g, "/")}'.`);
|
|
1173
|
+
}
|
|
1132
1174
|
if (entry.isDirectory()) {
|
|
1133
1175
|
collectFiles(root, entryPath, files);
|
|
1134
1176
|
continue;
|
package/src/template-trust.js
CHANGED
|
@@ -185,12 +185,16 @@ function collectImplementationFiles(implementationRoot, currentDir, files) {
|
|
|
185
185
|
continue;
|
|
186
186
|
}
|
|
187
187
|
const entryPath = path.join(currentDir, entry.name);
|
|
188
|
+
const relativePath = normalizeRelativePath(path.relative(implementationRoot, entryPath));
|
|
189
|
+
if (entry.isSymbolicLink()) {
|
|
190
|
+
throw new Error(`Template implementation contains unsupported symlink '${relativePath}'.`);
|
|
191
|
+
}
|
|
188
192
|
if (entry.isDirectory()) {
|
|
189
193
|
collectImplementationFiles(implementationRoot, entryPath, files);
|
|
190
194
|
continue;
|
|
191
195
|
}
|
|
192
196
|
if (entry.isFile()) {
|
|
193
|
-
files.push(
|
|
197
|
+
files.push(relativePath);
|
|
194
198
|
}
|
|
195
199
|
}
|
|
196
200
|
}
|
|
@@ -250,17 +254,38 @@ export function implementationTrustFingerprint(config) {
|
|
|
250
254
|
};
|
|
251
255
|
}
|
|
252
256
|
|
|
257
|
+
/**
|
|
258
|
+
* @param {{ config: Record<string, any>, configDir: string }} implementationInfo
|
|
259
|
+
* @param {Record<string, any>|null} [projectConfig]
|
|
260
|
+
* @returns {boolean}
|
|
261
|
+
*/
|
|
262
|
+
export function implementationRequiresTrust(implementationInfo, projectConfig = null) {
|
|
263
|
+
const fingerprint = implementationTrustFingerprint(implementationInfo.config);
|
|
264
|
+
const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
|
|
265
|
+
const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
|
|
266
|
+
return isSameOrInside(implementationRoot, modulePath) || projectHasTemplateAttachment(projectConfig);
|
|
267
|
+
}
|
|
268
|
+
|
|
253
269
|
/**
|
|
254
270
|
* @param {{ config: Record<string, any>, configDir: string }} implementationInfo
|
|
255
271
|
* @returns {boolean}
|
|
256
272
|
*/
|
|
257
|
-
|
|
273
|
+
function implementationModuleIsUnderRoot(implementationInfo) {
|
|
258
274
|
const fingerprint = implementationTrustFingerprint(implementationInfo.config);
|
|
259
275
|
const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
|
|
260
276
|
const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
|
|
261
277
|
return isSameOrInside(implementationRoot, modulePath);
|
|
262
278
|
}
|
|
263
279
|
|
|
280
|
+
/**
|
|
281
|
+
* @param {Record<string, any>|null} projectConfig
|
|
282
|
+
* @returns {boolean}
|
|
283
|
+
*/
|
|
284
|
+
function projectHasTemplateAttachment(projectConfig) {
|
|
285
|
+
const template = projectConfig?.template || null;
|
|
286
|
+
return Boolean(template?.id || template?.sourceSpec || template?.requested);
|
|
287
|
+
}
|
|
288
|
+
|
|
264
289
|
/**
|
|
265
290
|
* @param {string} configDir
|
|
266
291
|
* @returns {TemplateTrustRecord|null}
|
|
@@ -312,6 +337,14 @@ export function writeTemplateTrustRecord(configDir, projectConfig) {
|
|
|
312
337
|
if (!implementationConfig) {
|
|
313
338
|
throw new Error("Cannot trust template implementation because topogram.project.json has no implementation config.");
|
|
314
339
|
}
|
|
340
|
+
const implementationInfo = {
|
|
341
|
+
config: implementationConfig,
|
|
342
|
+
configDir
|
|
343
|
+
};
|
|
344
|
+
if (!implementationModuleIsUnderRoot(implementationInfo)) {
|
|
345
|
+
const implementation = implementationTrustFingerprint(implementationConfig);
|
|
346
|
+
throw new Error(`Template implementation module '${implementation.module}' must be under implementation/.`);
|
|
347
|
+
}
|
|
315
348
|
const implementation = implementationTrustFingerprint(implementationConfig);
|
|
316
349
|
const record = buildTrustRecord(configDir, projectConfig, implementation);
|
|
317
350
|
fs.writeFileSync(path.join(configDir, TEMPLATE_TRUST_FILE), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
@@ -393,7 +426,8 @@ function implementationReviewFile(configDir, relativePath, file) {
|
|
|
393
426
|
* @returns {{ ok: boolean, requiresTrust: boolean, trustPath: string, trustRecord: TemplateTrustRecord|null, template: { id: string|null, version: string|null, source: string|null, sourceSpec: string|null, requested: string|null, sourceRoot: string|null, catalog?: Record<string, any>|null, includesExecutableImplementation: boolean|null }, implementation: { id: string|null, module: string|null, export: string|null }, content: { trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }, issues: string[] }}
|
|
394
427
|
*/
|
|
395
428
|
export function getTemplateTrustStatus(implementationInfo, projectConfig = null) {
|
|
396
|
-
|
|
429
|
+
const templateAttached = projectHasTemplateAttachment(projectConfig);
|
|
430
|
+
if (!implementationRequiresTrust(implementationInfo, projectConfig)) {
|
|
397
431
|
return {
|
|
398
432
|
ok: true,
|
|
399
433
|
requiresTrust: false,
|
|
@@ -406,6 +440,7 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
|
|
|
406
440
|
};
|
|
407
441
|
}
|
|
408
442
|
const fingerprint = implementationTrustFingerprint(implementationInfo.config);
|
|
443
|
+
const moduleInsideImplementation = implementationModuleIsUnderRoot(implementationInfo);
|
|
409
444
|
const trustRecord = readTemplateTrustRecord(implementationInfo.configDir);
|
|
410
445
|
const configLabel = implementationInfo.configPath || "topogram.project.json";
|
|
411
446
|
const trustPath = path.join(implementationInfo.configDir, TEMPLATE_TRUST_FILE);
|
|
@@ -415,6 +450,12 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
|
|
|
415
450
|
/** @type {{ trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }} */
|
|
416
451
|
const contentStatus = { trustedDigest: null, currentDigest: null, added: [], removed: [], changed: [] };
|
|
417
452
|
|
|
453
|
+
if (templateAttached && !moduleInsideImplementation) {
|
|
454
|
+
issues.push(
|
|
455
|
+
`Template implementation module '${fingerprint.module}' must be under implementation/ for template-attached projects`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
418
459
|
if (!trustRecord) {
|
|
419
460
|
issues.push(
|
|
420
461
|
`Refusing to load executable implementation '${fingerprint.module}' from ${normalizeRoot(configLabel)} without ${TEMPLATE_TRUST_FILE}`
|
|
@@ -441,7 +482,10 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
|
|
|
441
482
|
issues.push(`${TEMPLATE_TRUST_FILE} trusts template version '${trustRecord.template?.version}', but topogram.project.json declares '${projectTemplate.version}'`);
|
|
442
483
|
}
|
|
443
484
|
|
|
444
|
-
if (!
|
|
485
|
+
if (!moduleInsideImplementation) {
|
|
486
|
+
// The module itself is outside the only supported trust root. Do not
|
|
487
|
+
// pretend the implementation/ content digest covers the executable code.
|
|
488
|
+
} else if (!trustRecord.content) {
|
|
445
489
|
issues.push(`${TEMPLATE_TRUST_FILE} is missing implementation content hashes`);
|
|
446
490
|
} else if (trustRecord.content.algorithm !== "sha256") {
|
|
447
491
|
issues.push(`${TEMPLATE_TRUST_FILE} uses unsupported content hash algorithm '${trustRecord.content.algorithm}'`);
|