ember.nvp 1.9.0 → 1.10.0

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 (43) hide show
  1. package/.release-plan.json +22 -4
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +65 -4
  4. package/package.json +2 -1
  5. package/packages/rolldown/package.json +6 -2
  6. package/packages/rolldown/src/dts.test.ts +105 -1
  7. package/packages/rolldown/src/isolated-declarations.ts +67 -61
  8. package/packages/rolldown/src/transform.test.ts +212 -15
  9. package/packages/rolldown/src/transform.ts +106 -26
  10. package/src/cli/args.js +103 -14
  11. package/src/cli/generator.js +2 -1
  12. package/src/cli/help.js +66 -16
  13. package/src/cli/index.js +15 -0
  14. package/src/cli/questions/layer-options.js +130 -0
  15. package/src/cli/questions/validate-option.js +106 -0
  16. package/src/layers/eslint-bundled/app.test.ts +182 -0
  17. package/src/layers/eslint-bundled/index.js +172 -0
  18. package/src/layers/expect-type/index.js +11 -1
  19. package/src/layers/github-actions/app.test.ts +1 -1
  20. package/src/layers/typescript/app.test.ts +6 -0
  21. package/src/layers/typescript/index.js +48 -148
  22. package/src/layers/typescript/typescript-6.js +102 -0
  23. package/src/layers/typescript/typescript-7.js +177 -0
  24. package/src/utils/manifest.js +13 -0
  25. package/src/utils/npm.js +27 -0
  26. package/src/utils/project.js +23 -0
  27. package/src/utils/types.ts +52 -1
  28. package/src/utils/typescript.js +147 -0
  29. package/test/ember.nvp/base-library.test.ts +11 -0
  30. package/test/ember.nvp/cli.test.ts +86 -1
  31. package/test/ember.nvp/layer-options.test.ts +248 -0
  32. package/test/ember.nvp/layer-readme.test.ts +4 -2
  33. package/test/ember.nvp/layer-typescript.test.ts +302 -0
  34. package/test/ember.nvp/layers.test.ts +1 -2
  35. package/test/ember.nvp/permutations/shared.ts +3 -4
  36. package/test/ember.nvp/validate-option.test.ts +152 -0
  37. package/test/test-helpers/permutate.test.ts +2 -7
  38. package/test/test-helpers.ts +8 -1
  39. package/src/layers/eslint-bundled-ember/index.js +0 -91
  40. package/src/layers/eslint-bundled-nvp/app.test.ts +0 -91
  41. package/src/layers/eslint-bundled-nvp/index.js +0 -91
  42. /package/src/layers/{eslint-bundled-ember/files → eslint-bundled/files/ember}/eslint.config.js +0 -0
  43. /package/src/layers/{eslint-bundled-nvp/files → eslint-bundled/files/nvp}/eslint.config.js +0 -0
@@ -1,43 +1,20 @@
1
- import { js, packageJson } from "ember-apply";
2
- import { join } from "node:path";
3
- import { cp } from "node:fs/promises";
4
- import { existsSync } from "node:fs";
5
- import { hasConfiguredTSBabel, prependPlugin } from "#utils/babel.js";
6
- import { getLatest } from "#utils/npm.js";
1
+ import { packageJson } from "ember-apply";
2
+ import { readManifest } from "#utils/manifest.js";
7
3
  import { isLibraryType } from "#utils/project-type.js";
8
-
9
- const bases = join(import.meta.dirname, "../../bases");
10
- const appBase = join(bases, "minimal-app/files");
11
- const extensionBase = join(bases, "minimal-extension/files");
12
- const libraryBase = join(bases, "minimal-library/files");
13
- const customElementBase = join(bases, "minimal-custom-element/files");
14
-
15
- const sharedDeps = {
16
- "@glint/ember-tsc": "^1.0.8",
17
- "@glint/template": "^1.7.3",
18
- "@glint/tsserver-plugin": "^2.0.8",
19
- typescript: "^6.0.3",
20
- };
21
-
22
- const appDeps = {
23
- // Apps strip types via their own babel.config.js.
24
- // Libraries have no babel config: ember() handles type stripping.
25
- "@babel/plugin-transform-typescript": "^7.28.5",
26
- "@ember/app-tsconfig": "^2.0.0",
27
- };
28
-
29
- const libraryDeps = {
30
- "@ember/library-tsconfig": "^2.0.0",
31
- };
4
+ import { usesTypeScript7 } from "#utils/typescript.js";
5
+ import * as typescript6 from "./typescript-6.js";
6
+ import * as typescript7 from "./typescript-7.js";
32
7
 
