@astryxdesign/cli 0.7.2 → 0.7.3-canary.06872b4

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 (212) hide show
  1. package/README.md +99 -66
  2. package/api/build/_adapter.d.mts +58 -0
  3. package/api/build/_adapter.mjs +115 -3
  4. package/api/build/blocks/blocks.d.mts +42 -0
  5. package/api/build/blocks/blocks.mjs +169 -0
  6. package/api/build/blocks/blocks.test.mjs +108 -0
  7. package/api/build/blocks/blocks.weights.json +1 -0
  8. package/api/build/build.mjs +25 -6
  9. package/api/build/kit/answer.d.mts +14 -0
  10. package/api/build/kit/answer.mjs +85 -0
  11. package/api/build/kit/answer.test.mjs +38 -0
  12. package/api/build/kit/kind.d.mts +25 -0
  13. package/api/build/kit/kind.mjs +170 -0
  14. package/api/build/kit/kit.d.mts +2 -4
  15. package/api/build/kit/kit.mjs +8 -398
  16. package/api/build/kit/rank.d.mts +3 -32
  17. package/api/build/kit/rank.mjs +25 -342
  18. package/api/build/kit/rank.test.mjs +2 -1
  19. package/api/build/kit/signals.d.mts +60 -0
  20. package/api/build/kit/signals.mjs +284 -0
  21. package/api/build/kit/start.d.mts +32 -0
  22. package/api/build/kit/start.mjs +163 -0
  23. package/api/build/kit/words.d.mts +72 -0
  24. package/api/build/kit/words.mjs +202 -0
  25. package/api/build/tree.d.mts +20 -0
  26. package/api/build/tree.mjs +52 -0
  27. package/api/build/tree.test.mjs +177 -0
  28. package/api/build/tree.weights.json +1 -0
  29. package/api/component/component.mjs +16 -2
  30. package/api/component/component.test.mjs +15 -0
  31. package/api/discover/_adapter.mjs +36 -9
  32. package/api/discover/_adapter.test.mjs +42 -0
  33. package/api/docs/_adapter.mjs +17 -5
  34. package/api/docs/docs.test.mjs +7 -1
  35. package/api/doctor/doctor.mjs +86 -12
  36. package/api/doctor/doctor.test.mjs +28 -3
  37. package/api/init/init.doc.mjs +20 -6
  38. package/api/init/init.test.mjs +18 -2
  39. package/api/init/init.type.d.mts +3 -3
  40. package/api/init/init.type.mjs +3 -3
  41. package/api/init/remove/remove.mjs +17 -4
  42. package/api/init/remove/remove.test.mjs +114 -2
  43. package/api/init/run/run.mjs +69 -6
  44. package/api/integration/authoring-checks.mjs +83 -9
  45. package/api/integration/pack-check.mjs +123 -4
  46. package/api/integration/pack-check.test.mjs +87 -0
  47. package/api/integration/validate-integration-case-disk.test.mjs +111 -0
  48. package/api/integration/validate-integration-gaps.test.mjs +1964 -0
  49. package/api/integration/validate-integration.d.mts +11 -2
  50. package/api/integration/validate-integration.mjs +226 -32
  51. package/api/integration/validate-integration.type.d.mts +58 -1
  52. package/api/integration/validate-integration.type.mjs +45 -2
  53. package/api/integration/validateIntegration.doc.mjs +1 -1
  54. package/api/search/ORDERING.md +28 -0
  55. package/api/search/_adapter.d.mts +66 -2
  56. package/api/search/_adapter.mjs +118 -10
  57. package/api/search/_candidate-score.d.mts +40 -0
  58. package/api/search/_candidate-score.mjs +238 -0
  59. package/api/search/_cards.json +6098 -0
  60. package/api/search/_compound.d.mts +50 -0
  61. package/api/search/_compound.mjs +170 -0
  62. package/api/search/_doc-searcher.d.mts +4 -0
  63. package/api/search/_doc-searcher.mjs +23 -0
  64. package/api/search/_doc-searcher.test.mjs +36 -0
  65. package/api/search/_doc-searcher.weights.json +1 -0
  66. package/api/search/_doc-text.d.mts +30 -0
  67. package/api/search/_doc-text.mjs +87 -0
  68. package/api/search/_entries.d.mts +52 -0
  69. package/api/search/_entries.mjs +158 -0
  70. package/api/search/_features.d.mts +35 -0
  71. package/api/search/_features.mjs +155 -0
  72. package/api/search/_gather-components.d.mts +26 -0
  73. package/api/search/_gather-components.mjs +284 -0
  74. package/api/search/_gather-docs.d.mts +20 -0
  75. package/api/search/_gather-docs.mjs +254 -0
  76. package/api/search/_gather-hooks.d.mts +12 -0
  77. package/api/search/_gather-hooks.mjs +52 -0
  78. package/api/search/_gather-templates.d.mts +25 -0
  79. package/api/search/_gather-templates.mjs +107 -0
  80. package/api/search/_lexical.d.mts +135 -0
  81. package/api/search/_lexical.mjs +351 -0
  82. package/api/search/_main-searcher.d.mts +19 -0
  83. package/api/search/_main-searcher.mjs +115 -0
  84. package/api/search/_main-searcher.test.mjs +106 -0
  85. package/api/search/_main-searcher.weights.json +1 -0
  86. package/api/search/_module-doc.d.mts +32 -0
  87. package/api/search/_module-doc.mjs +78 -0
  88. package/api/search/_order.d.mts +32 -0
  89. package/api/search/_order.mjs +162 -0
  90. package/api/search/_order.test.mjs +72 -0
  91. package/api/search/_priority.d.mts +50 -0
  92. package/api/search/_priority.mjs +139 -0
  93. package/api/search/_query-words.d.mts +25 -0
  94. package/api/search/_query-words.mjs +92 -0
  95. package/api/search/_query.d.mts +65 -0
  96. package/api/search/_query.mjs +176 -0
  97. package/api/search/_result.d.mts +97 -0
  98. package/api/search/_result.mjs +150 -0
  99. package/api/search/_score.d.mts +60 -0
  100. package/api/search/_score.mjs +283 -0
  101. package/api/search/_synonyms.d.mts +21 -0
  102. package/api/search/_synonyms.mjs +67 -0
  103. package/api/search/_template-searcher.d.mts +4 -0
  104. package/api/search/_template-searcher.mjs +23 -0
  105. package/api/search/_template-searcher.test.mjs +40 -0
  106. package/api/search/_template-searcher.weights.json +1 -0
  107. package/api/search/_titles.d.mts +33 -0
  108. package/api/search/_titles.mjs +65 -0
  109. package/api/search/_words.d.mts +64 -0
  110. package/api/search/_words.mjs +157 -0
  111. package/api/search/search.d.mts +22 -305
  112. package/api/search/search.doc.mjs +7 -5
  113. package/api/search/search.mjs +53 -2483
  114. package/api/search/search.type.d.mts +6 -1
  115. package/api/search/search.type.mjs +6 -1
  116. package/api/search/tree.d.mts +15 -0
  117. package/api/search/tree.mjs +40 -0
  118. package/api/search/tree.test.mjs +66 -0
  119. package/api/search/tree.weights.json +1 -0
  120. package/api/theme/build/build.packed-old-core.test.mjs +1 -0
  121. package/api/upgrade/_adapter.d.mts +57 -9
  122. package/api/upgrade/_adapter.mjs +198 -28
  123. package/api/upgrade/registry/registry.d.mts +10 -4
  124. package/api/upgrade/registry/registry.mjs +33 -16
  125. package/api/upgrade/registry/registry.test.mjs +85 -0
  126. package/api/upgrade/run/run.d.mts +4 -2
  127. package/api/upgrade/run/run.mjs +13 -2
  128. package/api/upgrade/run/run.test.mjs +417 -3
  129. package/api/upgrade/upgrade.doc.mjs +10 -5
  130. package/api/upgrade/upgrade.mjs +13 -2
  131. package/api/upgrade/upgrade.test.mjs +194 -0
  132. package/api/upgrade/upgrade.type.d.mts +5 -3
  133. package/api/upgrade/upgrade.type.mjs +8 -6
  134. package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
  135. package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
  136. package/assets/docs/is-my-code-good.doc.mjs +287 -0
  137. package/assets/docs/tree/api.doc.mjs +6 -0
  138. package/assets/docs/tree/host-entry.doc.mjs +128 -0
  139. package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
  140. package/assets/docs/tree/tokens-data.doc.mjs +2 -2
  141. package/assets/docs/working-with-ai.doc.mjs +6 -2
  142. package/assets/templates/themes/butter/butterTheme.ts +4 -2
  143. package/assets/templates/themes/stone/stoneTheme.ts +4 -2
  144. package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
  145. package/authoring/debug/parse.d.mts +1 -1
  146. package/clients/cli/bin/astryx.mjs +7 -78
  147. package/clients/cli/commands/doctor.mjs +30 -0
  148. package/clients/cli/commands/doctor.test.mjs +65 -0
  149. package/clients/cli/commands/init.behavior.test.mjs +230 -1
  150. package/clients/cli/commands/init.doc.mjs +28 -9
  151. package/clients/cli/commands/init.flag-help.test.mjs +1 -0
  152. package/clients/cli/commands/init.mjs +2 -2
  153. package/clients/cli/commands/outside-project.test.mjs +2 -1
  154. package/clients/cli/commands/setup-nudge.test.mjs +61 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +4 -2
  156. package/clients/cli/commands/upgrade.mjs +9 -2
  157. package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
  158. package/clients/cli/index.mjs +19 -1
  159. package/clients/cli/run.mjs +108 -0
  160. package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
  161. package/foundation/agent-docs/agent-doc-state.mjs +35 -8
  162. package/foundation/agent-docs/agent-docs.d.mts +100 -12
  163. package/foundation/agent-docs/agent-docs.mjs +1010 -99
  164. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
  165. package/foundation/agent-docs/agent-docs.test.mjs +678 -35
  166. package/foundation/config/project.mjs +20 -22
  167. package/foundation/config/project.test.mjs +56 -0
  168. package/foundation/discovery/component-discovery.d.mts +37 -0
  169. package/foundation/discovery/component-discovery.mjs +80 -5
  170. package/foundation/discovery/component-loader.d.mts +1 -1
  171. package/foundation/discovery/docs-discovery.mjs +14 -1
  172. package/foundation/discovery/module-exports.d.mts +41 -0
  173. package/foundation/discovery/module-exports.mjs +253 -0
  174. package/foundation/discovery/template-adapter.mjs +18 -1
  175. package/foundation/discovery/template-needs.mjs +1 -44
  176. package/foundation/discovery/theme-discovery.mjs +147 -3
  177. package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
  178. package/foundation/doc-compiler/read.mjs +59 -1
  179. package/foundation/doc-compiler/read.test.mjs +11 -2
  180. package/foundation/fs/paths.d.mts +70 -8
  181. package/foundation/fs/paths.mjs +556 -38
  182. package/foundation/fs/paths.test.mjs +430 -54
  183. package/foundation/integrations/autolink.d.mts +2 -1
  184. package/foundation/integrations/autolink.mjs +4 -13
  185. package/foundation/integrations/computer-integrations.d.mts +6 -3
  186. package/foundation/integrations/computer-integrations.mjs +38 -9
  187. package/foundation/integrations/contribution-inventory.d.mts +15 -20
  188. package/foundation/integrations/contribution-inventory.mjs +41 -5
  189. package/foundation/integrations/integrations.d.mts +27 -1
  190. package/foundation/integrations/integrations.mjs +61 -2
  191. package/foundation/integrations/validate-contributions.d.mts +115 -0
  192. package/foundation/integrations/validate-contributions.mjs +1168 -13
  193. package/foundation/release/handoff.d.mts +23 -4
  194. package/foundation/release/handoff.mjs +175 -8
  195. package/foundation/release/run-context.mjs +14 -0
  196. package/foundation/response/response-types.doc.mjs +1 -1
  197. package/foundation/route/route.d.mts +285 -0
  198. package/foundation/route/route.mjs +324 -0
  199. package/foundation/route/route.test.mjs +153 -0
  200. package/foundation/runtime/hosted-runtime.test.mjs +403 -0
  201. package/foundation/runtime/locked-versions.d.mts +32 -0
  202. package/foundation/runtime/locked-versions.mjs +65 -0
  203. package/foundation/runtime/package-root.d.mts +91 -0
  204. package/foundation/runtime/package-root.mjs +174 -0
  205. package/foundation/runtime/package-set.d.mts +62 -0
  206. package/foundation/runtime/package-set.mjs +130 -0
  207. package/foundation/runtime/package-set.test.mjs +432 -0
  208. package/host/door-conformance.test.mjs +274 -0
  209. package/host/index.d.ts +116 -0
  210. package/host/index.mjs +295 -0
  211. package/host/index.test.mjs +302 -0
  212. package/package.json +14 -9
