@astryxdesign/cli 0.6.3-canary.d0d49a4 → 0.6.3-canary.dea6813
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/README.md +32 -18
- package/api/build/build.doc.mjs +3 -3
- package/api/build/build.test.mjs +14 -3
- package/api/build/build.type.d.mts +35 -1
- package/api/build/build.type.mjs +24 -3
- package/api/build/help/help.d.mts +8 -5
- package/api/build/help/help.mjs +56 -6
- package/api/component/component.doc.mjs +11 -2
- package/api/component/component.mjs +29 -0
- package/api/component/component.test.mjs +38 -0
- package/api/component/component.type.d.mts +11 -1
- package/api/component/component.type.mjs +8 -1
- package/api/discover/discover.doc.mjs +1 -1
- package/api/gap-report/gap-report.doc.mjs +7 -4
- package/api/init/init.doc.mjs +4 -0
- package/api/init/init.test.mjs +41 -1
- package/api/init/remove/remove.mjs +1 -1
- package/api/init/run/run.mjs +20 -10
- package/api/integration/add-contribution.component-names.test.mjs +120 -0
- package/api/integration/add-contribution.mjs +11 -0
- package/api/integration/add-contribution.test.mjs +80 -0
- package/api/integration/integrationAdd.doc.mjs +7 -4
- package/api/integration/integrationAddComponent.doc.mjs +1 -1
- package/api/integration/validate-integration.mjs +2 -0
- package/api/integration/validate-integration.test.mjs +91 -0
- package/api/integration/validateIntegration.doc.mjs +1 -1
- package/api/json/envelope-types.test.mjs +76 -0
- package/api/json/parseResponse.doc.mjs +2 -2
- package/api/layout/expand/expand.mjs +7 -5
- package/api/layout/expand/expand.path-safety.test.mjs +53 -0
- package/api/layout/grammar/grammar.mjs +2 -1
- package/api/layout/layoutExpand.doc.mjs +1 -1
- package/api/search/search-return-type.test.mjs +54 -0
- package/api/search/search.d.mts +2 -9
- package/api/search/search.doc.mjs +5 -1
- package/api/search/search.mjs +10 -3
- package/api/search/search.test.mjs +34 -0
- package/api/swizzle/copy/copy.mjs +28 -11
- package/api/swizzle/swizzle.doc.mjs +1 -1
- package/api/template/copy/copy.mjs +17 -23
- package/api/template/copy/copy.test.mjs +17 -0
- package/api/template/template.doc.mjs +2 -1
- package/api/theme/add/add.binary.test.mjs +91 -0
- package/api/theme/add/add.mjs +20 -3
- package/api/theme/add/add.staging.test.mjs +66 -0
- package/api/theme/build/build.mjs +114 -37
- package/api/theme/build/build.public-component-vars.test.mjs +1 -1
- package/api/theme/build/build.receipt-doc.test.mjs +111 -0
- package/api/theme/build/font-warning.mjs +3 -3
- package/api/theme/build/font-warning.test.mjs +5 -2
- package/api/theme/palette/generate/generate.mjs +7 -2
- package/api/theme/palette/generate/generate.test.mjs +96 -0
- package/api/theme/palette/generate/generator.mjs +8 -1
- package/api/theme/palette/generate/generator.test.mjs +10 -0
- package/api/theme/template/template.mjs +11 -2
- package/api/theme/template/template.test.mjs +15 -0
- package/api/theme/themeBuild.doc.mjs +7 -4
- package/api/theme/themeTemplate.doc.mjs +4 -1
- package/api/upgrade/_adapter.mjs +27 -4
- package/api/upgrade/list/list.mjs +2 -1
- package/api/upgrade/list/list.test.mjs +73 -0
- package/api/upgrade/status/status.mjs +2 -2
- package/api/upgrade/upgrade.doc.mjs +2 -1
- package/api/upgrade/upgrade.type.d.mts +4 -0
- package/api/upgrade/upgrade.type.mjs +1 -0
- package/assets/codemods/term-log.mjs +32 -8
- package/assets/codemods/term-log.test.mjs +19 -1
- package/assets/codemods/transforms/v0.6.0/__tests__/next-codemods.test.mjs +47 -0
- package/assets/codemods/transforms/v0.6.0/rename-resizable-pixel-bounds.mjs +57 -10
- package/assets/templates/pages/table-tree/page.tsx +1704 -0
- package/assets/templates/pages/table-tree/template.doc.mjs +12 -0
- package/authoring/config/config.doc.mjs +1 -1
- package/authoring/config/debug-composition.test.mjs +92 -0
- package/authoring/config/type.ts +15 -3
- package/clients/cli/command-load-failure.test.mjs +83 -0
- package/clients/cli/commands/blog.mjs +23 -8
- package/clients/cli/commands/blog.test.mjs +42 -1
- package/clients/cli/commands/build-theme.ascii-output.test.mjs +161 -0
- package/clients/cli/commands/build-theme.flag-docs.test.mjs +110 -0
- package/clients/cli/commands/build-theme.mjs +5 -5
- package/clients/cli/commands/build-theme.path-safety.test.mjs +86 -1
- package/clients/cli/commands/build-theme.variants.test.mjs +76 -0
- package/clients/cli/commands/build.doc.mjs +4 -1
- package/clients/cli/commands/build.exit-codes-doc.test.mjs +50 -0
- package/clients/cli/commands/build.mjs +32 -47
- package/clients/cli/commands/build.playbook.test.mjs +75 -0
- package/clients/cli/commands/build.text-fields.test.mjs +40 -0
- package/clients/cli/commands/component/index.mjs +2 -7
- package/clients/cli/commands/component-package.test.mjs +18 -0
- package/clients/cli/commands/component-resolution.test.mjs +21 -0
- package/clients/cli/commands/component.test.mjs +19 -0
- package/clients/cli/commands/detail-levels.test.mjs +2 -2
- package/clients/cli/commands/discover.broken-integration.test.mjs +48 -0
- package/clients/cli/commands/discover.components-flag.test.mjs +97 -0
- package/clients/cli/commands/discover.doc.mjs +4 -2
- package/clients/cli/commands/discover.mjs +3 -3
- package/clients/cli/commands/discover.text-projection.test.mjs +103 -0
- package/clients/cli/commands/doctor-integration.package-json.test.mjs +53 -0
- package/clients/cli/commands/doctor.mjs +6 -16
- package/clients/cli/commands/doctor.test.mjs +42 -0
- package/clients/cli/commands/gap-report.doc.mjs +16 -5
- package/clients/cli/commands/gap-report.test.mjs +72 -0
- package/clients/cli/commands/hook/index.mjs +7 -17
- package/clients/cli/commands/hook.text-projection.test.mjs +45 -0
- package/clients/cli/commands/init.doc.mjs +19 -8
- package/clients/cli/commands/init.flag-help.test.mjs +153 -0
- package/clients/cli/commands/integration-add.controls.test.mjs +132 -0
- package/clients/cli/commands/integration-add.doc.mjs +21 -5
- package/clients/cli/commands/interactive-guard.test.mjs +101 -24
- package/clients/cli/commands/json-contract.test.mjs +33 -0
- package/clients/cli/commands/layout-check.doc.mjs +14 -3
- package/clients/cli/commands/layout-expand.doc.mjs +21 -4
- package/clients/cli/commands/layout.doc.mjs +2 -2
- package/clients/cli/commands/layout.mjs +21 -9
- package/clients/cli/commands/layout.path-help.test.mjs +33 -0
- package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
- package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
- package/clients/cli/commands/no-prompt-wording.test.mjs +94 -0
- package/clients/cli/commands/search.doc.mjs +6 -3
- package/clients/cli/commands/search.mjs +17 -7
- package/clients/cli/commands/search.test.mjs +75 -0
- package/clients/cli/commands/swizzle.doc.mjs +2 -1
- package/clients/cli/commands/swizzle.path-safety.test.mjs +42 -0
- package/clients/cli/commands/template.doc.mjs +28 -6
- package/clients/cli/commands/template.flag-help.test.mjs +117 -0
- package/clients/cli/commands/template.path-help.test.mjs +40 -0
- package/clients/cli/commands/theme-add.doc.mjs +2 -1
- package/clients/cli/commands/theme-build.doc.mjs +7 -6
- package/clients/cli/commands/theme-palette-generate.test.mjs +19 -0
- package/clients/cli/commands/theme-template.behavior.test.mjs +12 -0
- package/clients/cli/commands/theme-template.doc.mjs +1 -1
- package/clients/cli/commands/upgrade.ascii-output.test.mjs +87 -0
- package/clients/cli/commands/upgrade.doc.mjs +17 -6
- package/clients/cli/commands/upgrade.flag-help.test.mjs +188 -0
- package/clients/cli/commands/upgrade.hook-output.test.mjs +88 -0
- package/clients/cli/index.mjs +13 -30
- package/clients/cli/latest-version-env.test.mjs +50 -0
- package/clients/cli/lib/cli-error.test.mjs +7 -0
- package/clients/cli/lib/component-format.mjs +9 -9
- package/clients/cli/lib/component-format.test.mjs +1 -1
- package/clients/cli/lib/define-command.mjs +32 -6
- package/clients/cli/lib/doc-text-ascii.test.mjs +82 -0
- package/clients/cli/lib/exit-codes.test.mjs +97 -0
- package/clients/cli/lib/hook-format.mjs +5 -5
- package/clients/cli/lib/json-shim.mjs +38 -2
- package/clients/cli/lib/json-shim.test.mjs +83 -0
- package/clients/cli/lib/manifest.d.ts +2 -0
- package/clients/cli/lib/manifest.mjs +22 -0
- package/clients/cli/lib/manifest.test.mjs +17 -0
- package/foundation/agent-docs/agent-docs.d.mts +4 -0
- package/foundation/agent-docs/agent-docs.mjs +69 -3
- package/foundation/agent-docs/agent-docs.path-safety.test.mjs +266 -4
- package/foundation/config/integration-debug.test.mjs +28 -3
- package/foundation/config/project.mjs +123 -9
- package/foundation/config/project.test.mjs +126 -0
- package/foundation/discovery/template-adapter.d.mts +14 -5
- package/foundation/discovery/template-adapter.fixture-refs.test.mjs +248 -0
- package/foundation/discovery/template-adapter.integration-isolation.test.mjs +94 -0
- package/foundation/discovery/template-adapter.mjs +292 -25
- package/foundation/discovery/theming-targets.test.mjs +4 -0
- package/foundation/fs/module-loader.d.mts +1 -0
- package/foundation/fs/module-loader.mjs +50 -1
- package/foundation/fs/module-loader.stdout.test.mjs +332 -0
- package/foundation/fs/path-safety.d.mts +3 -2
- package/foundation/fs/path-safety.mjs +49 -19
- package/foundation/fs/path-safety.test.mjs +50 -0
- package/foundation/fs/publish-file-hardlink-unavailable.test.mjs +129 -94
- package/foundation/integrations/autolink.mjs +6 -1
- package/foundation/response/base.d.ts +8 -4
- package/foundation/response/error-codes.doc.mjs +1 -2
- package/foundation/response/error-codes.mjs +1 -1
- package/foundation/response/error-codes.test.mjs +83 -0
- package/foundation/response/json-contract.test.mjs +11 -0
- package/foundation/response/json.d.mts +4 -2
- package/foundation/response/json.mjs +8 -10
- package/foundation/response/response-types.doc.mjs +3 -3
- package/foundation/xle/expand.d.mts +2 -0
- package/foundation/xle/expand.mjs +3 -2
- package/foundation/xle/expand.test.mjs +54 -0
- package/package.json +9 -9
- package/clients/cli/lib/update-check.mjs +0 -83
- package/clients/cli/lib/update-check.test.mjs +0 -137
- package/clients/cli/update-hint-commands.test.mjs +0 -54
package/api/init/init.test.mjs
CHANGED
|
@@ -230,13 +230,40 @@ describe('init() — logger', () => {
|
|
|
230
230
|
errSpy.mockRestore();
|
|
231
231
|
}
|
|
232
232
|
const text = lines.join('\n');
|
|
233
|
-
expect(text).toContain('
|
|
233
|
+
expect(text).toContain('[ok] AI agent docs installed -> AGENTS.md');
|
|
234
234
|
expect(text).toContain(' Next steps:');
|
|
235
235
|
// The exact next-steps block the CLI prints comes from getNextSteps().
|
|
236
236
|
expect(text).toContain(getNextSteps('npx astryx')[2].slice(0, 20));
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
+
describe('init() — ASCII output', () => {
|
|
241
|
+
it('prints only ASCII on the default, all-features, re-run, template, and remove paths', async () => {
|
|
242
|
+
/** @type {string[]} */
|
|
243
|
+
const lines = [];
|
|
244
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...a) => lines.push(a.join(' ')));
|
|
245
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation((...a) => lines.push(a.join(' ')));
|
|
246
|
+
const empty = fs.mkdtempSync(path.join(process.cwd(), '.astryx-init-remove-'));
|
|
247
|
+
logger.setSilent(false);
|
|
248
|
+
try {
|
|
249
|
+
await init({}, {cwd: tmpDir});
|
|
250
|
+
await init({all: true}, {cwd: tmpDir});
|
|
251
|
+
await init({all: true}, {cwd: tmpDir});
|
|
252
|
+
await init({features: 'template', templateName: 'blank'}, {cwd: tmpDir});
|
|
253
|
+
await init({removeAgents: true}, {cwd: tmpDir});
|
|
254
|
+
await init({removeAgents: true}, {cwd: empty});
|
|
255
|
+
} finally {
|
|
256
|
+
logger.setSilent(true);
|
|
257
|
+
logSpy.mockRestore();
|
|
258
|
+
errSpy.mockRestore();
|
|
259
|
+
fs.rmSync(empty, {recursive: true, force: true});
|
|
260
|
+
}
|
|
261
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
262
|
+
expect(lines.some(line => line.startsWith('[ok] Removed'))).toBe(true);
|
|
263
|
+
expect(lines.filter(line => /[\u0080-\uFFFF]/.test(line))).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
240
267
|
describe('init() — write-path safety', () => {
|
|
241
268
|
it('template scaffold refuses to clobber an existing page.tsx', async () => {
|
|
242
269
|
const dest = path.join(tmpDir, 'src', 'pages', 'blank');
|
|
@@ -260,4 +287,17 @@ describe('init() — write-path safety', () => {
|
|
|
260
287
|
init({features: 'template', templateName: '../../etc/evil'}, {cwd: tmpDir}),
|
|
261
288
|
).rejects.toMatchObject({code: ERROR_CODES.ERR_UNKNOWN_TEMPLATE});
|
|
262
289
|
});
|
|
290
|
+
|
|
291
|
+
it('refuses a template write that a symlinked src would carry outside cwd (ERR_PATH_TRAVERSAL)', async () => {
|
|
292
|
+
const outside = fs.mkdtempSync(path.join(process.cwd(), '.astryx-init-outside-'));
|
|
293
|
+
try {
|
|
294
|
+
fs.symlinkSync(outside, path.join(tmpDir, 'src'), 'dir');
|
|
295
|
+
await expect(
|
|
296
|
+
init({features: 'template', templateName: 'blank'}, {cwd: tmpDir}),
|
|
297
|
+
).rejects.toMatchObject({code: ERROR_CODES.ERR_PATH_TRAVERSAL});
|
|
298
|
+
expect(fs.readdirSync(outside)).toEqual([]);
|
|
299
|
+
} finally {
|
|
300
|
+
fs.rmSync(outside, {recursive: true, force: true});
|
|
301
|
+
}
|
|
302
|
+
});
|
|
263
303
|
});
|
|
@@ -24,6 +24,6 @@ import {logger} from '../../logger.mjs';
|
|
|
24
24
|
*/
|
|
25
25
|
export async function remove({cwd = process.cwd()} = {}) {
|
|
26
26
|
removeAgentDocs(cwd);
|
|
27
|
-
logger.log('
|
|
27
|
+
logger.log('[ok] AI agent docs removed.');
|
|
28
28
|
return {type: 'init.remove', data: {removed: true}};
|
|
29
29
|
}
|
package/api/init/run/run.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
import * as fs from 'node:fs';
|
|
19
19
|
import {CLI_ROOT} from '../../../foundation/fs/paths.mjs';
|
|
20
|
-
import {PathSafetyError} from '../../../foundation/fs/path-safety.mjs';
|
|
20
|
+
import {PathSafetyError, assertWithin} from '../../../foundation/fs/path-safety.mjs';
|
|
21
21
|
import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
|
|
22
22
|
import {
|
|
23
23
|
installAgentDocs,
|
|
@@ -98,7 +98,7 @@ async function applyAgents(cwd, options, invocation, data) {
|
|
|
98
98
|
renderedBlock,
|
|
99
99
|
});
|
|
100
100
|
data.docsWritten = written;
|
|
101
|
-
logger.log(
|
|
101
|
+
logger.log(`[ok] AI agent docs installed -> ${written.join(', ')}`);
|
|
102
102
|
} catch (err) {
|
|
103
103
|
// PathSafetyError carries a precise, user-actionable message — surface it
|
|
104
104
|
// (and flag exit 1) instead of the generic "could not install" warning so
|
|
@@ -132,8 +132,8 @@ function applyTheme(cwd, invocation, data) {
|
|
|
132
132
|
data.themeTemplatePath = didWrite ? written : null;
|
|
133
133
|
logger.log(
|
|
134
134
|
didWrite
|
|
135
|
-
?
|
|
136
|
-
:
|
|
135
|
+
? `[ok] Theme template written -> ${written}`
|
|
136
|
+
: `- ${written} already exists - left as is.`,
|
|
137
137
|
);
|
|
138
138
|
} catch {
|
|
139
139
|
// Soft failure, like agent docs: the guidance below is still useful.
|
|
@@ -166,16 +166,16 @@ function applyTemplate(cwd, {templateName}, invocation, data) {
|
|
|
166
166
|
// Point agents at the build workflow rather than dumping page-template
|
|
167
167
|
// names — `build` surfaces pages AND blocks AND components for an idea,
|
|
168
168
|
// and `build` with no args is the full how-to-build playbook.
|
|
169
|
-
logger.log('
|
|
169
|
+
logger.log('[ok] To build UI, use these commands:');
|
|
170
170
|
logger.log('');
|
|
171
171
|
logger.log(
|
|
172
|
-
` ${invocation} build "<what you're building>" build a page
|
|
172
|
+
` ${invocation} build "<what you're building>" build a page - kit: closest template + blocks + components`,
|
|
173
173
|
);
|
|
174
174
|
logger.log(
|
|
175
175
|
` ${invocation} build the how-to-build workflow (read this first)`,
|
|
176
176
|
);
|
|
177
177
|
logger.log(
|
|
178
|
-
` ${invocation} search <query> find anything
|
|
178
|
+
` ${invocation} search <query> find anything - components, docs, templates, blocks`,
|
|
179
179
|
);
|
|
180
180
|
logger.log('');
|
|
181
181
|
data.template = 'workflow';
|
|
@@ -190,9 +190,19 @@ function applyTemplate(cwd, {templateName}, invocation, data) {
|
|
|
190
190
|
);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
|
|
193
|
+
let destFile;
|
|
194
|
+
try {
|
|
195
|
+
destFile = assertWithin(path.join('src', 'pages', templateName, 'page.tsx'), cwd, {
|
|
196
|
+
label: 'template output path',
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err instanceof PathSafetyError) {
|
|
200
|
+
throw new AstryxError(err.message, undefined, ERROR_CODES.ERR_PATH_TRAVERSAL);
|
|
201
|
+
}
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
const outputDir = path.dirname(destFile);
|
|
194
205
|
const srcPath = path.join(CLI_ROOT, 'assets', 'templates', 'pages', templateName, 'page.tsx');
|
|
195
|
-
const destFile = path.join(outputDir, 'page.tsx');
|
|
196
206
|
// Don't clobber a user's existing page — same guard the peer template/copy and
|
|
197
207
|
// theme/add write-leaves apply (init is a public API surface too).
|
|
198
208
|
if (fs.existsSync(destFile)) {
|
|
@@ -206,7 +216,7 @@ function applyTemplate(cwd, {templateName}, invocation, data) {
|
|
|
206
216
|
fs.mkdirSync(outputDir, {recursive: true});
|
|
207
217
|
fs.copyFileSync(srcPath, destFile);
|
|
208
218
|
const rel = path.relative(cwd, outputDir);
|
|
209
|
-
logger.log(
|
|
219
|
+
logger.log(`[ok] Template created at ${rel}/page.tsx`);
|
|
210
220
|
data.template = 'created';
|
|
211
221
|
data.templatePath = rel;
|
|
212
222
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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 path from 'node:path';
|
|
6
|
+
import {integrationAdd, integrationAddComponent} from './add-contribution.mjs';
|
|
7
|
+
import {discoverIntegrationComponents} from '../../foundation/discovery/component-discovery.mjs';
|
|
8
|
+
|
|
9
|
+
let tmpDir;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-add-component-'));
|
|
13
|
+
fs.writeFileSync(
|
|
14
|
+
path.join(tmpDir, 'package.json'),
|
|
15
|
+
`${JSON.stringify({name: '@acme/widgets', version: '1.0.0', files: ['dist']}, null, 2)}\n`,
|
|
16
|
+
);
|
|
17
|
+
fs.writeFileSync(
|
|
18
|
+
path.join(tmpDir, 'astryx.integration.mjs'),
|
|
19
|
+
"export default {\n components: './components',\n};\n",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** @param {string} relativeDoc */
|
|
28
|
+
function writeAuthoredComponent(relativeDoc) {
|
|
29
|
+
const doc = path.join(tmpDir, relativeDoc);
|
|
30
|
+
fs.mkdirSync(path.dirname(doc), {recursive: true});
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
doc,
|
|
33
|
+
"export default {\n type: 'component',\n name: 'AcmeWidget',\n description: 'The authored AcmeWidget.',\n props: [],\n};\n",
|
|
34
|
+
);
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
doc.replace(/\.doc\.(?:ts|mjs|js)$/u, '.tsx'),
|
|
37
|
+
'export function AcmeWidget() {\n return <div>Authored</div>;\n}\n',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every file under the package, with its bytes. */
|
|
42
|
+
function snapshot() {
|
|
43
|
+
/** @type {Record<string, string>} */
|
|
44
|
+
const files = {};
|
|
45
|
+
/** @param {string} dir */
|
|
46
|
+
const walk = dir => {
|
|
47
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
48
|
+
const full = path.join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) walk(full);
|
|
50
|
+
else files[path.relative(tmpDir, full)] = fs.readFileSync(full, 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
walk(tmpDir);
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('integrationAddComponent name collisions', () => {
|
|
58
|
+
it('refuses a name a nested component doc already defines', async () => {
|
|
59
|
+
writeAuthoredComponent('components/AcmeWidget/AcmeWidget.doc.mjs');
|
|
60
|
+
const before = snapshot();
|
|
61
|
+
|
|
62
|
+
await expect(
|
|
63
|
+
integrationAddComponent('AcmeWidget', {cwd: tmpDir}),
|
|
64
|
+
).rejects.toMatchObject({
|
|
65
|
+
code: 'ERR_FILE_EXISTS',
|
|
66
|
+
message: expect.stringContaining(
|
|
67
|
+
'components/AcmeWidget/AcmeWidget.doc.mjs',
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
expect(snapshot()).toEqual(before);
|
|
71
|
+
expect(
|
|
72
|
+
discoverIntegrationComponents({
|
|
73
|
+
name: '@acme/widgets',
|
|
74
|
+
components: path.join(tmpDir, 'components'),
|
|
75
|
+
}).map(component => path.relative(tmpDir, component.docPath)),
|
|
76
|
+
).toEqual([path.join('components', 'AcmeWidget', 'AcmeWidget.doc.mjs')]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('refuses the same collision under --dry-run', async () => {
|
|
80
|
+
writeAuthoredComponent('components/widgets/AcmeWidget.doc.mjs');
|
|
81
|
+
|
|
82
|
+
await expect(
|
|
83
|
+
integrationAdd('component', 'AcmeWidget', {cwd: tmpDir, dryRun: true}),
|
|
84
|
+
).rejects.toMatchObject({code: 'ERR_FILE_EXISTS'});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('refuses a same-stem doc with another extension in the root', async () => {
|
|
88
|
+
fs.mkdirSync(path.join(tmpDir, 'components'));
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
path.join(tmpDir, 'components', 'AcmeWidget.doc.ts'),
|
|
91
|
+
"export default {type: 'component', name: 'AcmeWidget', props: []};\n",
|
|
92
|
+
);
|
|
93
|
+
const before = snapshot();
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
integrationAddComponent('AcmeWidget', {cwd: tmpDir}),
|
|
97
|
+
).rejects.toMatchObject({code: 'ERR_FILE_EXISTS'});
|
|
98
|
+
expect(snapshot()).toEqual(before);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('still adds a component whose name is free', async () => {
|
|
102
|
+
writeAuthoredComponent('components/AcmeWidget/AcmeWidget.doc.mjs');
|
|
103
|
+
|
|
104
|
+
const result = await integrationAddComponent('AcmeBadge', {cwd: tmpDir});
|
|
105
|
+
|
|
106
|
+
expect(result.data.files).toEqual([
|
|
107
|
+
'components/AcmeBadge.doc.mjs',
|
|
108
|
+
'components/AcmeBadge.tsx',
|
|
109
|
+
'package.json',
|
|
110
|
+
]);
|
|
111
|
+
expect(
|
|
112
|
+
discoverIntegrationComponents({
|
|
113
|
+
name: '@acme/widgets',
|
|
114
|
+
components: path.join(tmpDir, 'components'),
|
|
115
|
+
})
|
|
116
|
+
.map(component => component.name)
|
|
117
|
+
.sort(),
|
|
118
|
+
).toEqual(['AcmeBadge', 'AcmeWidget']);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from '../../foundation/integrations/integrations.mjs';
|
|
36
36
|
import {isValidSemver} from '../../foundation/env/semver.mjs';
|
|
37
37
|
import {assertContributionVisible} from '../../foundation/integrations/contribution-inventory.mjs';
|
|
38
|
+
import {findIntegrationComponentDoc} from '../../foundation/discovery/component-discovery.mjs';
|
|
38
39
|
import {parseAgentDocsField} from '../../authoring/integration/schema.mjs';
|
|
39
40
|
import {integrationAddTheme} from './add-theme.mjs';
|
|
40
41
|
import {
|
|
@@ -248,6 +249,16 @@ async function addComponent(name, options) {
|
|
|
248
249
|
ERROR_CODES.ERR_FILE_EXISTS,
|
|
249
250
|
);
|
|
250
251
|
}
|
|
252
|
+
// Discovery keys components by doc stem anywhere under the root, so a second
|
|
253
|
+
// doc with this name would shadow one of the two.
|
|
254
|
+
const existingDoc = findIntegrationComponentDoc({components: root}, name);
|
|
255
|
+
if (existingDoc != null) {
|
|
256
|
+
throw new AstryxError(
|
|
257
|
+
`Refusing to add component "${name}": ${projectPath(path.relative(packageDir, existingDoc))} already defines it.`,
|
|
258
|
+
undefined,
|
|
259
|
+
ERROR_CODES.ERR_FILE_EXISTS,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
251
262
|
|
|
252
263
|
const sourcePath = projectPath(path.relative(packageDir, sourceFile));
|
|
253
264
|
const extensionlessPath = sourcePath.replace(/\.tsx?$/u, '');
|
|
@@ -731,3 +731,83 @@ describe('public dispatcher', () => {
|
|
|
731
731
|
).rejects.toThrow(/component, doc, template, codemod, agent-doc, or theme/);
|
|
732
732
|
});
|
|
733
733
|
});
|
|
734
|
+
|
|
735
|
+
describe('dry-run receipts match the real write', () => {
|
|
736
|
+
/** Package states each writer has to plan for. */
|
|
737
|
+
const STATES = {
|
|
738
|
+
'no manifest': {pkg: {files: ['dist']}},
|
|
739
|
+
'an empty manifest': {pkg: {}, manifest: 'export default {};\n'},
|
|
740
|
+
'declared roots and an exports map': {
|
|
741
|
+
pkg: {files: ['dist'], exports: {'.': './dist/index.js'}},
|
|
742
|
+
manifest:
|
|
743
|
+
"export default {\n components: './components',\n docs: './docs',\n templates: './templates',\n codemods: './codemods',\n themes: './themes',\n};\n",
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
/** @type {Array<[any, string, Record<string, unknown>]>} */
|
|
747
|
+
const KINDS = [
|
|
748
|
+
['component', 'AcmeWidget', {}],
|
|
749
|
+
['doc', 'deploying', {}],
|
|
750
|
+
['template', 'account-page', {}],
|
|
751
|
+
['template', 'account-card', {templateType: 'block'}],
|
|
752
|
+
['codemod', 'rename-widget', {to: '1.2.0'}],
|
|
753
|
+
['agent-doc', 'Run acme verify before finishing.', {}],
|
|
754
|
+
['theme', 'ocean', {}],
|
|
755
|
+
];
|
|
756
|
+
|
|
757
|
+
/** Every file under the package, keyed by project path, with its bytes. */
|
|
758
|
+
function snapshot() {
|
|
759
|
+
/** @type {Record<string, string>} */
|
|
760
|
+
const files = {};
|
|
761
|
+
/** @param {string} dir */
|
|
762
|
+
const walk = dir => {
|
|
763
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
764
|
+
const full = path.join(dir, entry.name);
|
|
765
|
+
if (entry.isDirectory()) walk(full);
|
|
766
|
+
else {
|
|
767
|
+
files[path.relative(tmpDir, full).split(path.sep).join('/')] =
|
|
768
|
+
fs.readFileSync(full, 'base64');
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
walk(tmpDir);
|
|
773
|
+
return files;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
for (const [stateName, state] of Object.entries(STATES)) {
|
|
777
|
+
for (const [kind, name, options] of KINDS) {
|
|
778
|
+
it(`${kind} ${name} in a package with ${stateName}`, async () => {
|
|
779
|
+
fs.writeFileSync(
|
|
780
|
+
path.join(tmpDir, 'package.json'),
|
|
781
|
+
`${JSON.stringify({name: '@acme/integration', version: '1.0.0', ...state.pkg}, null, 2)}\n`,
|
|
782
|
+
);
|
|
783
|
+
if (state.manifest != null) {
|
|
784
|
+
fs.writeFileSync(
|
|
785
|
+
path.join(tmpDir, 'astryx.integration.mjs'),
|
|
786
|
+
state.manifest,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
const before = snapshot();
|
|
790
|
+
|
|
791
|
+
const plan = await integrationAdd(kind, name, {
|
|
792
|
+
cwd: tmpDir,
|
|
793
|
+
dryRun: true,
|
|
794
|
+
...options,
|
|
795
|
+
});
|
|
796
|
+
expect(snapshot()).toEqual(before);
|
|
797
|
+
|
|
798
|
+
const written = await integrationAdd(kind, name, {
|
|
799
|
+
cwd: tmpDir,
|
|
800
|
+
...options,
|
|
801
|
+
});
|
|
802
|
+
const after = snapshot();
|
|
803
|
+
const changed = Object.keys(after)
|
|
804
|
+
.filter(file => after[file] !== before[file])
|
|
805
|
+
.sort();
|
|
806
|
+
|
|
807
|
+
expect(written.data.root).toEqual(plan.data.root);
|
|
808
|
+
expect(written.data.files).toEqual(plan.data.files);
|
|
809
|
+
expect([...written.data.files].sort()).toEqual(changed);
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
});
|
|
@@ -33,7 +33,8 @@ export const doc = {
|
|
|
33
33
|
{
|
|
34
34
|
name: 'name',
|
|
35
35
|
type: 'string',
|
|
36
|
-
description:
|
|
36
|
+
description:
|
|
37
|
+
'Contribution name: PascalCase for component, lowercase kebab-case for template, codemod, and theme, letters, digits, _ and - for doc, or the literal agent-doc line.',
|
|
37
38
|
required: true,
|
|
38
39
|
},
|
|
39
40
|
{
|
|
@@ -56,17 +57,19 @@ export const doc = {
|
|
|
56
57
|
{
|
|
57
58
|
name: 'options.replaces',
|
|
58
59
|
type: 'string',
|
|
59
|
-
description:
|
|
60
|
+
description:
|
|
61
|
+
'Existing topic to replace. Only valid for doc, and not with options.extends.',
|
|
60
62
|
},
|
|
61
63
|
{
|
|
62
64
|
name: 'options.extends',
|
|
63
65
|
type: 'string',
|
|
64
|
-
description:
|
|
66
|
+
description:
|
|
67
|
+
'Existing topic to extend. Only valid for doc, and not with options.replaces.',
|
|
65
68
|
},
|
|
66
69
|
{
|
|
67
70
|
name: 'options.to',
|
|
68
71
|
type: 'string',
|
|
69
|
-
description: 'Exact target semver. Required for codemod.',
|
|
72
|
+
description: 'Exact target semver (e.g. 1.2.0). Required for codemod and only valid there.',
|
|
70
73
|
},
|
|
71
74
|
],
|
|
72
75
|
returns: [
|
|
@@ -8,7 +8,7 @@ export const doc = {
|
|
|
8
8
|
displayName: 'integrationAddComponent()',
|
|
9
9
|
summary: 'Add a complete component contribution.',
|
|
10
10
|
description:
|
|
11
|
-
'Creates the same-stem component source and metadata pair, declares the components root on first use, preserves existing package allowlists, and publishes an exact source subpath when the package already has an exports map.',
|
|
11
|
+
'Creates the same-stem component source and metadata pair, declares the components root on first use, preserves existing package allowlists, and publishes an exact source subpath when the package already has an exports map. Refuses with ERR_FILE_EXISTS when a component doc anywhere under the components root already uses the name.',
|
|
12
12
|
importPath: '@astryxdesign/cli/api',
|
|
13
13
|
signature:
|
|
14
14
|
'integrationAddComponent(name: string, options?: IntegrationAddComponentOptions): Promise<IntegrationAddResponse>',
|
|
@@ -400,8 +400,10 @@ export async function validateLocalIntegration(cwd = process.cwd()) {
|
|
|
400
400
|
try {
|
|
401
401
|
pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
402
402
|
} catch (err) {
|
|
403
|
+
// A null name means "no manifest here"; this package has one.
|
|
403
404
|
return {
|
|
404
405
|
found: true,
|
|
406
|
+
name: '(local package)',
|
|
405
407
|
manifestFile: manifests[0],
|
|
406
408
|
issues: [
|
|
407
409
|
error(
|
|
@@ -13,10 +13,16 @@ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
|
13
13
|
import * as fs from 'node:fs';
|
|
14
14
|
import * as path from 'node:path';
|
|
15
15
|
import {
|
|
16
|
+
validateIntegration,
|
|
16
17
|
validateLocalIntegration,
|
|
17
18
|
validateInstalledIntegration,
|
|
18
19
|
summarizeIssues,
|
|
19
20
|
} from './validate-integration.mjs';
|
|
21
|
+
import {
|
|
22
|
+
integrationComponentConflicts,
|
|
23
|
+
integrationDocConflicts,
|
|
24
|
+
integrationTemplateConflicts,
|
|
25
|
+
} from './authoring-checks.mjs';
|
|
20
26
|
|
|
21
27
|
let tmpDir;
|
|
22
28
|
|
|
@@ -472,3 +478,88 @@ describe('validate-integration API', () => {
|
|
|
472
478
|
expect(byCode(result.issues, 'invalid_package_spec')).toHaveLength(1);
|
|
473
479
|
});
|
|
474
480
|
});
|
|
481
|
+
|
|
482
|
+
describe('integration diagnostics are read-only', () => {
|
|
483
|
+
/** Every directory and file under `dir`, with file bytes. */
|
|
484
|
+
function snapshot(dir) {
|
|
485
|
+
/** @type {Record<string, string>} */
|
|
486
|
+
const entries = {};
|
|
487
|
+
/** @param {string} current */
|
|
488
|
+
const walk = current => {
|
|
489
|
+
for (const entry of fs.readdirSync(current, {withFileTypes: true})) {
|
|
490
|
+
const full = path.join(current, entry.name);
|
|
491
|
+
const key = path.relative(dir, full);
|
|
492
|
+
if (entry.isDirectory()) {
|
|
493
|
+
entries[`${key}/`] = '';
|
|
494
|
+
walk(full);
|
|
495
|
+
} else {
|
|
496
|
+
entries[key] = fs.readFileSync(full, 'base64');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
walk(dir);
|
|
501
|
+
return entries;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A package with valid, broken, conflicting, and unreachable contributions. */
|
|
505
|
+
function writeDiagnosedPackage(dir) {
|
|
506
|
+
writePackage(dir, {
|
|
507
|
+
manifest:
|
|
508
|
+
"export default {\n components: './components',\n templates: './templates',\n docs: './docs',\n codemods: './gone',\n themes: './themes',\n};\n",
|
|
509
|
+
});
|
|
510
|
+
const write = (relative, contents) => {
|
|
511
|
+
fs.mkdirSync(path.dirname(path.join(dir, relative)), {recursive: true});
|
|
512
|
+
fs.writeFileSync(path.join(dir, relative), contents);
|
|
513
|
+
};
|
|
514
|
+
write(
|
|
515
|
+
'components/Card.doc.mjs',
|
|
516
|
+
"export default {type: 'component', name: 'Card', props: []};\n",
|
|
517
|
+
);
|
|
518
|
+
write('components/Card.tsx', 'export function Card() { return null; }\n');
|
|
519
|
+
write(
|
|
520
|
+
'components/Orphan.doc.mjs',
|
|
521
|
+
"export default {type: 'component', name: 'Orphan', props: []};\n",
|
|
522
|
+
);
|
|
523
|
+
write('templates/dash.doc.mjs', "export default {type: 'page', name: 'Dash'};\n");
|
|
524
|
+
write(
|
|
525
|
+
'docs/theme.doc.mjs',
|
|
526
|
+
"export default {type: 'generic', name: 'theme', title: 'Theme', description: 'x', sections: [{title: 'A', content: [{type: 'prose', text: 'x'}]}]};\n",
|
|
527
|
+
);
|
|
528
|
+
write('themes/manifest.json', '{"version": 1, "themes": [{"slug": 7}]}\n');
|
|
529
|
+
write(
|
|
530
|
+
'stray/Stray.doc.mjs',
|
|
531
|
+
"export default {type: 'component', name: 'Stray', props: []};\n",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
it('leaves local and installed packages byte-for-byte unchanged', async () => {
|
|
536
|
+
const pkgDir = path.join(tmpDir, 'pkg');
|
|
537
|
+
writeDiagnosedPackage(pkgDir);
|
|
538
|
+
const consumer = path.join(tmpDir, 'consumer');
|
|
539
|
+
fs.mkdirSync(consumer, {recursive: true});
|
|
540
|
+
fs.writeFileSync(
|
|
541
|
+
path.join(consumer, 'package.json'),
|
|
542
|
+
JSON.stringify({name: 'consumer'}),
|
|
543
|
+
);
|
|
544
|
+
writeDiagnosedPackage(path.join(consumer, 'node_modules', '@acme', 'widgets'));
|
|
545
|
+
const before = {pkg: snapshot(pkgDir), consumer: snapshot(consumer)};
|
|
546
|
+
|
|
547
|
+
const local = await validateIntegration(undefined, {cwd: pkgDir});
|
|
548
|
+
const installed = await validateIntegration('@acme/widgets', {
|
|
549
|
+
cwd: consumer,
|
|
550
|
+
});
|
|
551
|
+
for (const [pkg, cwd] of [
|
|
552
|
+
[undefined, pkgDir],
|
|
553
|
+
['@acme/widgets', consumer],
|
|
554
|
+
]) {
|
|
555
|
+
await integrationTemplateConflicts(pkg, {cwd});
|
|
556
|
+
await integrationComponentConflicts(pkg, {cwd});
|
|
557
|
+
await integrationDocConflicts(pkg, {cwd});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
expect(summarizeIssues(local.data.issues).errors).toBeGreaterThan(0);
|
|
561
|
+
expect(byCode(local.data.issues, 'unreachable_contribution')).toHaveLength(1);
|
|
562
|
+
expect(summarizeIssues(installed.data.issues).errors).toBeGreaterThan(0);
|
|
563
|
+
expect({pkg: snapshot(pkgDir), consumer: snapshot(consumer)}).toEqual(before);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
@@ -45,7 +45,7 @@ export const doc = {
|
|
|
45
45
|
{
|
|
46
46
|
type: 'integration.validate',
|
|
47
47
|
description:
|
|
48
|
-
'The result envelope: `data.name` and `data.version` of the validated package (both null when no local manifest is found), plus `data.issues`, an AstryxIntegrationIssue[] of {code, severity: `warning` | `error`, message}.',
|
|
48
|
+
'The result envelope: `data.name` and `data.version` of the validated package (both null only when no local manifest is found; `data.name` is `(local package)` when the local package.json has no readable name), plus `data.issues`, an AstryxIntegrationIssue[] of {code, severity: `warning` | `error`, message}.',
|
|
49
49
|
},
|
|
50
50
|
],
|
|
51
51
|
examples: [
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file The published `@astryxdesign/cli/json` envelope types carry
|
|
5
|
+
* `apiVersion`, the field every `--json` envelope has. Type-checks a consumer
|
|
6
|
+
* fixture that reads it from each envelope shape.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {describe, it, expect} from 'vitest';
|
|
10
|
+
import {spawnSync} from 'node:child_process';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import {createRequire} from 'node:module';
|
|
15
|
+
import {fileURLToPath} from 'node:url';
|
|
16
|
+
|
|
17
|
+
const INDEX = fileURLToPath(new URL('./index.ts', import.meta.url));
|
|
18
|
+
const TSC = createRequire(import.meta.url).resolve('typescript/bin/tsc');
|
|
19
|
+
|
|
20
|
+
const FIXTURE = `
|
|
21
|
+
import type {
|
|
22
|
+
assertResponse,
|
|
23
|
+
CLIError,
|
|
24
|
+
CLIUnsupportedError,
|
|
25
|
+
parseResponse,
|
|
26
|
+
} from ${JSON.stringify(INDEX)};
|
|
27
|
+
|
|
28
|
+
declare const error: CLIError;
|
|
29
|
+
declare const unsupported: CLIUnsupportedError;
|
|
30
|
+
declare const parsed: Exclude<ReturnType<typeof parseResponse>, CLIError | CLIUnsupportedError>;
|
|
31
|
+
declare const asserted: ReturnType<typeof assertResponse<'manifest'>>;
|
|
32
|
+
|
|
33
|
+
export const versions: number[] = [
|
|
34
|
+
error.apiVersion,
|
|
35
|
+
unsupported.apiVersion,
|
|
36
|
+
parsed.apiVersion,
|
|
37
|
+
asserted.apiVersion,
|
|
38
|
+
];
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
describe('@astryxdesign/cli/json envelope types', () => {
|
|
42
|
+
it('declare apiVersion on the success and error envelopes', () => {
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-envelope-types-'));
|
|
44
|
+
try {
|
|
45
|
+
fs.writeFileSync(path.join(dir, 'fixture.ts'), FIXTURE);
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
path.join(dir, 'tsconfig.json'),
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
compilerOptions: {
|
|
50
|
+
target: 'ES2022',
|
|
51
|
+
module: 'ESNext',
|
|
52
|
+
moduleResolution: 'bundler',
|
|
53
|
+
strict: true,
|
|
54
|
+
noEmit: true,
|
|
55
|
+
skipLibCheck: true,
|
|
56
|
+
allowImportingTsExtensions: true,
|
|
57
|
+
// index.ts re-exports JSDoc types from .type.mjs files; read them
|
|
58
|
+
// from source, as tsconfig.json-api.json does, not from generated
|
|
59
|
+
// declarations that exist only after a build.
|
|
60
|
+
allowJs: true,
|
|
61
|
+
types: [],
|
|
62
|
+
},
|
|
63
|
+
files: ['fixture.ts'],
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const res = spawnSync(process.execPath, [TSC, '-p', dir], {
|
|
67
|
+
encoding: 'utf-8',
|
|
68
|
+
timeout: 60_000,
|
|
69
|
+
});
|
|
70
|
+
expect(res.stdout + res.stderr).toBe('');
|
|
71
|
+
expect(res.status).toBe(0);
|
|
72
|
+
} finally {
|
|
73
|
+
fs.rmSync(dir, {recursive: true, force: true});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -16,7 +16,7 @@ export const doc = {
|
|
|
16
16
|
description:
|
|
17
17
|
'The entry point for consuming the CLI as a subprocess. A JSON string is parsed; ' +
|
|
18
18
|
'an already-parsed object is passed through unchanged, so it is safe to call on ' +
|
|
19
|
-
'either. It returns the structural { type, data, meta? } envelope without narrowing: ' +
|
|
19
|
+
'either. It returns the structural { apiVersion, type, data, meta? } envelope without narrowing: ' +
|
|
20
20
|
'`data` stays unknown until you discriminate on `type` (or use assertResponse).',
|
|
21
21
|
importPath: '@astryxdesign/cli/json',
|
|
22
22
|
signature: 'parseResponse(raw: unknown): any',
|
|
@@ -34,7 +34,7 @@ export const doc = {
|
|
|
34
34
|
{
|
|
35
35
|
type: 'any',
|
|
36
36
|
description:
|
|
37
|
-
'The { type, data, meta? } envelope, or a CLIError envelope. The published signature is intentionally untyped; narrow it by casting to the matching *Response type exported from @astryxdesign/cli/json.',
|
|
37
|
+
'The { apiVersion, type, data, meta? } envelope, or a CLIError envelope ({ apiVersion, error, code, suggestions? }). The published signature is intentionally untyped; narrow it by casting to the matching *Response type exported from @astryxdesign/cli/json.',
|
|
38
38
|
},
|
|
39
39
|
],
|
|
40
40
|
throws: [
|
|
@@ -120,18 +120,20 @@ export async function layoutExpand(expression, options = {}) {
|
|
|
120
120
|
|
|
121
121
|
let written = null;
|
|
122
122
|
if (targetPath) {
|
|
123
|
-
|
|
123
|
+
// The guard sees the file that will be written, not only its directory:
|
|
124
|
+
// a symlink at that name would otherwise carry the write outside the root.
|
|
125
|
+
const fileTarget = isFilePathArg(targetPath)
|
|
126
|
+
? targetPath
|
|
127
|
+
: path.join(targetPath, `${componentName}.tsx`);
|
|
128
|
+
let filePath;
|
|
124
129
|
try {
|
|
125
|
-
|
|
130
|
+
filePath = assertWithin(fileTarget, cwd, {label: 'layout target path'});
|
|
126
131
|
} catch (err) {
|
|
127
132
|
if (err instanceof PathSafetyError) {
|
|
128
133
|
throw new AstryxError(err.message, undefined, ERROR_CODES.ERR_PATH_TRAVERSAL);
|
|
129
134
|
}
|
|
130
135
|
throw err;
|
|
131
136
|
}
|
|
132
|
-
const filePath = isFilePathArg(targetPath)
|
|
133
|
-
? resolved
|
|
134
|
-
: path.join(resolved, `${componentName}.tsx`);
|
|
135
137
|
fs.mkdirSync(path.dirname(filePath), {recursive: true});
|
|
136
138
|
fs.writeFileSync(filePath, result.code);
|
|
137
139
|
written = path.relative(cwd, filePath);
|