33
8
  /**
9
+ * A project that has TypeScript 7 keeps it, even when version 6 is selected.
10
+ * Nothing moves a project from 7 back to 6 yet.
11
+ *
34
12
  * @param {import('#utils/project.js').Project} project
35
13
  */
36
- function depsFor(project) {
37
- return {
38
- ...sharedDeps,
39
- ...(project.isLibrary ? libraryDeps : appDeps),
40
- };
14
+ async function setupFor(project) {
15
+ let manifest = await packageJson.read(project.directory);
16
+
17
+ return usesTypeScript7(project, manifest) ? typescript7 : typescript6;
41
18
  }
42
19
 
43
20
  /**
@@ -56,14 +33,38 @@ export default {
56
33
  return isLibraryType(projectType);
57
34
  },
58
35
 
36
+ options: {
37
+ version: {
38
+ type: "select",
39
+ prompt: "Which TypeScript version?",
40
+ default: "7",
41
+ options: [
42
+ {
43
+ value: "7",
44
+ label: "TypeScript 7.1+",
45
+ hint: "native tsc, checks .gts and .gjs with ember-content-mapper",
46
+ },
47
+ {
48
+ value: "6",
49
+ label: "TypeScript 6",
50
+ hint: "checks with Glint's ember-tsc",
51
+ },
52
+ ],
53
+ async detect(project) {
54
+ let deps = (await readManifest(project)).devDependencies ?? {};
55
+
56
+ if (deps["@typescript/native"]) return "7";
57
+ if (deps.typescript) return "6";
58
+
59
+ return undefined;
60
+ },
61
+ },
62
+ },
63
+
59
64
  async run(project) {
60
- /**
61
- * TODO:
62
- * - if jsconfig exists, switch to tsconfig
63
- */
64
- await addTSConfig(project);
65
- await updatePackageJson(project);
66
- await updateBabelConfig(project);
65
+ let setup = await setupFor(project);
66
+
67
+ await setup.run(project);
67
68
  },
68
69
 
69
70
  /**
@@ -79,37 +80,9 @@ export default {
79
80
  * @returns {Promise<boolean>}
80
81
  */
