@astryxdesign/cli 0.6.3-canary.d0d49a4 → 0.6.3-canary.db4e378

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 (206) hide show
  1. package/README.md +86 -72
  2. package/api/build/build.doc.mjs +3 -3
  3. package/api/build/build.test.mjs +14 -3
  4. package/api/build/build.type.d.mts +35 -1
  5. package/api/build/build.type.mjs +24 -3
  6. package/api/build/help/help.d.mts +8 -5
  7. package/api/build/help/help.mjs +56 -6
  8. package/api/component/component.doc.mjs +12 -3
  9. package/api/component/component.mjs +31 -2
  10. package/api/component/component.test.mjs +38 -0
  11. package/api/component/component.type.d.mts +16 -5
  12. package/api/component/component.type.mjs +13 -5
  13. package/api/component/detail/blocks/blocks.d.mts +2 -1
  14. package/api/component/detail/blocks/blocks.mjs +4 -3
  15. package/api/component/list/list.d.mts +0 -5
  16. package/api/component/list/list.mjs +37 -9
  17. package/api/discover/discover.doc.mjs +1 -1
  18. package/api/gap-report/gap-report.doc.mjs +7 -4
  19. package/api/init/init.doc.mjs +4 -0
  20. package/api/init/init.test.mjs +41 -1
  21. package/api/init/remove/remove.mjs +1 -1
  22. package/api/init/run/run.mjs +20 -10
  23. package/api/integration/add-contribution.component-names.test.mjs +120 -0
  24. package/api/integration/add-contribution.mjs +11 -0
  25. package/api/integration/add-contribution.test.mjs +80 -0
  26. package/api/integration/integrationAdd.doc.mjs +7 -4
  27. package/api/integration/integrationAddComponent.doc.mjs +1 -1
  28. package/api/integration/validate-integration.mjs +2 -0
  29. package/api/integration/validate-integration.test.mjs +91 -0
  30. package/api/integration/validateIntegration.doc.mjs +1 -1
  31. package/api/json/envelope-types.test.mjs +76 -0
  32. package/api/json/parseResponse.doc.mjs +2 -2
  33. package/api/layout/expand/expand.mjs +7 -5
  34. package/api/layout/expand/expand.path-safety.test.mjs +53 -0
  35. package/api/layout/grammar/grammar.mjs +2 -1
  36. package/api/layout/layoutExpand.doc.mjs +1 -1
  37. package/api/search/search-return-type.test.mjs +54 -0
  38. package/api/search/search.d.mts +2 -9
  39. package/api/search/search.doc.mjs +5 -1
  40. package/api/search/search.mjs +10 -3
  41. package/api/search/search.test.mjs +34 -0
  42. package/api/swizzle/copy/copy.mjs +28 -11
  43. package/api/swizzle/swizzle.doc.mjs +1 -1
  44. package/api/template/copy/copy.mjs +17 -23
  45. package/api/template/copy/copy.test.mjs +17 -0
  46. package/api/template/template.doc.mjs +2 -1
  47. package/api/theme/add/add.binary.test.mjs +91 -0
  48. package/api/theme/add/add.mjs +20 -3
  49. package/api/theme/add/add.staging.test.mjs +66 -0
  50. package/api/theme/build/build.mjs +114 -37
  51. package/api/theme/build/build.public-component-vars.test.mjs +1 -1
  52. package/api/theme/build/build.receipt-doc.test.mjs +111 -0
  53. package/api/theme/build/font-warning.mjs +3 -3
  54. package/api/theme/build/font-warning.test.mjs +5 -2
  55. package/api/theme/palette/generate/generate.mjs +7 -2
  56. package/api/theme/palette/generate/generate.test.mjs +96 -0
  57. package/api/theme/palette/generate/generator.mjs +8 -1
  58. package/api/theme/palette/generate/generator.test.mjs +10 -0
  59. package/api/theme/template/template.mjs +11 -2
  60. package/api/theme/template/template.test.mjs +15 -0
  61. package/api/theme/themeBuild.doc.mjs +7 -4
  62. package/api/theme/themeTemplate.doc.mjs +4 -1
  63. package/api/upgrade/_adapter.mjs +27 -4
  64. package/api/upgrade/list/list.mjs +2 -1
  65. package/api/upgrade/list/list.test.mjs +73 -0
  66. package/api/upgrade/status/status.mjs +2 -2
  67. package/api/upgrade/upgrade.doc.mjs +2 -1
  68. package/api/upgrade/upgrade.type.d.mts +4 -0
  69. package/api/upgrade/upgrade.type.mjs +1 -0
  70. package/assets/codemods/term-log.mjs +32 -8
  71. package/assets/codemods/term-log.test.mjs +19 -1
  72. package/assets/codemods/transform-prop.mjs +109 -0
  73. package/assets/codemods/transform-prop.test.mjs +95 -0
  74. package/assets/codemods/transforms/v0.0.14/__tests__/rename-status-variants.test.mjs +86 -165
  75. package/assets/codemods/transforms/v0.0.14/rename-status-variants.mjs +72 -210
  76. package/assets/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +83 -115
  77. package/assets/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +57 -186
  78. package/assets/codemods/transforms/v0.6.0/__tests__/next-codemods.test.mjs +47 -0
  79. package/assets/codemods/transforms/v0.6.0/rename-resizable-pixel-bounds.mjs +57 -10
  80. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaInlineRail.doc.mjs +14 -0
  81. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaInlineRail.tsx +61 -0
  82. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaOverscrollChaining.doc.mjs +14 -0
  83. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaOverscrollChaining.tsx +126 -0
  84. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaShowcase.doc.mjs +15 -0
  85. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaShowcase.tsx +86 -0
  86. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyGroupHeaders.doc.mjs +14 -0
  87. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyGroupHeaders.tsx +99 -0
  88. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyPassthrough.doc.mjs +14 -0
  89. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyPassthrough.tsx +122 -0
  90. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaTwoAxisBoard.doc.mjs +14 -0
  91. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaTwoAxisBoard.tsx +95 -0
  92. package/assets/templates/pages/table-tree/page.tsx +1704 -0
  93. package/assets/templates/pages/table-tree/template.doc.mjs +12 -0
  94. package/authoring/config/config.doc.mjs +1 -1
  95. package/authoring/config/debug-composition.test.mjs +92 -0
  96. package/authoring/config/type.ts +15 -3
  97. package/clients/cli/command-load-failure.test.mjs +83 -0
  98. package/clients/cli/commands/blog.mjs +23 -8
  99. package/clients/cli/commands/blog.test.mjs +42 -1
  100. package/clients/cli/commands/build-theme.ascii-output.test.mjs +161 -0
  101. package/clients/cli/commands/build-theme.flag-docs.test.mjs +110 -0
  102. package/clients/cli/commands/build-theme.mjs +5 -5
  103. package/clients/cli/commands/build-theme.path-safety.test.mjs +86 -1
  104. package/clients/cli/commands/build-theme.variants.test.mjs +76 -0
  105. package/clients/cli/commands/build.doc.mjs +4 -1
  106. package/clients/cli/commands/build.exit-codes-doc.test.mjs +50 -0
  107. package/clients/cli/commands/build.mjs +32 -47
  108. package/clients/cli/commands/build.playbook.test.mjs +75 -0
  109. package/clients/cli/commands/build.text-fields.test.mjs +40 -0
  110. package/clients/cli/commands/component/index.mjs +2 -7
  111. package/clients/cli/commands/component-package.test.mjs +46 -0
  112. package/clients/cli/commands/component-resolution.test.mjs +21 -0
  113. package/clients/cli/commands/component.test.mjs +19 -0
  114. package/clients/cli/commands/detail-levels.test.mjs +2 -2
  115. package/clients/cli/commands/discover.broken-integration.test.mjs +48 -0
  116. package/clients/cli/commands/discover.components-flag.test.mjs +97 -0
  117. package/clients/cli/commands/discover.doc.mjs +4 -2
  118. package/clients/cli/commands/discover.mjs +3 -3
  119. package/clients/cli/commands/discover.text-projection.test.mjs +103 -0
  120. package/clients/cli/commands/doctor-integration.package-json.test.mjs +53 -0
  121. package/clients/cli/commands/doctor.mjs +6 -16
  122. package/clients/cli/commands/doctor.test.mjs +42 -0
  123. package/clients/cli/commands/gap-report.doc.mjs +16 -5
  124. package/clients/cli/commands/gap-report.test.mjs +72 -0
  125. package/clients/cli/commands/hook/index.mjs +7 -17
  126. package/clients/cli/commands/hook.text-projection.test.mjs +45 -0
  127. package/clients/cli/commands/init.doc.mjs +19 -8
  128. package/clients/cli/commands/init.flag-help.test.mjs +153 -0
  129. package/clients/cli/commands/integration-add.controls.test.mjs +132 -0
  130. package/clients/cli/commands/integration-add.doc.mjs +21 -5
  131. package/clients/cli/commands/interactive-guard.test.mjs +101 -24
  132. package/clients/cli/commands/json-contract.test.mjs +33 -0
  133. package/clients/cli/commands/layout-check.doc.mjs +14 -3
  134. package/clients/cli/commands/layout-expand.doc.mjs +21 -4
  135. package/clients/cli/commands/layout.doc.mjs +2 -2
  136. package/clients/cli/commands/layout.mjs +21 -9
  137. package/clients/cli/commands/layout.path-help.test.mjs +33 -0
  138. package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
  139. package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
  140. package/clients/cli/commands/no-prompt-wording.test.mjs +94 -0
  141. package/clients/cli/commands/search.doc.mjs +6 -3
  142. package/clients/cli/commands/search.mjs +17 -7
  143. package/clients/cli/commands/search.test.mjs +75 -0
  144. package/clients/cli/commands/swizzle.doc.mjs +2 -1
  145. package/clients/cli/commands/swizzle.path-safety.test.mjs +42 -0
  146. package/clients/cli/commands/template.doc.mjs +28 -6
  147. package/clients/cli/commands/template.flag-help.test.mjs +117 -0
  148. package/clients/cli/commands/template.path-help.test.mjs +40 -0
  149. package/clients/cli/commands/theme-add.doc.mjs +2 -1
  150. package/clients/cli/commands/theme-build.doc.mjs +7 -6
  151. package/clients/cli/commands/theme-palette-generate.test.mjs +19 -0
  152. package/clients/cli/commands/theme-template.behavior.test.mjs +12 -0
  153. package/clients/cli/commands/theme-template.doc.mjs +1 -1
  154. package/clients/cli/commands/upgrade.ascii-output.test.mjs +87 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +17 -6
  156. package/clients/cli/commands/upgrade.flag-help.test.mjs +188 -0
  157. package/clients/cli/commands/upgrade.hook-output.test.mjs +88 -0
  158. package/clients/cli/index.mjs +13 -30
  159. package/clients/cli/latest-version-env.test.mjs +50 -0
  160. package/clients/cli/lib/cli-error.test.mjs +7 -0
  161. package/clients/cli/lib/component-format.mjs +9 -9
  162. package/clients/cli/lib/component-format.test.mjs +1 -1
  163. package/clients/cli/lib/define-command.mjs +32 -6
  164. package/clients/cli/lib/doc-text-ascii.test.mjs +82 -0
  165. package/clients/cli/lib/exit-codes.test.mjs +97 -0
  166. package/clients/cli/lib/hook-format.mjs +5 -5
  167. package/clients/cli/lib/json-shim.mjs +38 -2
  168. package/clients/cli/lib/json-shim.test.mjs +83 -0
  169. package/clients/cli/lib/manifest.d.ts +2 -0
  170. package/clients/cli/lib/manifest.mjs +22 -0
  171. package/clients/cli/lib/manifest.test.mjs +17 -0
  172. package/foundation/agent-docs/agent-docs.d.mts +4 -0
  173. package/foundation/agent-docs/agent-docs.mjs +69 -3
  174. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +266 -4
  175. package/foundation/config/integration-debug.test.mjs +28 -3
  176. package/foundation/config/project.mjs +123 -9
  177. package/foundation/config/project.test.mjs +126 -0
  178. package/foundation/discovery/template-adapter.d.mts +14 -5
  179. package/foundation/discovery/template-adapter.fixture-refs.test.mjs +248 -0
  180. package/foundation/discovery/template-adapter.integration-isolation.test.mjs +94 -0
  181. package/foundation/discovery/template-adapter.mjs +292 -25
  182. package/foundation/discovery/theming-targets.test.mjs +4 -0
  183. package/foundation/fs/module-loader.d.mts +1 -0
  184. package/foundation/fs/module-loader.mjs +50 -1
  185. package/foundation/fs/module-loader.stdout.test.mjs +332 -0
  186. package/foundation/fs/path-safety.d.mts +3 -2
  187. package/foundation/fs/path-safety.mjs +49 -19
  188. package/foundation/fs/path-safety.test.mjs +50 -0
  189. package/foundation/fs/publish-file-hardlink-unavailable.test.mjs +129 -94
  190. package/foundation/integrations/autolink.mjs +6 -1
  191. package/foundation/response/base.d.ts +8 -4
  192. package/foundation/response/error-codes.doc.mjs +1 -2
  193. package/foundation/response/error-codes.mjs +1 -1
  194. package/foundation/response/error-codes.test.mjs +83 -0
  195. package/foundation/response/json-contract.test.mjs +11 -0
  196. package/foundation/response/json.d.mts +4 -2
  197. package/foundation/response/json.mjs +8 -10
  198. package/foundation/response/response-types.doc.mjs +10 -10
  199. package/foundation/response/response-types.doc.test.mjs +158 -0
  200. package/foundation/xle/expand.d.mts +2 -0
  201. package/foundation/xle/expand.mjs +3 -2
  202. package/foundation/xle/expand.test.mjs +54 -0
  203. package/package.json +9 -9
  204. package/clients/cli/lib/update-check.mjs +0 -83
  205. package/clients/cli/lib/update-check.test.mjs +0 -137
  206. package/clients/cli/update-hint-commands.test.mjs +0 -54
