@topogram/cli 0.3.39 → 0.3.41

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.39",
3
+ "version": "0.3.41",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  getTemplateTrustStatus,
33
33
  implementationRequiresTrust,
34
34
  TEMPLATE_TRUST_FILE,
35
+ templateTrustRecoveryGuidance,
35
36
  validateProjectImplementationTrust,
36
37
  writeTemplateTrustRecord
37
38
  } from "./template-trust.js";
@@ -5951,6 +5952,15 @@ function diagnosticForTemplateCreateFailure(message, templateSpec, step) {
5951
5952
  step
5952
5953
  });
5953
5954
  }
5955
+ if (message.includes("unsupported symlink")) {
5956
+ return templateCheckDiagnostic({
5957
+ code: "template_symlink_unsupported",
5958
+ message,
5959
+ path: path.isAbsolute(templateSpec) ? templateSpec : null,
5960
+ suggestedFix: "Replace template symlinks with real files or directories, then rerun `topogram new` or `topogram template check`.",
5961
+ step
5962
+ });
5963
+ }
5954
5964
  return templateCheckDiagnostic({
5955
5965
  code: "template_create_failed",
5956
5966
  message,
@@ -5968,13 +5978,15 @@ function diagnosticForTemplateCreateFailure(message, templateSpec, step) {
5968
5978
  */
5969
5979
  function diagnosticForStarterCheckFailure(error, step, configPath) {
5970
5980
  const locFile = typeof error?.loc?.file === "string" ? error.loc.file : null;
5971
- const isTrust = error.message.includes(TEMPLATE_TRUST_FILE);
5981
+ const isTrust = error.message.includes(TEMPLATE_TRUST_FILE) ||
5982
+ error.message.includes("unsupported symlink") ||
5983
+ error.message.includes("must be under implementation/");
5972
5984
  return templateCheckDiagnostic({
5973
5985
  code: isTrust ? "template_trust_invalid" : "starter_check_failed",
5974
5986
  message: error.message,
5975
5987
  path: locFile || configPath,
5976
5988
  suggestedFix: isTrust
5977
- ? "Review implementation/ and run topogram trust template in the generated starter."
5989
+ ? templateTrustRecoveryGuidance(error.message)
5978
5990
  : "Fix the generated Topogram source or topogram.project.json so topogram check passes.",
5979
5991
  step
5980
5992
  });
@@ -6170,13 +6182,13 @@ function buildTemplateCheckPayload(templateSpec) {
6170
6182
  configDir: projectConfigInfo.configDir
6171
6183
  }
6172
6184
  : null;
6173
- if (implementationInfo && implementationRequiresTrust(implementationInfo)) {
6185
+ if (implementationInfo && implementationRequiresTrust(implementationInfo, projectConfigInfo.config)) {
6174
6186
  const trustStatus = getTemplateTrustStatus(implementationInfo, projectConfigInfo.config);
6175
6187
  const trustDiagnostics = trustStatus.issues.map((issue) => templateCheckDiagnostic({
6176
6188
  code: "template_trust_invalid",
6177
6189
  message: issue,
6178
6190
  path: trustStatus.trustPath,
6179
- suggestedFix: "Review implementation/ and run topogram trust template in the generated starter.",
6191
+ suggestedFix: templateTrustRecoveryGuidance(issue),
6180
6192
  step: "executable-implementation-trust"
6181
6193
  }));
6182
6194
  steps.push(templateCheckStep("executable-implementation-trust", trustStatus.ok, {
@@ -7796,7 +7808,7 @@ try {
7796
7808
  configPath: projectConfigInfo.configPath,
7797
7809
  configDir: projectConfigInfo.configDir
7798
7810
  };
7799
- if (implementationRequiresTrust(implementationInfo)) {
7811
+ if (implementationRequiresTrust(implementationInfo, projectConfigInfo.config)) {
7800
7812
  const trustRecord = writeTemplateTrustRecord(projectConfigInfo.configDir, projectConfigInfo.config);
7801
7813
  console.log(`Wrote ${TEMPLATE_TRUST_FILE} for ${trustRecord.implementation.module}.`);
7802
7814
  if (trustRecord.template.id) {
@@ -7853,7 +7865,10 @@ try {
7853
7865
  console.log(`Removed: ${filePath}`);
7854
7866
  }
7855
7867
  if (!status.ok) {
7856
- console.log("Run `topogram trust diff` to review implementation changes, then `topogram trust template` to trust the current files.");
7868
+ const guidance = templateTrustRecoveryGuidance(status.issues);
7869
+ if (guidance) {
7870
+ console.log(guidance);
7871
+ }
7857
7872
  }
7858
7873
  }
7859
7874
  process.exit(status.ok ? 0 : 1);
@@ -7882,6 +7897,12 @@ try {
7882
7897
  for (const issue of diff.status.issues) {
7883
7898
  console.log(`Issue: ${issue}`);
7884
7899
  }
7900
+ if (!diff.ok) {
7901
+ const guidance = templateTrustRecoveryGuidance(diff.status.issues);
7902
+ if (guidance) {
7903
+ console.log(guidance);
7904
+ }
7905
+ }
7885
7906
  } else {
7886
7907
  console.log(diff.ok ? "Template trust diff: no implementation changes." : "Template trust diff: review required");
7887
7908
  for (const file of diff.files) {
@@ -7906,7 +7927,10 @@ try {
7906
7927
  }
7907
7928
  if (!diff.ok) {
7908
7929
  console.log("");
7909
- console.log("After review, run `topogram trust template` to trust the current files.");
7930
+ const guidance = templateTrustRecoveryGuidance(diff.status.issues);
7931
+ if (guidance) {
7932
+ console.log(guidance);
7933
+ }
7910
7934
  }
7911
7935
  }
7912
7936
  process.exit(diff.ok ? 0 : 1);
@@ -32,6 +32,15 @@ const SURFACE_ORDER = new Map([
32
32
  ["native", 40]
33
33
  ]);
34
34
 
35
+ /**
36
+ * @param {string} templateId
37
+ * @param {string} relativePath
38
+ * @returns {string}
39
+ */
40
+ function unsupportedTemplateSymlinkMessage(templateId, relativePath) {
41
+ return `Template '${templateId}' contains unsupported symlink '${relativePath}'. Template packs must copy real files because Topogram records hashes for copied topogram/ and implementation/ content; symlinks can point outside the trusted template root. Replace the symlink with a real file or directory before running topogram new or topogram template check.`;
42
+ }
43
+
35
44
  /**
36
45
  * @typedef {Object} CreateNewProjectOptions
37
46
  * @property {string} targetPath
@@ -331,6 +340,34 @@ function readTemplateManifest(templateRoot) {
331
340
  return validateTemplateManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
332
341
  }
333
342
 
343
+ /**
344
+ * @param {string} root
345
+ * @param {string} currentDir
346
+ * @param {string} label
347
+ * @param {string} templateId
348
+ * @returns {void}
349
+ */
350
+ function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
351
+ const rootStat = fs.lstatSync(currentDir);
352
+ const relativeRoot = path.relative(root, currentDir).replace(/\\/g, "/") || label;
353
+ if (rootStat.isSymbolicLink()) {
354
+ throw new Error(unsupportedTemplateSymlinkMessage(templateId, relativeRoot));
355
+ }
356
+ if (!rootStat.isDirectory()) {
357
+ return;
358
+ }
359
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
360
+ const entryPath = path.join(currentDir, entry.name);
361
+ const relativePath = path.relative(root, entryPath).replace(/\\/g, "/");
362
+ if (entry.isSymbolicLink()) {
363
+ throw new Error(unsupportedTemplateSymlinkMessage(templateId, relativePath));
364
+ }
365
+ if (entry.isDirectory()) {
366
+ assertTemplateTreeHasNoSymlinks(root, entryPath, label, templateId);
367
+ }
368
+ }
369
+ }
370
+
334
371
  /**
335
372
  * @param {string} templateRoot
336
373
  * @returns {TemplateManifest}
@@ -339,19 +376,30 @@ function validateTemplateRoot(templateRoot) {
339
376
  const manifest = readTemplateManifest(templateRoot);
340
377
  const topogramRoot = path.join(templateRoot, "topogram");
341
378
  const projectConfigPath = path.join(templateRoot, "topogram.project.json");
379
+ if (fs.existsSync(topogramRoot) && fs.lstatSync(topogramRoot).isSymbolicLink()) {
380
+ throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "topogram"));
381
+ }
382
+ if (fs.existsSync(projectConfigPath) && fs.lstatSync(projectConfigPath).isSymbolicLink()) {
383
+ throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "topogram.project.json"));
384
+ }
342
385
  if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
343
386
  throw new Error(`Template '${manifest.id}' is missing topogram/.`);
344
387
  }
345
388
  if (!fs.existsSync(projectConfigPath) || !fs.statSync(projectConfigPath).isFile()) {
346
389
  throw new Error(`Template '${manifest.id}' is missing topogram.project.json.`);
347
390
  }
391
+ assertTemplateTreeHasNoSymlinks(templateRoot, topogramRoot, "topogram", manifest.id);
348
392
  if (manifest.includesExecutableImplementation) {
349
393
  const implementationRoot = path.join(templateRoot, "implementation");
394
+ if (fs.existsSync(implementationRoot) && fs.lstatSync(implementationRoot).isSymbolicLink()) {
395
+ throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "implementation"));
396
+ }
350
397
  if (!fs.existsSync(implementationRoot) || !fs.statSync(implementationRoot).isDirectory()) {
351
398
  throw new Error(
352
399
  `Template '${manifest.id}' declares executable implementation code but is missing implementation/.`
353
400
  );
354
401
  }
402
+ assertTemplateTreeHasNoSymlinks(templateRoot, implementationRoot, "implementation", manifest.id);
355
403
  } else {
356
404
  const implementationRoot = path.join(templateRoot, "implementation");
357
405
  if (fs.existsSync(implementationRoot) && fs.statSync(implementationRoot).isDirectory()) {
@@ -1129,6 +1177,9 @@ function collectFiles(root, currentDir, files) {
1129
1177
  continue;
1130
1178
  }
1131
1179
  const entryPath = path.join(currentDir, entry.name);
1180
+ if (entry.isSymbolicLink()) {
1181
+ throw new Error(`Template-owned files cannot include symlink '${path.relative(root, entryPath).replace(/\\/g, "/")}'. Template-owned files must be real files so Topogram can hash the exact content being trusted. Replace the symlink with a real file, then run topogram trust status, topogram trust diff, and topogram trust template after review.`);
1182
+ }
1132
1183
  if (entry.isDirectory()) {
1133
1184
  collectFiles(root, entryPath, files);
1134
1185
  continue;
@@ -19,6 +19,7 @@ export const TEMPLATE_TRUST_POLICY = "topogram-template-executable-implementatio
19
19
 
20
20
  const IGNORED_IMPLEMENTATION_ENTRIES = new Set([".DS_Store", "node_modules", ".tmp"]);
21
21
  const MAX_TEXT_DIFF_BYTES = 256 * 1024;
22
+ const TRUST_REVIEW_COMMANDS = "`topogram trust status`, `topogram trust diff`, and `topogram trust template`";
22
23
 
23
24
  /**
24
25
  * @param {string} parent
@@ -46,6 +47,45 @@ function normalizeRelativePath(value) {
46
47
  return value.replace(/\\/g, "/");
47
48
  }
48
49
 
50
+ /**
51
+ * @param {string} relativePath
52
+ * @returns {string}
53
+ */
54
+ function unsupportedImplementationSymlinkMessage(relativePath) {
55
+ return `Template implementation contains unsupported symlink '${relativePath}'. Implementation trust hashes real files under implementation/; symlinks can point outside the trusted root. Replace symlinks with real files under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
56
+ }
57
+
58
+ /**
59
+ * @param {string} modulePath
60
+ * @returns {string}
61
+ */
62
+ function implementationOutsideRootMessage(modulePath) {
63
+ return `Template implementation module '${modulePath}' must be under implementation/ for template-attached projects. Keep executable template code inside implementation/ so the trust record covers what topogram generate may load. Move the module back under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
64
+ }
65
+
66
+ /**
67
+ * @param {string|string[]} issueOrIssues
68
+ * @returns {string}
69
+ */
70
+ export function templateTrustRecoveryGuidance(issueOrIssues) {
71
+ const issues = Array.isArray(issueOrIssues) ? issueOrIssues : [issueOrIssues];
72
+ const text = issues.join("\n");
73
+ if (issues.length > 0 && issues.every((issue) =>
74
+ issue.includes("topogram trust status") &&
75
+ issue.includes("topogram trust diff") &&
76
+ issue.includes("topogram trust template")
77
+ )) {
78
+ return "";
79
+ }
80
+ if (text.includes("unsupported symlink")) {
81
+ return `Replace symlinks with real files under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
82
+ }
83
+ if (text.includes("must be under implementation/")) {
84
+ return `Keep executable template code under implementation/ so it can be hashed and trusted; move the module back under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
85
+ }
86
+ return `Run \`topogram trust status\` and \`topogram trust diff\` to review implementation changes; after review, run \`topogram trust template\` to trust the current files.`;
87
+ }
88
+
49
89
  /**
50
90
  * @param {string} value
51
91
  * @returns {string}
@@ -185,12 +225,16 @@ function collectImplementationFiles(implementationRoot, currentDir, files) {
185
225
  continue;
186
226
  }
187
227
  const entryPath = path.join(currentDir, entry.name);
228
+ const relativePath = normalizeRelativePath(path.relative(implementationRoot, entryPath));
229
+ if (entry.isSymbolicLink()) {
230
+ throw new Error(unsupportedImplementationSymlinkMessage(relativePath));
231
+ }
188
232
  if (entry.isDirectory()) {
189
233
  collectImplementationFiles(implementationRoot, entryPath, files);
190
234
  continue;
191
235
  }
192
236
  if (entry.isFile()) {
193
- files.push(normalizeRelativePath(path.relative(implementationRoot, entryPath)));
237
+ files.push(relativePath);
194
238
  }
195
239
  }
196
240
  }
@@ -250,17 +294,38 @@ export function implementationTrustFingerprint(config) {
250
294
  };
251
295
  }
252
296
 
297
+ /**
298
+ * @param {{ config: Record<string, any>, configDir: string }} implementationInfo
299
+ * @param {Record<string, any>|null} [projectConfig]
300
+ * @returns {boolean}
301
+ */
302
+ export function implementationRequiresTrust(implementationInfo, projectConfig = null) {
303
+ const fingerprint = implementationTrustFingerprint(implementationInfo.config);
304
+ const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
305
+ const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
306
+ return isSameOrInside(implementationRoot, modulePath) || projectHasTemplateAttachment(projectConfig);
307
+ }
308
+
253
309
  /**
254
310
  * @param {{ config: Record<string, any>, configDir: string }} implementationInfo
255
311
  * @returns {boolean}
256
312
  */
257
- export function implementationRequiresTrust(implementationInfo) {
313
+ function implementationModuleIsUnderRoot(implementationInfo) {
258
314
  const fingerprint = implementationTrustFingerprint(implementationInfo.config);
259
315
  const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
260
316
  const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
261
317
  return isSameOrInside(implementationRoot, modulePath);
262
318
  }
263
319
 
320
+ /**
321
+ * @param {Record<string, any>|null} projectConfig
322
+ * @returns {boolean}
323
+ */
324
+ function projectHasTemplateAttachment(projectConfig) {
325
+ const template = projectConfig?.template || null;
326
+ return Boolean(template?.id || template?.sourceSpec || template?.requested);
327
+ }
328
+
264
329
  /**
265
330
  * @param {string} configDir
266
331
  * @returns {TemplateTrustRecord|null}
@@ -312,6 +377,14 @@ export function writeTemplateTrustRecord(configDir, projectConfig) {
312
377
  if (!implementationConfig) {
313
378
  throw new Error("Cannot trust template implementation because topogram.project.json has no implementation config.");
314
379
  }
380
+ const implementationInfo = {
381
+ config: implementationConfig,
382
+ configDir
383
+ };
384
+ if (!implementationModuleIsUnderRoot(implementationInfo)) {
385
+ const implementation = implementationTrustFingerprint(implementationConfig);
386
+ throw new Error(implementationOutsideRootMessage(implementation.module));
387
+ }
315
388
  const implementation = implementationTrustFingerprint(implementationConfig);
316
389
  const record = buildTrustRecord(configDir, projectConfig, implementation);
317
390
  fs.writeFileSync(path.join(configDir, TEMPLATE_TRUST_FILE), `${JSON.stringify(record, null, 2)}\n`, "utf8");
@@ -329,8 +402,9 @@ export function assertTrustedImplementation(implementationInfo, projectConfig =
329
402
  return;
330
403
  }
331
404
  const firstIssue = status.issues[0] || "implementation trust is invalid";
405
+ const guidance = templateTrustRecoveryGuidance(firstIssue);
332
406
  throw new Error(
333
- `${firstIssue}. Review implementation/ and run 'topogram trust template' to trust the current files.`
407
+ guidance ? `${firstIssue}. ${guidance}` : firstIssue
334
408
  );
335
409
  }
336
410
 
@@ -393,7 +467,8 @@ function implementationReviewFile(configDir, relativePath, file) {
393
467
  * @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
468
  */
395
469
  export function getTemplateTrustStatus(implementationInfo, projectConfig = null) {
396
- if (!implementationRequiresTrust(implementationInfo)) {
470
+ const templateAttached = projectHasTemplateAttachment(projectConfig);
471
+ if (!implementationRequiresTrust(implementationInfo, projectConfig)) {
397
472
  return {
398
473
  ok: true,
399
474
  requiresTrust: false,
@@ -406,6 +481,7 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
406
481
  };
407
482
  }
408
483
  const fingerprint = implementationTrustFingerprint(implementationInfo.config);
484
+ const moduleInsideImplementation = implementationModuleIsUnderRoot(implementationInfo);
409
485
  const trustRecord = readTemplateTrustRecord(implementationInfo.configDir);
410
486
  const configLabel = implementationInfo.configPath || "topogram.project.json";
411
487
  const trustPath = path.join(implementationInfo.configDir, TEMPLATE_TRUST_FILE);
@@ -415,6 +491,10 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
415
491
  /** @type {{ trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }} */
416
492
  const contentStatus = { trustedDigest: null, currentDigest: null, added: [], removed: [], changed: [] };
417
493
 
494
+ if (templateAttached && !moduleInsideImplementation) {
495
+ issues.push(implementationOutsideRootMessage(fingerprint.module));
496
+ }
497
+
418
498
  if (!trustRecord) {
419
499
  issues.push(
420
500
  `Refusing to load executable implementation '${fingerprint.module}' from ${normalizeRoot(configLabel)} without ${TEMPLATE_TRUST_FILE}`
@@ -441,22 +521,29 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
441
521
  issues.push(`${TEMPLATE_TRUST_FILE} trusts template version '${trustRecord.template?.version}', but topogram.project.json declares '${projectTemplate.version}'`);
442
522
  }
443
523
 
444
- if (!trustRecord.content) {
524
+ if (!moduleInsideImplementation) {
525
+ // The module itself is outside the only supported trust root. Do not
526
+ // pretend the implementation/ content digest covers the executable code.
527
+ } else if (!trustRecord.content) {
445
528
  issues.push(`${TEMPLATE_TRUST_FILE} is missing implementation content hashes`);
446
529
  } else if (trustRecord.content.algorithm !== "sha256") {
447
530
  issues.push(`${TEMPLATE_TRUST_FILE} uses unsupported content hash algorithm '${trustRecord.content.algorithm}'`);
448
531
  } else {
449
- const currentContent = hashImplementationContent(implementationInfo.configDir);
450
- contentStatus.trustedDigest = trustRecord.content.digest;
451
- contentStatus.currentDigest = currentContent.digest;
452
- const trustedByPath = new Map((trustRecord.content.files || []).map((file) => [file.path, file]));
453
- const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
454
- const diff = diffContentFiles(trustedByPath, currentByPath);
455
- contentStatus.added = diff.added;
456
- contentStatus.removed = diff.removed;
457
- contentStatus.changed = diff.changed;
458
- if (trustRecord.content.digest !== currentContent.digest) {
459
- issues.push(`${TEMPLATE_TRUST_FILE} implementation content changed since it was last trusted`);
532
+ try {
533
+ const currentContent = hashImplementationContent(implementationInfo.configDir);
534
+ contentStatus.trustedDigest = trustRecord.content.digest;
535
+ contentStatus.currentDigest = currentContent.digest;
536
+ const trustedByPath = new Map((trustRecord.content.files || []).map((file) => [file.path, file]));
537
+ const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
538
+ const diff = diffContentFiles(trustedByPath, currentByPath);
539
+ contentStatus.added = diff.added;
540
+ contentStatus.removed = diff.removed;
541
+ contentStatus.changed = diff.changed;
542
+ if (trustRecord.content.digest !== currentContent.digest) {
543
+ issues.push(`${TEMPLATE_TRUST_FILE} implementation content changed since it was last trusted`);
544
+ }
545
+ } catch (error) {
546
+ issues.push(error instanceof Error ? error.message : String(error));
460
547
  }
461
548
  }
462
549
  }
@@ -494,7 +581,12 @@ export function getTemplateTrustDiff(implementationInfo, projectConfig = null) {
494
581
  if (!status.requiresTrust || !status.trustRecord?.content) {
495
582
  return { ok: status.ok, requiresTrust: status.requiresTrust, status, files: [] };
496
583
  }
497
- const currentContent = hashImplementationContent(implementationInfo.configDir);
584
+ let currentContent;
585
+ try {
586
+ currentContent = hashImplementationContent(implementationInfo.configDir);
587
+ } catch (_error) {
588
+ return { ok: false, requiresTrust: status.requiresTrust, status, files: [] };
589
+ }
498
590
  const trustedByPath = new Map((status.trustRecord.content.files || []).map((file) => [file.path, file]));
499
591
  const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
500
592
  /** @type {Array<{ path: string, kind: "added"|"removed"|"changed", trusted: { path: string, sha256: string|null, size: number|null }|null, current: { path: string, sha256: string|null, size: number|null, binary: boolean, diffOmitted: boolean }|null, binary: boolean, diffOmitted: boolean, unifiedDiff: string|null }>} */