@@ -0,0 +1,432 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Matching a project to a host's package sets (architecture:cli-surface
5
+ * INV30): one root per run, the first that qualifies in the host's order, the
6
+ * versions a project would install, nothing filled that ships an integration
7
+ * manifest, and the integrations a host approves for use outside a project.
8
+ */
9
+
10
+ import {describe, it, expect, afterEach} from 'vitest';
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import {
14
+ cleanupFixtures,
15
+ project,
16
+ tempDir,
17
+ writeJson,
18
+ } from '../../test-utils/release-fixtures.mjs';
19
+ import {projectAstryxPins} from './locked-versions.mjs';
20
+ import {qualifyingRoot, rootPackage, satisfiesInstall} from './package-set.mjs';
21
+ import {scanComputerIntegrations} from '../integrations/computer-integrations.mjs';
22
+ import {findCoreDir} from '../fs/paths.mjs';
23
+ import {
24
+ _resetHostedRun,
25
+ activateHostedRun,
26
+ recordResolved,
27
+ recordResolvedIntegrations,
28
+ resolvedPackages,
29
+ } from './package-root.mjs';
30
+
31
+ afterEach(cleanupFixtures);
32
+
33
+ /**
34
+ * Write a package into `dir`'s node_modules.
35
+ * @param {string} dir
36
+ * @param {string} name
37
+ * @param {Record<string, unknown>} pkg
38
+ * @param {{manifest?: boolean, bin?: boolean, hostEntry?: boolean}} [options]
39
+ * @returns {string} the package folder
40
+ */
41
+ function install(dir, name, pkg, options = {}) {
42
+ const pkgDir = path.join(dir, 'node_modules', ...name.split('/'));
43
+ writeJson(path.join(pkgDir, 'package.json'), {
44
+ name,
45
+ ...pkg,
46
+ ...(options.bin ? {bin: {astryx: './bin.cjs'}} : {}),
47
+ });
48
+ if (options.bin) fs.writeFileSync(path.join(pkgDir, 'bin.cjs'), '');
49
+ if (options.hostEntry) {
50
+ fs.mkdirSync(path.join(pkgDir, 'host'), {recursive: true});
51
+ fs.writeFileSync(path.join(pkgDir, 'host', 'index.mjs'), '');
52
+ }
53
+ if (options.manifest) {
54
+ fs.writeFileSync(
55
+ path.join(pkgDir, 'astryx.integration.mjs'),
56
+ 'export default {};\n',
57
+ );
58
+ }
59
+ return pkgDir;
60
+ }
61
+
62
+ /**
63
+ * A package set: a root holding a CLI and Core of `version`.
64
+ * @param {string} version
65
+ * @param {{hostEntry?: boolean, bin?: boolean}} [options]
66
+ * @returns {string}
67
+ */
68
+ function packageSet(version, options = {}) {
69
+ const root = tempDir('set');
70
+ install(
71
+ root,
72
+ '@astryxdesign/cli',
73
+ {version},
74
+ {bin: options.bin ?? true, hostEntry: options.hostEntry},
75
+ );
76
+ install(root, '@astryxdesign/core', {version});
77
+ return root;
78
+ }
79
+
80
+ describe('the versions a project would install', () => {
81
+ it('reads each declared @astryxdesign/* package with its lockfile and installed versions', () => {
82
+ const root = project(tempDir(), {
83
+ '@astryxdesign/core': '^0.7.0',
84
+ '@astryxdesign/cli': '0.7.0',
85
+ react: '^19.0.0',
86
+ });
87
+ install(root, '@astryxdesign/cli', {version: '0.7.0'});
88
+ writeJson(path.join(root, 'package-lock.json'), {
89
+ lockfileVersion: 3,
90
+ packages: {
91
+ '': {},
92
+ 'node_modules/@astryxdesign/core': {version: '0.7.1'},
93
+ 'node_modules/@astryxdesign/cli': {version: '0.7.0'},
94
+ },
95
+ });
96
+ expect(projectAstryxPins(root)).toEqual([
97
+ {
98
+ name: '@astryxdesign/core',
99
+ declared: '^0.7.0',
100
+ locked: '0.7.1',
101
+ installed: null,
102
+ },
103
+ {
104
+ name: '@astryxdesign/cli',
105
+ declared: '0.7.0',
106
+ locked: '0.7.0',
107
+ installed: '0.7.0',
108
+ },
109
+ ]);
110
+ });
111
+ });
112
+
113
+ describe('a version the project would install for a range', () => {
114
+ it.each([
115
+ ['0.7.1', '^0.7.0', true],
116
+ ['0.8.0', '^0.7.0', false],
117
+ ['0.7.1-canary.1', '^0.7.0', false],
118
+ ['0.7.1-canary.1', '^0.7.1-canary.0', true],
119
+ ['0.7.1', 'file:../core', false],
120
+ ['0.7.1', 'workspace:*', false],
121
+ [null, '^0.7.0', false],
122
+ ])('%s for %s: %s', (version, range, expected) => {
123
+ expect(satisfiesInstall(version, range)).toBe(expected);
124
+ });
125
+ });
126
+
127
+ describe('the package set that serves a run another release answers', () => {
128
+ it('serves from the first set whose CLI and Core are that release', () => {
129
+ const older = packageSet('0.6.8');
130
+ const first = packageSet('0.7.0');
131
+ const second = packageSet('0.7.0');
132
+ const root = project(tempDir(), {'@astryxdesign/core': '0.7.0'});
133
+ const served = qualifyingRoot({
134
+ release: '0.7.0',
135
+ projectDir: root,
136
+ roots: [older, first, second],
137
+ });
138
+ expect(served).toMatchObject({root: first, hostEntry: null});
139
+ expect(/** @type {any} */ (served).bin).toBe(
140
+ path.join(first, 'node_modules', '@astryxdesign', 'cli', 'bin.cjs'),
141
+ );
142
+ });
143
+
144
+ it('skips a set whose Core is another release, or missing', () => {
145
+ const mixed = tempDir('set');
146
+ install(mixed, '@astryxdesign/cli', {version: '0.7.0'}, {bin: true});
147
+ install(mixed, '@astryxdesign/core', {version: '0.7.1'});
148
+ const bare = tempDir('set');
149
+ install(bare, '@astryxdesign/cli', {version: '0.7.0'}, {bin: true});
150
+ const root = project(tempDir(), {'@astryxdesign/core': '0.7.0'});
151
+ expect(
152
+ qualifyingRoot({
153
+ release: '0.7.0',
154
+ projectDir: root,
155
+ roots: [mixed, bare],
156
+ }),
157
+ ).toEqual({
158
+ reason:
159
+ "no package set the host passed holds CLI 0.7.0 with this project's packages",
160
+ });
161
+ });
162
+
163
+ it("holds the lockfile's version of each package it fills, else one in the declared range", () => {
164
+ const locked = project(tempDir(), {
165
+ '@astryxdesign/core': '^0.7.0',
166
+ '@astryxdesign/charts': '^0.7.0',
167
+ });
168
+ writeJson(path.join(locked, 'package-lock.json'), {
169
+ lockfileVersion: 3,
170
+ packages: {
171
+ '': {},
172
+ 'node_modules/@astryxdesign/core': {version: '0.7.0'},
173
+ 'node_modules/@astryxdesign/charts': {version: '0.7.0'},
174
+ },
175
+ });
176
+ const newer = packageSet('0.7.0');
177
+ install(newer, '@astryxdesign/charts', {version: '0.7.2'});
178
+ const exact = packageSet('0.7.0');
179
+ install(exact, '@astryxdesign/charts', {version: '0.7.0'});
180
+ expect(
181
+ qualifyingRoot({
182
+ release: '0.7.0',
183
+ projectDir: locked,
184
+ roots: [newer, exact],
185
+ }),
186
+ ).toMatchObject({root: exact});
187
+
188
+ const ranged = project(tempDir(), {
189
+ '@astryxdesign/core': '^0.7.0',
190
+ '@astryxdesign/charts': '^0.7.0',
191
+ });
192
+ expect(
193
+ qualifyingRoot({release: '0.7.0', projectDir: ranged, roots: [newer]}),
194
+ ).toMatchObject({root: newer});
195
+ });
196
+
197
+ it('keeps out a set whose copy the project declares as a path and has not locked', () => {
198
+ const root = project(tempDir(), {
199
+ '@astryxdesign/core': '0.7.0',
200
+ '@astryxdesign/charts': 'file:../charts',
201
+ });
202
+ const set = packageSet('0.7.0');
203
+ install(set, '@astryxdesign/charts', {version: '0.7.0'});
204
+ expect(
205
+ qualifyingRoot({release: '0.7.0', projectDir: root, roots: [set]}),
206
+ ).toHaveProperty('reason');
207
+ });
208
+
209
+ it('never fills an @astryxdesign/* integration, so its version never keeps a set out', () => {
210
+ const root = project(tempDir(), {
211
+ '@astryxdesign/core': '0.7.0',
212
+ '@astryxdesign/richtext': '^0.9.0',
213
+ });
214
+ const set = packageSet('0.7.0');
215
+ install(
216
+ set,
217
+ '@astryxdesign/richtext',
218
+ {version: '0.7.0'},
219
+ {manifest: true},
220
+ );
221
+ expect(
222
+ qualifyingRoot({release: '0.7.0', projectDir: root, roots: [set]}),
223
+ ).toMatchObject({root: set});
224
+ });
225
+
226
+ it('fills nothing the project installed', () => {
227
+ const root = project(tempDir(), {
228
+ '@astryxdesign/core': '0.7.0',
229
+ '@astryxdesign/charts': '^0.6.0',
230
+ });
231
+ install(root, '@astryxdesign/charts', {version: '0.6.3'});
232
+ const set = packageSet('0.7.0');
233
+ install(set, '@astryxdesign/charts', {version: '0.7.0'});
234
+ expect(
235
+ qualifyingRoot({release: '0.7.0', projectDir: root, roots: [set]}),
236
+ ).toMatchObject({root: set});
237
+ });
238
+
239
+ it('needs a host entry when asked to', () => {
240
+ const plain = packageSet('0.7.0');
241
+ const hosted = packageSet('0.7.0', {hostEntry: true});
242
+ const root = project(tempDir(), {'@astryxdesign/core': '0.7.0'});
243
+ expect(
244
+ qualifyingRoot({
245
+ release: '0.7.0',
246
+ projectDir: root,
247
+ roots: [plain, hosted],
248
+ needsHostEntry: true,
249
+ }),
250
+ ).toMatchObject({
251
+ root: hosted,
252
+ hostEntry: path.join(
253
+ hosted,
254
+ 'node_modules',
255
+ '@astryxdesign',
256
+ 'cli',
257
+ 'host',
258
+ 'index.mjs',
259
+ ),
260
+ });
261
+ });
262
+
263
+ it("reads a root's own node_modules only", () => {
264
+ const outer = tempDir('outer');
265
+ install(outer, '@astryxdesign/core', {version: '0.7.0'});
266
+ const set = path.join(outer, 'set');
267
+ install(set, '@astryxdesign/cli', {version: '0.7.0'}, {bin: true});
268
+ expect(rootPackage(set, '@astryxdesign/core')).toBeNull();
269
+ expect(
270
+ qualifyingRoot({release: '0.7.0', projectDir: null, roots: [set]}),
271
+ ).toHaveProperty('reason');
272
+ });
273
+ });
274
+
275
+ describe('the integrations a host approves', () => {
276
+ it('load by name from the package set that holds the CLI, with the same checks', () => {
277
+ const set = tempDir('set');
278
+ const cliDir = install(set, '@astryxdesign/cli', {version: '0.7.2'});
279
+ // The set declares only the CLI and Core, so FR12's scan finds nothing.
280
+ writeJson(path.join(set, 'package.json'), {
281
+ dependencies: {'@astryxdesign/cli': '0.7.2'},
282
+ });
283
+ install(set, 'acme-widgets', {version: '1.0.0'}, {manifest: true});
284
+ install(
285
+ set,
286
+ 'acme-old',
287
+ {version: '1.0.0', peerDependencies: {'@astryxdesign/core': '^0.6.0'}},
288
+ {manifest: true},
289
+ );
290
+ install(set, 'acme-plain', {version: '1.0.0'});
291
+ expect(
292
+ scanComputerIntegrations({cliRoot: cliDir, release: '0.7.2'}),
293
+ ).toEqual([]);
294
+ const found = scanComputerIntegrations({
295
+ cliRoot: cliDir,
296
+ release: '0.7.2',
297
+ approved: ['acme-widgets', 'acme-old', 'acme-plain', 'acme-missing'],
298
+ });
299
+ expect(found.map(c => [c.name, c.incompatible])).toEqual([
300
+ ['acme-widgets', null],
301
+ [
302
+ 'acme-old',
303
+ "it needs @astryxdesign/core ^0.6.0, and this computer's CLI is 0.7.2",
304
+ ],
305
+ ]);
306
+ expect(found[0].packageDir).toBe(
307
+ path.join(set, 'node_modules', 'acme-widgets'),
308
+ );
309
+ });
310
+
311
+ it('never takes an approved name from a folder above the package set', () => {
312
+ const outer = tempDir('outer');
313
+ install(outer, 'acme-outer', {version: '1.0.0'}, {manifest: true});
314
+ const set = path.join(outer, 'set');
315
+ const cliDir = install(set, '@astryxdesign/cli', {version: '0.7.2'});
316
+ writeJson(path.join(set, 'package.json'), {
317
+ dependencies: {'@astryxdesign/cli': '0.7.2'},
318
+ });
319
+ expect(
320
+ scanComputerIntegrations({
321
+ cliRoot: cliDir,
322
+ release: '0.7.2',
323
+ approved: ['acme-outer'],
324
+ }),
325
+ ).toEqual([]);
326
+ });
327
+ });
328
+
329
+ describe('where each package a hosted run resolved came from', () => {
330
+ afterEach(() => _resetHostedRun());
331
+
332
+ /**
333
+ * @param {string} root
334
+ * @param {string} name
335
+ */
336
+ const at = (root, name) =>
337
+ path.join(root, 'node_modules', ...name.split('/'));
338
+
339
+ it("names the project's packages as the project's when the host's CLI is installed in the project", () => {
340
+ const app = tempDir('app');
341
+ activateHostedRun({startingRoot: app, projectRoot: app});
342
+ recordResolved('@astryxdesign/cli', '0.7.2', at(app, '@astryxdesign/cli'));
343
+ recordResolved(
344
+ '@astryxdesign/core',
345
+ '0.7.2',
346
+ at(app, '@astryxdesign/core'),
347
+ 'project',
348
+ );
349
+ recordResolvedIntegrations([
350
+ {
351
+ name: 'acme-widgets',
352
+ version: '1.0.0',
353
+ __packageDir: at(app, 'acme-widgets'),
354
+ },
355
+ ]);
356
+ expect(resolvedPackages()).toEqual([
357
+ {name: '@astryxdesign/cli', version: '0.7.2', source: 'project'},
358
+ {name: '@astryxdesign/core', version: '0.7.2', source: 'project'},
359
+ {name: 'acme-widgets', version: '1.0.0', source: 'project'},
360
+ ]);
361
+ });
362
+
363
+ it("names what a package set holds as the package set's, and the project's integrations as the project's", () => {
364
+ const set = tempDir('set');
365
+ const app = tempDir('app');
366
+ activateHostedRun({startingRoot: set, projectRoot: app});
367
+ recordResolved('@astryxdesign/cli', '0.7.2', at(set, '@astryxdesign/cli'));
368
+ recordResolved(
369
+ '@astryxdesign/core',
370
+ '0.7.2',
371
+ at(set, '@astryxdesign/core'),
372
+ );
373
+ recordResolvedIntegrations([
374
+ {
375
+ name: 'acme-tools',
376
+ version: '2.0.0',
377
+ __packageDir: at(set, 'acme-tools'),
378
+ __computer: true,
379
+ },
380
+ {
381
+ name: 'acme-widgets',
382
+ version: '1.0.0',
383
+ __packageDir: at(app, 'acme-widgets'),
384
+ },
385
+ ]);
386
+ expect(resolvedPackages()).toEqual([
387
+ {name: '@astryxdesign/cli', version: '0.7.2', source: 'package-set'},
388
+ {name: '@astryxdesign/core', version: '0.7.2', source: 'package-set'},
389
+ {name: 'acme-tools', version: '2.0.0', source: 'package-set'},
390
+ {name: 'acme-widgets', version: '1.0.0', source: 'project'},
391
+ ]);
392
+ });
393
+
394
+ it("names what the project loaded or found above itself, as a workspace hoists it, as the project's", () => {
395
+ // The host's CLI sits in the workspace root, which is its package set,
396
+ // and the project is a workspace package whose packages are hoisted there.
397
+ const mono = tempDir('mono');
398
+ writeJson(path.join(mono, 'package.json'), {
399
+ private: true,
400
+ workspaces: ['packages/*'],
401
+ });
402
+ const app = path.join(mono, 'packages', 'app');
403
+ writeJson(path.join(app, 'package.json'), {
404
+ name: 'app',
405
+ dependencies: {'@astryxdesign/core': '0.7.2', 'acme-widgets': '1.0.0'},
406
+ });
407
+ install(mono, '@astryxdesign/core', {version: '0.7.2'});
408
+ activateHostedRun({startingRoot: mono, projectRoot: app});
409
+ expect(findCoreDir(app)).toBe(at(mono, '@astryxdesign/core'));
410
+ recordResolvedIntegrations([
411
+ {
412
+ name: 'acme-widgets',
413
+ version: '1.0.0',
414
+ __packageDir: at(mono, 'acme-widgets'),
415
+ },
416
+ ]);
417
+ expect(resolvedPackages()).toEqual([
418
+ {name: '@astryxdesign/core', version: '0.7.2', source: 'project'},
419
+ {name: 'acme-widgets', version: '1.0.0', source: 'project'},
420
+ ]);
421
+ });
422
+
423
+ it('names a package set inside the project folder as a package set', () => {
424
+ const app = tempDir('app');
425
+ const set = path.join(app, '.tools', 'astryx');
426
+ activateHostedRun({startingRoot: set, projectRoot: app});
427
+ recordResolved('@astryxdesign/cli', '0.7.2', at(set, '@astryxdesign/cli'));
428
+ expect(resolvedPackages()).toEqual([
429
+ {name: '@astryxdesign/cli', version: '0.7.2', source: 'package-set'},
430
+ ]);
431
+ });
432
+ });
@@ -0,0 +1,274 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Door conformance (spec AST-081 FR1): every command example, run
5
+ * through the bin and through the host entry, gives the same exit status,
6
+ * output, and file changes, in an empty folder, a project on the current
7
+ * minor, and a project on the previous minor.
8
+ *
9
+ * Each run is a child process with its own copy of the folder, its own home
10
+ * folder, and its own per-user cache. The package registry is unreachable and
11
+ * notices are off, so no run reaches the network. Output is compared once each
12
+ * run's own folders read as placeholders. Neither door is given a command
13
+ * name, so FR5's command names are the same through both, and no line FR5 or
14
+ * FR6 lets differ prints.
15
+ */
16
+
17
+ import {spawn} from 'node:child_process';
18
+ import * as fs from 'node:fs';
19
+ import * as os from 'node:os';
20
+ import * as path from 'node:path';
21
+ import {fileURLToPath, pathToFileURL} from 'node:url';
22
+ import {afterAll, beforeAll, describe, expect, it} from 'vitest';
23
+
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
25
+ const CLI_ROOT = path.resolve(HERE, '..');
26
+ const CORE_ROOT = path.resolve(CLI_ROOT, '../core');
27
+ const BIN = path.join(CLI_ROOT, 'clients', 'cli', 'bin', 'astryx.mjs');
28
+ const HOST_URL = pathToFileURL(path.join(HERE, 'index.mjs')).href;
29
+ const COMMANDS = path.join(CLI_ROOT, 'clients', 'cli', 'commands');
30
+ const VERSION = JSON.parse(
31
+ fs.readFileSync(path.join(CLI_ROOT, 'package.json'), 'utf8'),
32
+ ).version;
33
+ const [MAJOR, MINOR] = VERSION.split('.').map(Number);
34
+ const PREVIOUS_MINOR = `${MAJOR}.${Math.max(MINOR - 1, 0)}.0`;
35
+ /** How long one run may take before it counts as hung. */
36
+ const RUN_TIMEOUT_MS = 90_000;
37
+
38
+ /** Split an example's command line the way a shell would, quotes included. */
39
+ function splitArgs(line) {
40
+ const out = [];
41
+ let current = '';
42
+ let quote = null;
43
+ let started = false;
44
+ for (const ch of line) {
45
+ if (quote) {
46
+ if (ch === quote) quote = null;
47
+ else current += ch;
48
+ } else if (ch === '"' || ch === "'") {
49
+ quote = ch;
50
+ started = true;
51
+ } else if (/\s/.test(ch)) {
52
+ if (current || started) out.push(current);
53
+ current = '';
54
+ started = false;
55
+ } else {
56
+ current += ch;
57
+ }
58
+ }
59
+ if (current || started) out.push(current);
60
+ return out;
61
+ }
62
+
63
+ /** @returns {Promise<Array<{cli: string, argv: string[]}>>} every distinct command example */
64
+ async function commandExamples() {
65
+ /** @type {Map<string, string[]>} */
66
+ const byLine = new Map();
67
+ for (const name of fs.readdirSync(COMMANDS).sort()) {
68
+ if (!name.endsWith('.doc.mjs')) continue;
69
+ const {doc} = await import(pathToFileURL(path.join(COMMANDS, name)).href);
70
+ if (doc?.type !== 'command') continue;
71
+ for (const example of doc.examples ?? []) {
72
+ const words = splitArgs(example.cli);
73
+ byLine.set(example.cli, words[0] === 'astryx' ? words.slice(1) : words);
74
+ }
75
+ }
76
+ return [...byLine].map(([cli, argv]) => ({cli, argv}));
77
+ }
78
+
79
+ /**
80
+ * @param {string} file
81
+ * @param {unknown} value
82
+ */
83
+ function writeJson(file, value) {
84
+ fs.mkdirSync(path.dirname(file), {recursive: true});
85
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
86
+ }
87
+
88
+ /** The folders every example runs in, each set up fresh for each run. */
89
+ const FOLDERS = {
90
+ 'an empty folder': () => {},
91
+ /** @param {string} dir */
92
+ 'a project on the current minor': dir => {
93
+ writeJson(path.join(dir, 'package.json'), {
94
+ name: 'app',
95
+ private: true,
96
+ dependencies: {'@astryxdesign/core': VERSION},
97
+ });
98
+ const scope = path.join(dir, 'node_modules', '@astryxdesign');
99
+ fs.mkdirSync(scope, {recursive: true});
100
+ fs.symlinkSync(CORE_ROOT, path.join(scope, 'core'), 'dir');
101
+ },
102
+ /** @param {string} dir */
103
+ 'a project on the previous minor': dir => {
104
+ writeJson(path.join(dir, 'package.json'), {
105
+ name: 'app',
106
+ private: true,
107
+ dependencies: {'@astryxdesign/core': PREVIOUS_MINOR},
108
+ });
109
+ writeJson(
110
+ path.join(dir, 'node_modules', '@astryxdesign', 'core', 'package.json'),
111
+ {name: '@astryxdesign/core', version: PREVIOUS_MINOR},
112
+ );
113
+ },
114
+ };
115
+
116
+ /** @type {string[]} */
117
+ const bases = [];
118
+
119
+ /**
120
+ * Every file under `root`, skipping node_modules, by path relative to `base`.
121
+ * @param {string} root
122
+ * @param {string} base
123
+ * @param {Map<string, string>} into
124
+ */
125
+ function readTree(root, base, into = new Map()) {
126
+ if (!fs.existsSync(root)) return into;
127
+ for (const entry of fs.readdirSync(root, {withFileTypes: true})) {
128
+ const file = path.join(root, entry.name);
129
+ if (entry.isDirectory() && entry.name !== 'node_modules') {
130
+ readTree(file, base, into);
131
+ } else if (entry.isFile()) {
132
+ into.set(path.relative(base, file), fs.readFileSync(file, 'utf8'));
133
+ }
134
+ }
135
+ return into;
136
+ }
137
+
138
+ /**
139
+ * Run one example through one door in a fresh copy of a folder.
140
+ * @param {'bin' | 'host entry'} door
141
+ * @param {string[]} argv
142
+ * @param {(dir: string) => void} setUp
143
+ * @returns {Promise<{status: number | null, stdout: string, stderr: string, changes: Record<string, string | null>}>}
144
+ */
145
+ async function runThrough(door, argv, setUp) {
146
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-door-'));
147
+ bases.push(base);
148
+ const dir = path.join(base, 'app');
149
+ const home = path.join(base, 'home');
150
+ fs.mkdirSync(dir);
151
+ fs.mkdirSync(home);
152
+ setUp(dir);
153
+ const read = () => readTree(home, base, readTree(dir, base));
154
+ const before = read();
155
+ const args =
156
+ door === 'bin'
157
+ ? [BIN, ...argv]
158
+ : [
159
+ '--input-type=module',
160
+ '-e',
161
+ `import {runHostedCli} from ${JSON.stringify(HOST_URL)};\n` +
162
+ `const result = await runHostedCli({argv: ${JSON.stringify(argv)}, cwd: process.cwd()});\n` +
163
+ 'process.exitCode = result.status;\n',
164
+ ];
165
+ const env = {...process.env};
166
+ for (const key of Object.keys(env)) {
167
+ if (key.startsWith('VITEST') || key === 'NODE_OPTIONS') delete env[key];
168
+ }
169
+ Object.assign(env, {
170
+ HOME: home,
171
+ USERPROFILE: home,
172
+ XDG_CACHE_HOME: path.join(base, 'cache'),
173
+ XDG_CONFIG_HOME: path.join(home, '.config'),
174
+ XDG_DATA_HOME: path.join(home, '.local', 'share'),
175
+ // Other tools' own state, such as the device id the gh tool writes when
176
+ // swizzle asks it whether it is signed in, and npm's cache and logs,
177
+ // stays outside the folders the runs compare.
178
+ XDG_STATE_HOME: path.join(base, 'state'),
179
+ npm_config_cache: path.join(base, 'npm-cache'),
180
+ CI: '1',
181
+ NO_UPDATE_NOTIFIER: '1',
182
+ NO_COLOR: '1',
183
+ npm_config_registry: 'http://127.0.0.1:9/',
184
+ npm_config_fetch_retries: '0',
185
+ npm_config_fetch_timeout: '2000',
186
+ });
187
+ const child = spawn(process.execPath, args, {
188
+ cwd: dir,
189
+ env,
190
+ stdio: ['ignore', 'pipe', 'pipe'],
191
+ });
192
+ let stdout = '';
193
+ let stderr = '';
194
+ child.stdout.on('data', chunk => (stdout += chunk));
195
+ child.stderr.on('data', chunk => (stderr += chunk));
196
+ const timer = setTimeout(() => child.kill('SIGKILL'), RUN_TIMEOUT_MS);
197
+ const status = await new Promise(resolve => child.on('close', resolve));
198
+ clearTimeout(timer);
199
+ // Each run's own folders read as placeholders.
200
+ const real = fs.realpathSync(base);
201
+ /** @param {string} text */
202
+ const placeholders = text =>
203
+ text.split(real).join('<run>').split(base).join('<run>');
204
+ const after = read();
205
+ /** @type {Record<string, string | null>} */
206
+ const changes = {};
207
+ for (const [file, text] of after) {
208
+ if (before.get(file) !== text) changes[file] = placeholders(text);
209
+ }
210
+ for (const file of before.keys()) {
211
+ if (!after.has(file)) changes[file] = null;
212
+ }
213
+ return {
214
+ status,
215
+ stdout: placeholders(stdout),
216
+ stderr: placeholders(stderr),
217
+ changes,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Run `tasks` with at most `limit` at a time.
223
+ * @template T
224
+ * @param {Array<() => Promise<T>>} tasks
225
+ * @param {number} limit
226
+ * @returns {Promise<T[]>}
227
+ */
228
+ async function inBatches(tasks, limit) {
229
+ const results = new Array(tasks.length);
230
+ let next = 0;
231
+ const worker = async () => {
232
+ while (next < tasks.length) {
233
+ const index = next++;
234
+ results[index] = await tasks[index]();
235
+ }
236
+ };
237
+ await Promise.all(Array.from({length: limit}, worker));
238
+ return results;
239
+ }
240
+
241
+ const examples = await commandExamples();
242
+
243
+ afterAll(() => {
244
+ for (const base of bases) fs.rmSync(base, {recursive: true, force: true});
245
+ });
246
+
247
+ describe.each(Object.entries(FOLDERS))('in %s', (_folder, setUp) => {
248
+ /** @type {Map<string, {bin: Awaited<ReturnType<typeof runThrough>>, host: Awaited<ReturnType<typeof runThrough>>}>} */
249
+ const runs = new Map();
250
+
251
+ beforeAll(async () => {
252
+ const limit = Math.max(2, Math.min(16, os.availableParallelism()));
253
+ const results = await inBatches(
254
+ examples.flatMap(({argv}) => [
255
+ () => runThrough('bin', argv, setUp),
256
+ () => runThrough('host entry', argv, setUp),
257
+ ]),
258
+ limit,
259
+ );
260
+ examples.forEach(({cli}, index) => {
261
+ runs.set(cli, {bin: results[2 * index], host: results[2 * index + 1]});
262
+ });
263
+ }, 15 * 60_000);
264
+
265
+ it('runs every example', () => {
266
+ expect(examples.length).toBeGreaterThan(100);
267
+ });
268
+
269
+ it.each(examples.map(({cli}) => [cli]))('%s', cli => {
270
+ const {bin, host} = runs.get(cli) ?? {};
271
+ expect(bin?.status, `${cli} finished through the bin`).not.toBeNull();
272
+ expect(host).toEqual(bin);
273
+ });
274
+ });