@@ -21,6 +21,7 @@
21
21
 
22
22
  import {describe, it, expect} from 'vitest';
23
23
  import * as fs from 'node:fs';
24
+ import * as os from 'node:os';
24
25
  import * as path from 'node:path';
25
26
  import {fileURLToPath} from 'node:url';
26
27
  import {
@@ -70,6 +71,28 @@ describe('search leaf — envelope + ranking', () => {
70
71
  }, SLOW);
71
72
  });
72
73
 
74
+ describe('search leaf — per-domain result fields', () => {
75
+ it('carries import for components and hooks, title for docs, displayName and kind for templates', async () => {
76
+ const r = await search('theme', {cwd, limit: 60});
77
+ expect(new Set(r.data.results.map(res => res.domain))).toEqual(
78
+ new Set(SEARCH_DOMAINS),
79
+ );
80
+ for (const res of r.data.results) {
81
+ expect(typeof res.command).toBe('string');
82
+ expect(typeof res.description).toBe('string');
83
+ if (res.domain === 'component' || res.domain === 'hook') {
84
+ expect(res.import).toMatch(/\S/);
85
+ } else if (res.domain === 'doc') {
86
+ expect(res.title).toMatch(/\S/);
87
+ expect(res.command).toBe(`astryx docs ${res.name}`);
88
+ } else {
89
+ expect(res.displayName).toMatch(/\S/);
90
+ expect(['page', 'block']).toContain(res.kind);
91
+ }
92
+ }
93
+ }, SLOW);
94
+ });
95
+
73
96
  describe('search leaf — matchCount is the total, not the cap', () => {
74
97
  it('reports every match while `results` stays bounded by the limit', async () => {
75
98
  // The regression: `matchCount` used to be `results.length`, so a query
@@ -160,6 +183,17 @@ describe('search leaf — error paths (pinned)', () => {
160
183
  search('button', {cwd, type: /** @type {any} */ ('bogus')}),
161
184
  ).rejects.toMatchObject({code: 'ERR_INVALID_ARGUMENT'});
162
185
  }, SLOW);
186
+
187
+ it('throws ERR_CORE_NOT_FOUND when @astryxdesign/core cannot be found', async () => {
188
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-search-no-core-'));
189
+ try {
190
+ await expect(search('button', {cwd: empty})).rejects.toMatchObject({
191
+ code: 'ERR_CORE_NOT_FOUND',
192
+ });
193
+ } finally {
194
+ fs.rmSync(empty, {recursive: true, force: true});
195
+ }
196
+ }, SLOW);
163
197
  });
164
198
 
165
199
  describe('search leaf — limit validation (API matches the CLI contract)', () => {
@@ -159,6 +159,24 @@ function resolveOwners(coreDir, loadedIntegrations, name, coreIssuesUrl) {
159
159
  return owners;
160
160
  }
161
161
 
162
+ /**
163
+ * `assertWithin`, with an escape reported as ERR_PATH_TRAVERSAL.
164
+ * @param {string} target
165
+ * @param {string} cwd
166
+ * @param {{allowAbsolute?: boolean, label: string}} options
167
+ * @returns {string}
168
+ */
169
+ function confineWrite(target, cwd, options) {
170
+ try {
171
+ return assertWithin(target, cwd, options);
172
+ } catch (err) {
173
+ if (err instanceof PathSafetyError) {
174
+ throw new AstryxError(err.message, [], ERROR_CODES.ERR_PATH_TRAVERSAL);
175
+ }
176
+ throw err;
177
+ }
178
+ }
179
+
162
180
  /** @param {string} file */
163
181
  function isExcludedFromCopy(file) {
164
182
  return (
@@ -243,23 +261,22 @@ export async function swizzleCopy(component, options = {}) {
243
261
 
244
262
  const componentDir = owner.sourceDir;
245
263
 
246
- // Path-safety: --output must resolve inside cwd.
247
- let outputBase;
248
- try {
249
- outputBase = assertWithin(output, cwd, {label: 'output directory'});
250
- } catch (err) {
251
- if (err instanceof PathSafetyError) {
252
- throw new AstryxError(err.message, [], ERROR_CODES.ERR_PATH_TRAVERSAL);
253
- }
254
- throw err;
255
- }
264
+ // Path-safety: every write target resolves inside cwd, not just --output. An
265
+ // existing component directory or file may be a symlink out of cwd.
266
+ const outputBase = confineWrite(output, cwd, {label: 'output directory'});
256
267
  const outputDir = path.join(outputBase, dirName);
257
268
 
258
- // Pre-flight overwrite check before any mkdir/writeFile.
269
+ // Pre-flight path and overwrite checks before any mkdir/writeFile.
259
270
  const sourceFiles = fs.readdirSync(componentDir).filter(file => {
260
271
  if (isExcludedFromCopy(file)) return false;
261
272
  return fs.statSync(path.join(componentDir, file)).isFile();
262
273
  });
274
+ for (const file of sourceFiles) {
275
+ confineWrite(path.join(outputDir, file), cwd, {
276
+ allowAbsolute: true,
277
+ label: 'output file',
278
+ });
279
+ }
263
280
  const existingFiles = sourceFiles.filter(f =>
264
281
  fs.existsSync(path.join(outputDir, f)),
265
282
  );
@@ -80,7 +80,7 @@ export const doc = {
80
80
  },
81
81
  {
82
82
  code: 'ERR_PATH_TRAVERSAL',
83
- when: 'the component name contains a path separator or traversal, or output resolves outside cwd',
83
+ when: 'the component name contains a path separator or traversal, output resolves outside cwd, or an existing output file or directory is a symlink that resolves outside cwd',
84
84
  },
85
85
  {
86
86
  code: 'ERR_UNKNOWN_COMPONENT',
@@ -36,13 +36,22 @@ export function templateCopy(match, {targetPath, cwd, overwrite = false}) {
36
36
  );
37
37
  }
38
38
 
39
- // Path-safety: resolve the user-supplied targetPath relative to cwd,
40
- // rejecting absolute paths and any traversal that escapes the project
41
- // root. This guard runs BEFORE any mkdir/copyFile so we never create
42
- // directories outside the root just to fail on the file write.
43
- let resolvedTarget;
39
+ // If targetPath looks like a file (e.g. `./foo.tsx`), write directly to
40
+ // it. Previously this path was treated as a directory and the file was
41
+ // written as `./foo.tsx/page.tsx`, which is wrong and surprising.
42
+ const fileTarget = isFilePathArg(targetPath)
43
+ ? targetPath
44
+ : path.join(
45
+ targetPath,
46
+ match.type === 'block' ? path.basename(match.filePath) : 'page.tsx',
47
+ );
48
+
49
+ // Path-safety: the guard sees the file that will be written, not only its
50
+ // directory — a symlink at that name would otherwise carry the write
51
+ // outside the project root. Runs BEFORE any mkdir/write.
52
+ let outputFilePath;
44
53
  try {
45
- resolvedTarget = assertWithin(targetPath, cwd, {
54
+ outputFilePath = assertWithin(fileTarget, cwd, {
46
55
  label: 'template target path',
47
56
  });
48
57
  } catch (err) {
@@ -55,23 +64,8 @@ export function templateCopy(match, {targetPath, cwd, overwrite = false}) {
55
64
  }
56
65
  throw err;
57
66
  }
58
-
59
- // If targetPath looks like a file (e.g. `./foo.tsx`), write directly to
60
- // it. Previously this path was treated as a directory and the file was
61
- // written as `./foo.tsx/page.tsx`, which is wrong and surprising.
62
- let outputDir;
63
- let outputFileName;
64
- let outputFilePath;
65
- if (isFilePathArg(targetPath)) {
66
- outputDir = path.dirname(resolvedTarget);
67
- outputFileName = path.basename(resolvedTarget);
68
- outputFilePath = resolvedTarget;
69
- } else {
70
- outputDir = resolvedTarget;
71
- outputFileName =
72
- match.type === 'block' ? path.basename(match.filePath) : 'page.tsx';
73
- outputFilePath = path.join(outputDir, outputFileName);
74
- }
67
+ const outputDir = path.dirname(outputFilePath);
68
+ const outputFileName = path.basename(outputFilePath);
75
69
 
76
70
  // Refuse to clobber an existing file unless the caller opts in. The CLI has
77
71
  // its own pre-flight collision message, but the API is a public surface
@@ -48,4 +48,21 @@ describe('template.copy — overwrite + path safety', () => {
48
48
  ).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
49
49
  expect(fs.existsSync(path.join(dir, '..', 'escape.tsx'))).toBe(false);
50
50
  }, SLOW);
51
+
52
+ it('rejects a directory target whose page.tsx is a symlink leading outside cwd', async () => {
53
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'tmpl-copy-outside-'));
54
+ try {
55
+ const victim = path.join(outside, 'victim.tsx');
56
+ fs.writeFileSync(victim, 'OUTSIDE');
57
+ fs.mkdirSync(path.join(dir, 'dest'));
58
+ fs.symlinkSync(victim, path.join(dir, 'dest', 'page.tsx'));
59
+
60
+ await expect(
61
+ template('blank', {targetPath: './dest', overwrite: true, cwd: dir}),
62
+ ).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
63
+ expect(fs.readFileSync(victim, 'utf-8')).toBe('OUTSIDE');
64
+ } finally {
65
+ fs.rmSync(outside, {recursive: true, force: true});
66
+ }
67
+ }, SLOW);
51
68
  });
@@ -75,7 +75,8 @@ export const doc = {
75
75
  name: 'options.targetPath',
76
76
  type: 'string',
77
77
  description:
78
- 'Destination (relative to cwd) to scaffold the template into. Its presence switches a named lookup into a copy.',
78
+ 'Destination (relative to cwd) to scaffold the template into. Its presence switches a named lookup into a copy. ' +
79
+ 'A path that ends in .tsx, .ts, .jsx, .js, .mjs, .cjs, .css, .scss, .json, .md or .html is the file to write; any other path is a directory, which receives page.tsx for a page template or the block\'s own file name for a block.',
79
80
  },
80
81
  {
81
82
  name: 'options.overwrite',
@@ -0,0 +1,91 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as os from 'node:os';
6
+ import * as path from 'node:path';
7
+ import {themeAdd} from './add.mjs';
8
+
9
+ const HEADER = '// Copyright (c) Meta Platforms, Inc. and affiliates.\n\n';
10
+
11
+ let tmpDir;
12
+
13
+ /** Every byte value, so any lossy text decoding shows up as a changed file. */
14
+ const FONT_BYTES = Buffer.concat([
15
+ Buffer.from('wOF2'),
16
+ Buffer.from(Array.from({length: 256}, (_, byte) => byte)),
17
+ ]);
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-themeadd-binary-'));
21
+ fs.writeFileSync(
22
+ path.join(tmpDir, 'package.json'),
23
+ JSON.stringify({
24
+ name: 'consumer',
25
+ dependencies: {'@acme/themes': '^1.0.0'},
26
+ }),
27
+ );
28
+ const packageDir = path.join(tmpDir, 'node_modules', '@acme', 'themes');
29
+ const themeDir = path.join(packageDir, 'themes', 'ocean');
30
+ fs.mkdirSync(path.join(themeDir, 'fonts'), {recursive: true});
31
+ fs.writeFileSync(
32
+ path.join(packageDir, 'package.json'),
33
+ JSON.stringify({name: '@acme/themes', version: '1.0.0'}),
34
+ );
35
+ fs.writeFileSync(
36
+ path.join(packageDir, 'astryx.integration.mjs'),
37
+ "export default {themes: './themes'};\n",
38
+ );
39
+ fs.writeFileSync(
40
+ path.join(packageDir, 'themes', 'manifest.json'),
41
+ JSON.stringify({
42
+ version: 1,
43
+ themes: [
44
+ {
45
+ slug: 'ocean',
46
+ displayName: 'Ocean',
47
+ description: 'Integration theme that ships a font.',
48
+ maintained: true,
49
+ entry: 'oceanTheme.ts',
50
+ exportName: 'oceanTheme',
51
+ files: ['oceanTheme.ts', 'fonts/ocean.woff2'],
52
+ },
53
+ ],
54
+ }),
55
+ );
56
+ fs.writeFileSync(
57
+ path.join(themeDir, 'oceanTheme.ts'),
58
+ `${HEADER}export const oceanTheme = {};\n`,
59
+ );
60
+ fs.writeFileSync(path.join(themeDir, 'fonts', 'ocean.woff2'), FONT_BYTES);
61
+ });
62
+
63
+ afterEach(() => {
64
+ fs.rmSync(tmpDir, {recursive: true, force: true});
65
+ });
66
+
67
+ describe('themeAdd copies every listed file', () => {
68
+ it('copies a binary file byte for byte', async () => {
69
+ const result = await themeAdd('ocean', {
70
+ cwd: tmpDir,
71
+ package: '@acme/themes',
72
+ });
73
+
74
+ expect(result.data.files).toEqual(['oceanTheme.ts', 'fonts/ocean.woff2']);
75
+ const copied = fs.readFileSync(
76
+ path.join(tmpDir, 'src', 'themes', 'ocean', 'fonts', 'ocean.woff2'),
77
+ );
78
+ expect(copied.equals(FONT_BYTES)).toBe(true);
79
+ });
80
+
81
+ it('still strips the repo header from a text source', async () => {
82
+ await themeAdd('ocean', {cwd: tmpDir, package: '@acme/themes'});
83
+
84
+ expect(
85
+ fs.readFileSync(
86
+ path.join(tmpDir, 'src', 'themes', 'ocean', 'oceanTheme.ts'),
87
+ 'utf-8',
88
+ ),
89
+ ).toBe('export const oceanTheme = {};\n');
90
+ });
91
+ });
@@ -26,6 +26,20 @@ function defaultTargetDir(slug) {
26
26
  return path.join('src', 'themes', slug);
27
27
  }
28
28
 
29
+ /**
30
+ * The bytes to write for one listed file. Only UTF-8 text loses our header;
31
+ * anything else (a font, an image) is copied verbatim, since decoding it would
32
+ * corrupt it.
33
+ * @param {Buffer} bytes
34
+ * @returns {Buffer | string}
35
+ */
36
+ function scaffoldContents(bytes) {
37
+ const text = bytes.toString('utf-8');
38
+ return Buffer.from(text, 'utf-8').equals(bytes)
39
+ ? stripCopyrightHeader(text)
40
+ : bytes;
41
+ }
42
+
29
43
  /**
30
44
  * Copy a bundled theme's files into the consumer's project (defaults to
31
45
  * `src/themes/<slug>/`). Writes are staged to temp files then renamed, rolling
@@ -134,9 +148,12 @@ export async function themeAdd(slug, options = {}) {
134
148
  label: `theme destination for ${w.name}`,
135
149
  });
136
150
  fs.mkdirSync(path.dirname(dest), {recursive: true});
137
- const tmp = `${dest}.${process.pid}.tmp`;
138
- const contents = stripCopyrightHeader(fs.readFileSync(w.src, 'utf-8'));
139
- fs.writeFileSync(tmp, contents);
151
+ // The staging file is an output path too: confine it, and never write
152
+ // through an entry that already exists under its name.
153
+ const tmp = assertWithin(`${w.name}.${process.pid}.tmp`, resolvedDir, {
154
+ label: `theme staging file for ${w.name}`,
155
+ });
156
+ fs.writeFileSync(tmp, scaffoldContents(fs.readFileSync(w.src)), {flag: 'wx'});
140
157
  staged.push({tmp, dest});
141
158
  }
142
159
  for (const s of staged) {
@@ -0,0 +1,66 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as os from 'node:os';
6
+ import * as path from 'node:path';
7
+ import {themeAdd} from './add.mjs';
8
+ import {listThemes} from '../_adapter.mjs';
9
+ import {isErrorCode} from '../../../foundation/response/error-codes.mjs';
10
+
11
+ let tmpDir;
12
+ let outsideDir;
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-themeadd-staging-'));
16
+ outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-themeadd-outside-'));
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(tmpDir, {recursive: true, force: true});
21
+ fs.rmSync(outsideDir, {recursive: true, force: true});
22
+ });
23
+
24
+ /**
25
+ * Put a symlink where `theme add` stages the first file of `slug`.
26
+ * @param {string} slug
27
+ * @param {string} target
28
+ */
29
+ function plantStagingLink(slug, target) {
30
+ const theme = listThemes().find(entry => entry.slug === slug);
31
+ if (!theme) throw new Error(`missing bundled theme ${slug}`);
32
+ const first = theme.files[0];
33
+ const dest = path.join(tmpDir, 'src', 'themes', slug, first);
34
+ fs.mkdirSync(path.dirname(dest), {recursive: true});
35
+ fs.symlinkSync(target, `${dest}.${process.pid}.tmp`);
36
+ return dest;
37
+ }
38
+
39
+ describe('themeAdd staging writes stay inside the project', () => {
40
+ it('refuses a staging path that links outside the project', async () => {
41
+ const victim = path.join(outsideDir, 'victim.txt');
42
+ fs.writeFileSync(victim, 'outside\n');
43
+ const dest = plantStagingLink('stone', victim);
44
+
45
+ await expect(themeAdd('stone', {cwd: tmpDir})).rejects.toMatchObject({
46
+ code: 'ERR_PATH_TRAVERSAL',
47
+ });
48
+ expect(fs.readFileSync(victim, 'utf-8')).toBe('outside\n');
49
+ expect(fs.existsSync(dest)).toBe(false);
50
+ });
51
+
52
+ it('never creates a file through a dangling staging link', async () => {
53
+ const victim = path.join(outsideDir, 'created.txt');
54
+ plantStagingLink('stone', victim);
55
+
56
+ let error;
57
+ try {
58
+ await themeAdd('stone', {cwd: tmpDir});
59
+ } catch (caught) {
60
+ error = caught;
61
+ }
62
+
63
+ expect(isErrorCode(error?.code)).toBe(true);
64
+ expect(fs.existsSync(victim)).toBe(false);
65
+ });
66
+ });