81
82
  async isSetup(project, explain) {
82
- const reasons = [];
83
-
84
- if (!project.hasFile("tsconfig.json")) {
85
- if (!explain) return false;
86
-
87
- reasons.push("tsconfig.json is missing");
88
- }
89
-
90
- // Only projects with their own babel config need the TS plugin in it.
91
- // Without one (libraries), ember() strips types.
92
- if (project.hasFile("babel.config.js") && !(await hasConfiguredTSBabel(project))) {
93
- if (!explain) return false;
94
-
95
- reasons.push(`babel.config is missing @babel/plugin-transform-typescript`);
96
- }
97
-
98
83
  let manifest = await packageJson.read(project.directory);
99
-
100
- if (!manifest.scripts?.["lint:types"]) {
101
- if (!explain) return false;
102
-
103
- reasons.push(`package.json is missing the "lint:types" script`);
104
- }
105
-
106
- for (let dep of Object.keys(depsFor(project))) {
107
- if (!manifest.devDependencies?.[dep]) {
108
- if (!explain) return false;
109
-
110
- reasons.push(`package.json is missing ${dep} in devDependencies`);
111
- }
112
- }
84
+ let setup = usesTypeScript7(project, manifest) ? typescript7 : typescript6;
85
+ let reasons = await setup.reasons(project, manifest);
113
86
 
114
87
  if (explain) {
115
88
  return {
@@ -124,82 +97,9 @@ export default {
124
97
  /**
125
98
  * @param {import('#utils/project.js').Project} project
126
99
  */
127
- readme(project) {
128
- return `### TypeScript
100
+ async readme(project) {
101
+ let setup = await setupFor(project);
129
102
 
130
- This project uses TypeScript and Glint for static type checking.
131
-
132
- - \`${project.runPrefix} lint:types\` - Typecheck code with Glint/TypeScript`;
103
+ return setup.readme(project);
133
104
  },
134
105
  };
135
-
136
- /**
137
- * @param {import('#utils/project.js').Project} project
138
- */
139
- async function updatePackageJson(project) {
140
- await packageJson.modify(async (json) => {
141
- json.scripts ||= {};
142
- json.devDependencies ||= {};
143
-
144
- Object.assign(json.scripts, {
145
- "lint:types": "ember-tsc --noEmit",
146
- });
147
- Object.assign(json.devDependencies, await getLatest(depsFor(project)));
148
- }, project.directory);
149
- }
150
-
151
- /**
152
- * Copies the base's tsconfig, unless the project already has one.
153
- *
154
- * @param {import('#utils/project.js').Project} project
155
- */
156
- async function addTSConfig(project) {
157
- if (existsSync(project.path("tsconfig.json"))) {
158
- return;
159
- }
160
-
161
- if (project.type === "app") {
162
- await cp(join(appBase, "tsconfig.json"), project.path("tsconfig.json"));
163
- return;
164
- }
165
-
166
- if (project.type === "extension") {
167
- await cp(join(extensionBase, "tsconfig.json"), project.path("tsconfig.json"));
168
- return;
169
- }
170
-
171
- if (project.type === "library") {
172
- await cp(join(libraryBase, "tsconfig.json"), project.path("tsconfig.json"));
173
- return;
174
- }
175
-
176
- if (project.type === "custom-element") {
177
- await cp(join(customElementBase, "tsconfig.json"), project.path("tsconfig.json"));
178
- }
179
- }
180
-
181
- /**
182
- * @param {import('#utils/project.js').Project} project
183
- */
184
- async function updateBabelConfig(project) {
185
- if (!project.hasFile("babel.config.js")) {
186
- // Nothing to patch (libraries): ember() strips types.
187
- return;
188
- }
189
-
190
- if (await hasConfiguredTSBabel(project)) {
191
- return;
192
- }
193
-
194
- await prependPlugin(
195
- project,
196
- `[
197
- "@babel/plugin-transform-typescript",
198
- {
199
- allExtensions: true,
200
- onlyRemoveTypeImports: true,
201
- allowDeclareFields: true,
202
- },
203
- ]`,
204
- );
205
- }
@@ -0,0 +1,102 @@
1
+ import { packageJson } from "ember-apply";
2
+ import { hasConfiguredTSBabel } from "#utils/babel.js";
3
+ import { getLatest } from "#utils/npm.js";
4
+ import { addTSConfig, updateBabelConfig } from "#utils/typescript.js";
5
+
6
+ const sharedDeps = {
7
+ "@glint/ember-tsc": "^1.0.8",
8
+ "@glint/template": "^1.7.3",
9
+ "@glint/tsserver-plugin": "^2.0.8",
10
+ typescript: "^6.0.3",
11
+ };
12
+
13
+ const appDeps = {
14
+ // Apps strip types via their own babel.config.js.
15
+ // Libraries have no babel config: ember() handles type stripping.
16
+ "@babel/plugin-transform-typescript": "^7.28.5",
17
+ "@ember/app-tsconfig": "^2.0.0",
18
+ };
19
+
20
+ const libraryDeps = {
21
+ "@ember/library-tsconfig": "^2.0.0",
22
+ };
23
+
24
+ /**
25
+ * @param {import('#utils/project.js').Project} project
26
+ */
27
+ function depsFor(project) {
28
+ return {
29
+ ...sharedDeps,
30
+ ...(project.isLibrary ? libraryDeps : appDeps),
31
+ };
32
+ }
33
+
34
+ /**
35
+ * @param {import('#utils/project.js').Project} project
36
+ */
37
+ export async function run(project) {
38
+ /**
39
+ * TODO:
40
+ * - if jsconfig exists, switch to tsconfig
41
+ */
42
+ await addTSConfig(project);
43
+ await updatePackageJson(project);
44
+ await updateBabelConfig(project);
45
+ }
46
+
47
+ /**
48
+ * @param {import('#utils/project.js').Project} project
49
+ * @param {Record<string, any>} manifest
50
+ * @returns {Promise<string[]>} why TypeScript 6 is not set up
51
+ */
52
+ export async function reasons(project, manifest) {
53
+ const reasons = [];
54
+
55
+ if (!project.hasFile("tsconfig.json")) {
56
+ reasons.push("tsconfig.json is missing");
57
+ }
58
+
59
+ // Only projects with their own babel config need the TS plugin in it.
60
+ // Without one (libraries), ember() strips types.
61
+ if (project.hasFile("babel.config.js") && !(await hasConfiguredTSBabel(project))) {
62
+ reasons.push(`babel.config is missing @babel/plugin-transform-typescript`);
63
+ }
64
+
65
+ if (!manifest.scripts?.["lint:types"]) {
66
+ reasons.push(`package.json is missing the "lint:types" script`);
67
+ }
68
+
69
+ for (let dep of Object.keys(depsFor(project))) {
70
+ if (!manifest.devDependencies?.[dep]) {
71
+ reasons.push(`package.json is missing ${dep} in devDependencies`);
72
+ }
73
+ }
74
+
75
+ return reasons;
76
+ }
77
+
78
+ /**
79
+ * @param {import('#utils/project.js').Project} project
80
+ */
81
+ export function readme(project) {
82
+ return `### TypeScript
83
+
84
+ This project uses TypeScript and Glint for static type checking.
85
+
86
+ - \`${project.runPrefix} lint:types\` - Typecheck code with Glint/TypeScript`;
87
+ }
88
+
89
+ /**
90
+ * @param {import('#utils/project.js').Project} project
91
+ */
92
+ async function updatePackageJson(project) {
93
+ await packageJson.modify(async (json) => {
94
+ json.scripts ||= {};
95
+ json.devDependencies ||= {};
96
+
97
+ Object.assign(json.scripts, {
98
+ "lint:types": "ember-tsc --noEmit",
99
+ });
100
+ Object.assign(json.devDependencies, await getLatest(depsFor(project)));
101
+ }, project.directory);
102
+ }
@@ -0,0 +1,177 @@
1
+ import { packageJson, tsconfig } from "ember-apply";
2
+ import { hasConfiguredTSBabel } from "#utils/babel.js";
3
+ import { getLatest } from "#utils/npm.js";
4
+ import {
5
+ TYPESCRIPT_7,
6
+ addTSConfig,
7
+ hasTypeScript6,
8
+ syncTypeScript6,
9
+ updateBabelConfig,
10
+ } from "#utils/typescript.js";
11
+
12
+ const MAPPER = "ember-content-mapper";
13
+
14
+ /**
15
+ * TypeScript 7 does not load tsserver plugins.
16
+ * The content mapper replaces this one.
17
+ */
18
+ const TSSERVER_PLUGIN = "@glint/tsserver-plugin";
19
+
20
+ /**
21
+ * `@typescript/native` owns the `tsc` bin.
22
+ * `typescript` is left to eslint, which needs TypeScript 6, see `syncTypeScript6`.
23
+ *
24
+ * The mapper transforms with `@glint/ember-tsc` and references its types.
25
+ * `@glint/template` types the signatures written by hand.
26
+ */
27
+ const sharedDeps = {
28
+ "@glint/ember-tsc": "^1.11.0",
29
+ "@glint/template": "^1.7.3",
30
+ "@typescript/native": TYPESCRIPT_7,
31
+ [MAPPER]: "^0.3.1",
32
+ };
33
+
34
+ const appDeps = {
35
+ // Apps strip types via their own babel.config.js.
36
+ // Libraries have no babel config: ember() handles type stripping.
37
+ "@babel/plugin-transform-typescript": "^7.28.5",
38
+ "@ember/app-tsconfig": "^2.0.0",
39
+ };
40
+
41
+ const libraryDeps = {
42
+ "@ember/library-tsconfig": "^2.0.0",
43
+ };
44
+
45
+ const scripts = {
46
+ "lint:types": "tsc --noEmit --runExternalCode",
47
+ };
48
+
49
+ /**
50
+ * @param {import('#utils/project.js').Project} project
51
+ */
52
+ function depsFor(project) {
53
+ return {
54
+ ...sharedDeps,
55
+ ...(project.isLibrary ? libraryDeps : appDeps),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * @param {Record<string, any>} config
61
+ * @returns {boolean}
62
+ */
63
+ function hasContentMapper(config) {
64
+ return (
65
+ Array.isArray(config.contentMappers) &&
66
+ config.contentMappers.some((/** @type {any} */ mapper) => mapper?.package === MAPPER)
67
+ );
68
+ }
69
+
70
+ /**
71
+ * @param {import('#utils/project.js').Project} project
72
+ */
73
+ export async function run(project) {
74
+ await addTSConfig(project);
75
+ await updateTSConfig(project);
76
+ await updatePackageJson(project);
77
+ await syncTypeScript6(project);
78
+ await updateBabelConfig(project);
79
+ }
80
+
81
+ /**
82
+ * @param {import('#utils/project.js').Project} project
83
+ * @param {Record<string, any>} manifest
84
+ * @returns {Promise<string[]>} why TypeScript 7 is not set up
85
+ */
86
+ export async function reasons(project, manifest) {
87
+ const reasons = [];
88
+
89
+ if (!project.hasFile("tsconfig.json")) {
90
+ reasons.push("tsconfig.json is missing");
91
+ } else if (!hasContentMapper(await tsconfig.read(project.directory))) {
92
+ reasons.push(`tsconfig.json is missing the ${MAPPER} entry in contentMappers`);
93
+ }
94
+
95
+ // Only projects with their own babel config need the TS plugin in it.
96
+ // Without one (libraries), ember() strips types.
97
+ if (project.hasFile("babel.config.js") && !(await hasConfiguredTSBabel(project))) {
98
+ reasons.push(`babel.config is missing @babel/plugin-transform-typescript`);
99
+ }
100
+
101
+ if (!manifest.scripts?.["lint:types"]?.includes("--runExternalCode")) {
102
+ reasons.push(`package.json's "lint:types" script is missing --runExternalCode`);
103
+ }
104
+
105
+ for (let dep of Object.keys(depsFor(project))) {
106
+ if (!manifest.devDependencies?.[dep]) {
107
+ reasons.push(`package.json is missing ${dep} in devDependencies`);
108
+ }
109
+ }
110
+
111
+ if (manifest.devDependencies?.typescript && !hasTypeScript6(manifest)) {
112
+ reasons.push(`package.json's typescript must be @typescript/typescript6, or tsc is ambiguous`);
113
+ }
114
+
115
+ return reasons;
116
+ }
117
+
118
+ /**
119
+ * @param {import('#utils/project.js').Project} project
120
+ */
121
+ export function readme(project) {
122
+ return `### TypeScript
123
+
124
+ This project uses TypeScript 7 and [ember-content-mapper](https://github.com/NullVoxPopuli/ember-content-mapper) for static type checking.
125
+
126
+ - \`${project.runPrefix} lint:types\` - Typecheck code with TypeScript
127
+ - Imports of \`.gts\` and \`.gjs\` modules must include the extension
128
+ - Editor setup: see [ember-content-mapper's editors section](https://github.com/NullVoxPopuli/ember-content-mapper#editors)`;
129
+ }
130
+
131
+ /**
132
+ * @param {import('#utils/project.js').Project} project
133
+ */
134
+ async function updateTSConfig(project) {
135
+ let config = await tsconfig.read(project.directory);
136
+
137
+ if (hasContentMapper(config) && !hasTSServerPlugin(config)) return;
138
+
139
+ await tsconfig.modify((json) => {
140
+ if (!hasContentMapper(json)) {
141
+ json.contentMappers ||= [];
142
+ json.contentMappers.push({ package: MAPPER, extensions: [".gts", ".gjs"] });
143
+ }
144
+
145
+ if (hasTSServerPlugin(json)) {
146
+ json.compilerOptions.plugins = json.compilerOptions.plugins.filter(
147
+ (/** @type {any} */ plugin) => plugin?.name !== TSSERVER_PLUGIN,
148
+ );
149
+
150
+ if (json.compilerOptions.plugins.length === 0) {
151
+ delete json.compilerOptions.plugins;
152
+ }
153
+ }
154
+ }, project.directory);
155
+ }
156
+
157
+ /**
158
+ * @param {Record<string, any>} config
159
+ * @returns {boolean}
160
+ */
161
+ function hasTSServerPlugin(config) {
162
+ let plugins = config.compilerOptions?.plugins;
163
+
164
+ return (
165
+ Array.isArray(plugins) &&
166
+ plugins.some((/** @type {any} */ plugin) => plugin?.name === TSSERVER_PLUGIN)
167
+ );
168
+ }
169
+
170
+ /**
171
+ * @param {import('#utils/project.js').Project} project
172
+ */
173
+ async function updatePackageJson(project) {
174
+ await packageJson.addScripts(scripts, project.directory);
175
+ await packageJson.addDevDependencies(await getLatest(depsFor(project)), project.directory);
176
+ await packageJson.removeDevDependencies([TSSERVER_PLUGIN], project.directory);
177
+ }
@@ -1,3 +1,4 @@
1
+ import { packageJson } from "ember-apply";
1
2
  import { hasAllKeys } from "./object.js";
2
3
 
3
4
  /**
@@ -7,3 +8,15 @@ import { hasAllKeys } from "./object.js";
7
8
  export function hasDevDeps(manifest, listOrObject) {
8
9
  return hasAllKeys(manifest.devDependencies ?? {}, listOrObject);
9
10
  }
11
+
12
+ /**
13
+ * Like `packageJson.read`, but an empty object when there is no package.json yet
14
+ *
15
+ * @param {import('./project.js').Project} project
16
+ * @returns {Promise<Record<string, any>>}
17
+ */
18
+ export async function readManifest(project) {
19
+ if (!project.hasFile("package.json")) return {};
20
+
21
+ return packageJson.read(project.directory);
22
+ }
package/src/utils/npm.js CHANGED
@@ -35,6 +35,7 @@ export async function getLatest(deps) {
35
35
  }
36
36
 
37
37
  let version;
38
+ let alias = parseAlias(range);
38
39
 
39
40
  /**
40
41
  * HACK FOR CI.
@@ -54,6 +55,8 @@ export async function getLatest(deps) {
54
55
  if (needsLocalLink && LOCAL_PACKAGES[dep]) {
55
56
  version =
56
57
  "link:" + resolve(join(import.meta.dirname, "../../packages", LOCAL_PACKAGES[dep]));
58
+ } else if (alias) {
59
+ version = `npm:${alias.name}@${await latestVersion(alias.name, { version: alias.range })}`;
57
60
  } else {
58
61
  if (range == "workspace:*") {
59
62
  range = "latest";
@@ -71,6 +74,30 @@ export async function getLatest(deps) {
71
74
  return Object.fromEntries(results);
72
75
  }
73
76
 
77
+ /**
78
+ * An npm alias installs a package under another name:
79
+ *
80
+ * "@typescript/native": "npm:typescript@^7.1.0-0"
81
+ *
82
+ * The range belongs to the aliased package.
83
+ *
84
+ * @param {string} range
85
+ * @returns {{ name: string; range: string } | undefined}
86
+ */
87
+ function parseAlias(range) {
88
+ if (!range.startsWith("npm:")) return;
89
+
90
+ let spec = range.slice("npm:".length);
91
+ // The last `@` starts the range. A scope's `@` is at index 0.
92
+ let at = spec.lastIndexOf("@");
93
+
94
+ if (at <= 0) {
95
+ return { name: spec, range: "latest" };
96
+ }
97
+
98
+ return { name: spec.slice(0, at), range: spec.slice(at + 1) };
99
+ }
100
+
74
101
  async function needsWorkspace() {
75
102
  if (process.env.GITHUB_REPOSITORY === "NullVoxPopuli/ember.nvp") return true;
76
103
 
@@ -80,6 +80,29 @@ export class Project {
80
80
  return this.#desires;
81
81
  }
82
82
 
83
+ /**
84
+ * Get resolved options for a specific layer, merging defaults with user-supplied options.
85
+ *
86
+ * @param {string} layerName
87
+ * @returns {Record<string, any>}
88
+ */
89
+ getLayerOptions(layerName) {
90
+ let layer = this.desires.layers?.find((l) => l.name === layerName);
91
+ /** @type {Record<string, any>} */
92
+ let defaults = {};
93
+
94
+ if (layer?.options) {
95
+ for (const [key, schema] of Object.entries(layer.options)) {
96
+ if (schema.default !== undefined) {
97
+ defaults[key] = schema.default;
98
+ }
99
+ }
100
+ }
101
+
102
+ let userOptions = this.desires.options?.[layerName] ?? {};
103
+ return { ...defaults, ...userOptions };
104
+ }
105
+
83
106
  /**
84
107
  * @param {string} name
85
108
  * @returns {Promise<boolean>}
@@ -1,13 +1,61 @@
1
+ import type { Validate } from "@clack/core";
1
2
  import { Project } from "./project.js";
2
3
  export type PackageManager = "pnpm" | "npm";
3
4
  export type ProjectType = "app" | "library" | "extension" | "custom-element";
4
5
 
6
+ export type LayerOptionType = "text" | "number" | "select" | "confirm" | "multiselect";
7
+
8
+ export interface LayerSelectChoice {
9
+ /**
10
+ * A string, because the same value is also typed as a CLI flag
11
+ */
12
+ value: string;
13
+ label: string;
14
+ hint?: string;
15
+ }
16
+
17
+ export interface LayerOptionSchema {
18
+ type: LayerOptionType;
19
+ /**
20
+ * The question to ask, also shown in `--help`
21
+ */
22
+ prompt: string;
23
+ default?: any;
24
+ /**
25
+ * The choices for "select" and "multiselect"
26
+ */
27
+ options?: LayerSelectChoice[];
28
+ /**
29
+ * The same as clack's `validate`:
30
+ * a function that returns a message (or an Error) to reject the value,
31
+ * or a Standard Schema.
32
+ *
33
+ * Receives the value after conversion to the option's type.
34
+ */
35
+ validate?: Validate<any>;
36
+ /**
37
+ * Reads the value that an existing project uses now.
38
+ *
39
+ * When updating a project, the prompt starts at this value instead of `default`,
40
+ * so that pressing enter keeps the project as it is.
41
+ */
42
+ detect?: (project: Project) => unknown;
43
+ }
44
+
45
+ export interface LayerOptionsSchema {
46
+ [optionKey: string]: LayerOptionSchema;
47
+ }
48
+
5
49
  export interface Layer {
6
50
  /**
7
51
  * The text to show during selection
8
52
  */
9
53
  label: string;
10
54
  hint?: string;
55
+ /**
56
+ * Optional schema of configurable options for this layer
57
+ */
58
+ options?: LayerOptionsSchema;
11
59
  /**
12
60
  * Whether the layer is pre-selected in the CLI.
13
61
  * The user can still deselect it.
@@ -24,8 +72,10 @@ export interface Layer {
24
72
  *
25
73
  * run _may_ be invoked multiple times,
26
74
  * so it's important to not require interaction here
75
+ *
76
+ * `options` are this layer's options, with defaults filled in.
27
77
  */
28
- run: (project: Project) => Promise<void>;
78
+ run: (project: Project, options: Record<string, any>) => Promise<void>;
29
79
  isSetup: <Explain extends boolean = false>(
30
80
  project: Project,
31
81
  explain?: Explain,
@@ -55,4 +105,5 @@ export interface Answers {
55
105
  name: string;
56
106
  layers: DiscoveredLayer[];
57
107
  packageManager: PackageManager;
108
+ options?: Record<string, Record<string, any>>;
58
109
  }