ember.nvp 1.8.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.
- package/.release-plan.json +15 -35
- package/CHANGELOG.md +33 -0
- package/README.md +73 -4
- package/package.json +2 -1
- package/packages/rolldown/package.json +6 -2
- package/packages/rolldown/src/dts.test.ts +105 -1
- package/packages/rolldown/src/isolated-declarations.ts +67 -61
- package/packages/rolldown/src/transform.test.ts +212 -15
- package/packages/rolldown/src/transform.ts +106 -26
- package/src/cli/args.js +170 -48
- package/src/cli/generator.js +2 -1
- package/src/cli/help.js +82 -0
- package/src/cli/index.js +15 -0
- package/src/cli/questions/layer-options.js +130 -0
- package/src/cli/questions/validate-option.js +106 -0
- package/src/layers/eslint-bundled/app.test.ts +182 -0
- package/src/layers/eslint-bundled/index.js +172 -0
- package/src/layers/expect-type/files/type-tests/index.test.ts +4 -0
- package/src/layers/expect-type/files/type-tests/tsconfig.json +8 -0
- package/src/layers/expect-type/index.js +114 -0
- package/src/layers/github-actions/app.test.ts +1 -1
- package/src/layers/typescript/app.test.ts +6 -0
- package/src/layers/typescript/index.js +48 -148
- package/src/layers/typescript/typescript-6.js +102 -0
- package/src/layers/typescript/typescript-7.js +177 -0
- package/src/utils/manifest.js +13 -0
- package/src/utils/npm.js +27 -0
- package/src/utils/project.js +23 -0
- package/src/utils/types.ts +52 -1
- package/src/utils/typescript.js +147 -0
- package/test/ember.nvp/base-library.test.ts +11 -0
- package/test/ember.nvp/cli.test.ts +142 -1
- package/test/ember.nvp/layer-expect-type.test.ts +113 -0
- package/test/ember.nvp/layer-options.test.ts +248 -0
- package/test/ember.nvp/layer-readme.test.ts +4 -2
- package/test/ember.nvp/layer-typescript.test.ts +302 -0
- package/test/ember.nvp/layers.test.ts +2 -2
- package/test/ember.nvp/permutations/shared.ts +6 -6
- package/test/ember.nvp/validate-option.test.ts +152 -0
- package/test/test-helpers/permutate.test.ts +2 -7
- package/test/test-helpers.ts +12 -2
- package/vitest.config.js +7 -1
- package/src/layers/eslint-bundled-ember/index.js +0 -91
- package/src/layers/eslint-bundled-nvp/app.test.ts +0 -91
- package/src/layers/eslint-bundled-nvp/index.js +0 -91
- /package/src/layers/{eslint-bundled-ember/files → eslint-bundled/files/ember}/eslint.config.js +0 -0
- /package/src/layers/{eslint-bundled-nvp/files → eslint-bundled/files/nvp}/eslint.config.js +0 -0
package/src/cli/help.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI flags only carry strings and booleans
|
|
5
|
+
*
|
|
6
|
+
* @type {Record<import('#types').LayerOptionType, string>}
|
|
7
|
+
*/
|
|
8
|
+
const FLAG_TYPES = {
|
|
9
|
+
text: "string",
|
|
10
|
+
number: "number",
|
|
11
|
+
confirm: "boolean",
|
|
12
|
+
select: "string",
|
|
13
|
+
multiselect: "string",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Print help text to stdout.
|
|
18
|
+
*
|
|
19
|
+
* @param {Record<string, any>} coreOptions
|
|
20
|
+
* @param {import('#types').DiscoveredLayer[]} [layers]
|
|
21
|
+
*/
|
|
22
|
+
export function printHelp(coreOptions, layers = []) {
|
|
23
|
+
const title = styleText(["bgCyan", "black"], " ember.nvp ");
|
|
24
|
+
console.log(`${title}\n`);
|
|
25
|
+
console.log(`${styleText("bold", "Usage:")} npx ember.nvp [options]\n`);
|
|
26
|
+
|
|
27
|
+
console.log(styleText("bold", "Core Options:"));
|
|
28
|
+
for (const [name, config] of Object.entries(coreOptions)) {
|
|
29
|
+
printOption({
|
|
30
|
+
flag: `--${name}`,
|
|
31
|
+
alias: config.short ? `-${config.short}, ` : " ",
|
|
32
|
+
type: config.type,
|
|
33
|
+
description: config.description,
|
|
34
|
+
choices: config.choices,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const withOptions = layers.filter((layer) => layer.options);
|
|
39
|
+
|
|
40
|
+
if (withOptions.length === 0) return;
|
|
41
|
+
|
|
42
|
+
console.log(styleText("bold", "Layer Options:"));
|
|
43
|
+
for (const layer of withOptions) {
|
|
44
|
+
for (const [key, schema] of Object.entries(layer.options ?? {})) {
|
|
45
|
+
const name = `${layer.name}.${key}`;
|
|
46
|
+
|
|
47
|
+
printOption({
|
|
48
|
+
flag: schema.type === "confirm" ? `--[no-]${name}` : `--${name}`,
|
|
49
|
+
alias: " ",
|
|
50
|
+
type: FLAG_TYPES[schema.type],
|
|
51
|
+
description: schema.prompt,
|
|
52
|
+
choices: schema.options?.map((choice) => choice.value),
|
|
53
|
+
fallback: schema.default,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{
|
|
61
|
+
* flag: string,
|
|
62
|
+
* alias: string,
|
|
63
|
+
* type?: string,
|
|
64
|
+
* description?: string,
|
|
65
|
+
* choices?: string[],
|
|
66
|
+
* fallback?: unknown,
|
|
67
|
+
* }} option
|
|
68
|
+
*/
|
|
69
|
+
function printOption({ flag, alias, type, description = "", choices, fallback }) {
|
|
70
|
+
const typeStr = type ? styleText("dim", `<${type}>`) : "";
|
|
71
|
+
const choicesStr = choices
|
|
72
|
+
? styleText("yellow", ` [choices: ${choices.map((c) => `"${c}"`).join(", ")}]`)
|
|
73
|
+
: "";
|
|
74
|
+
const fallbackStr =
|
|
75
|
+
fallback === undefined ? "" : styleText("dim", ` [default: ${JSON.stringify(fallback)}]`);
|
|
76
|
+
|
|
77
|
+
console.log(` ${styleText("cyan", alias)}${styleText("cyan", flag)} ${typeStr}`);
|
|
78
|
+
if (description || choicesStr || fallbackStr) {
|
|
79
|
+
console.log(` ${description}${choicesStr}${fallbackStr}`);
|
|
80
|
+
}
|
|
81
|
+
console.log("");
|
|
82
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as p from "@clack/prompts";
|
|
|
4
4
|
import { generateProject } from "./generator.js";
|
|
5
5
|
import { askName } from "./questions/name.js";
|
|
6
6
|
import { askLayers } from "./questions/layers.js";
|
|
7
|
+
import { askLayerOptions } from "./questions/layer-options.js";
|
|
7
8
|
import { askProjectType } from "./questions/project-type.js";
|
|
8
9
|
import { askPackageManager } from "./questions/package-manager.js";
|
|
9
10
|
import { askPath } from "./questions/path.js";
|
|
@@ -35,6 +36,19 @@ async function main() {
|
|
|
35
36
|
const selectedLayers = await askLayers(projectType);
|
|
36
37
|
const packageManager = await askPackageManager();
|
|
37
38
|
|
|
39
|
+
// Layer options start from what the project already uses
|
|
40
|
+
const existing =
|
|
41
|
+
replaceOrUpdate === "update"
|
|
42
|
+
? new Project(projectPath, {
|
|
43
|
+
name: projectName,
|
|
44
|
+
type: projectType,
|
|
45
|
+
path: projectPath,
|
|
46
|
+
layers: selectedLayers,
|
|
47
|
+
packageManager,
|
|
48
|
+
})
|
|
49
|
+
: undefined;
|
|
50
|
+
const layerOptions = await askLayerOptions(selectedLayers, existing);
|
|
51
|
+
|
|
38
52
|
await askIfOK();
|
|
39
53
|
|
|
40
54
|
/**
|
|
@@ -60,6 +74,7 @@ async function main() {
|
|
|
60
74
|
path: projectPath,
|
|
61
75
|
layers: selectedLayers,
|
|
62
76
|
packageManager,
|
|
77
|
+
options: layerOptions,
|
|
63
78
|
});
|
|
64
79
|
|
|
65
80
|
const s = p.spinner();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import { printArgInUse, parseLayerOptionsFromParsedArgs } from "#args";
|
|
4
|
+
import { validateOption } from "./validate-option.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prompt the user for options defined on the selected layers.
|
|
8
|
+
* Skips options that were explicitly provided via CLI flags.
|
|
9
|
+
*
|
|
10
|
+
* @param {import('#types').DiscoveredLayer[]} selectedLayers
|
|
11
|
+
* @param {import('#utils/project.js').Project} [existing] the project being updated, for `detect`
|
|
12
|
+
* @returns {Promise<Record<string, Record<string, any>>>}
|
|
13
|
+
*/
|
|
14
|
+
export async function askLayerOptions(selectedLayers, existing) {
|
|
15
|
+
const cliOptions = parseLayerOptionsFromParsedArgs(selectedLayers);
|
|
16
|
+
/** @type {Record<string, Record<string, any>>} */
|
|
17
|
+
const result = {};
|
|
18
|
+
|
|
19
|
+
for (const layer of selectedLayers) {
|
|
20
|
+
if (!layer.options) continue;
|
|
21
|
+
|
|
22
|
+
const layerResult = (result[layer.name] ??= {});
|
|
23
|
+
|
|
24
|
+
for (const [key, schema] of Object.entries(layer.options)) {
|
|
25
|
+
const fromCli = cliOptions[layer.name]?.[key];
|
|
26
|
+
|
|
27
|
+
if (fromCli !== undefined) {
|
|
28
|
+
layerResult[key] = fromCli;
|
|
29
|
+
printArgInUse(`${layer.name}.${key}`, String(fromCli));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const message = `${styleText("magentaBright", layer.name)}: ${schema.prompt}`;
|
|
34
|
+
const detected = existing && schema.detect ? await schema.detect(existing) : undefined;
|
|
35
|
+
|
|
36
|
+
layerResult[key] = await ask(message, schema, detected ?? schema.default);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} message
|
|
45
|
+
* @param {import('#types').LayerOptionSchema} schema
|
|
46
|
+
* @param {any} initial
|
|
47
|
+
* @returns {Promise<unknown>}
|
|
48
|
+
*/
|
|
49
|
+
async function ask(message, schema, initial) {
|
|
50
|
+
switch (schema.type) {
|
|
51
|
+
case "text":
|
|
52
|
+
case "number": {
|
|
53
|
+
const fallback = initial === undefined ? undefined : String(initial);
|
|
54
|
+
|
|
55
|
+
const answer = await p.text({
|
|
56
|
+
message,
|
|
57
|
+
placeholder: fallback,
|
|
58
|
+
defaultValue: fallback,
|
|
59
|
+
/**
|
|
60
|
+
* clack swaps in the defaultValue after validation,
|
|
61
|
+
* so an empty answer is checked as the default.
|
|
62
|
+
*/
|
|
63
|
+
validate: (input) => {
|
|
64
|
+
const result = validateOption(schema, input || fallback || "");
|
|
65
|
+
|
|
66
|
+
return result.ok ? undefined : result.error;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// converts "number" answers, which clack returns as strings
|
|
71
|
+
const result = validateOption(schema, exitIfCancelled(answer));
|
|
72
|
+
|
|
73
|
+
if (!result.ok) throw new Error(result.error);
|
|
74
|
+
|
|
75
|
+
return result.value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case "confirm":
|
|
79
|
+
return exitIfCancelled(
|
|
80
|
+
await p.confirm({
|
|
81
|
+
message,
|
|
82
|
+
initialValue: initial ?? true,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
case "select":
|
|
87
|
+
return exitIfCancelled(
|
|
88
|
+
await p.select({
|
|
89
|
+
message,
|
|
90
|
+
options: schema.options ?? [],
|
|
91
|
+
initialValue: initial,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
case "multiselect": {
|
|
96
|
+
// clack's multiselect has no validate, so ask again until the selection passes
|
|
97
|
+
while (true) {
|
|
98
|
+
const answer = await p.multiselect({
|
|
99
|
+
message,
|
|
100
|
+
options: schema.options ?? [],
|
|
101
|
+
initialValues: initial ?? [],
|
|
102
|
+
required: false,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const result = validateOption(schema, exitIfCancelled(answer));
|
|
106
|
+
|
|
107
|
+
if (result.ok) return result.value;
|
|
108
|
+
|
|
109
|
+
p.log.error(result.error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
default:
|
|
114
|
+
throw new Error(`Unknown option type '${String(schema.type)}'`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @template T
|
|
120
|
+
* @param {T | symbol} answer
|
|
121
|
+
* @returns {T}
|
|
122
|
+
*/
|
|
123
|
+
function exitIfCancelled(answer) {
|
|
124
|
+
if (p.isCancel(answer)) {
|
|
125
|
+
p.cancel("Operation cancelled");
|
|
126
|
+
return process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return answer;
|
|
130
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { runValidation } from "@clack/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('#types').LayerOptionSchema} LayerOptionSchema
|
|
5
|
+
*
|
|
6
|
+
* @typedef {{ ok: true, value: any }} ValidationSuccess
|
|
7
|
+
* @typedef {{ ok: false, error: string }} ValidationFailure
|
|
8
|
+
* @typedef {ValidationSuccess | ValidationFailure} ValidationResult
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convert a value from a CLI flag or a prompt to the option's type,
|
|
13
|
+
* then run the option's own `validate`.
|
|
14
|
+
*
|
|
15
|
+
* Defaults are not applied here.
|
|
16
|
+
* See `Project#getLayerOptions`.
|
|
17
|
+
*
|
|
18
|
+
* @param {LayerOptionSchema} schema
|
|
19
|
+
* @param {unknown} rawValue
|
|
20
|
+
* @returns {ValidationResult}
|
|
21
|
+
*/
|
|
22
|
+
export function validateOption(schema, rawValue) {
|
|
23
|
+
const converted = convert(schema, rawValue);
|
|
24
|
+
|
|
25
|
+
if (!converted.ok || !schema.validate) return converted;
|
|
26
|
+
|
|
27
|
+
const error = runValidation(schema.validate, converted.value);
|
|
28
|
+
|
|
29
|
+
if (!error) return converted;
|
|
30
|
+
|
|
31
|
+
return { ok: false, error: error instanceof Error ? error.message : error };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {LayerOptionSchema} schema
|
|
36
|
+
* @param {unknown} rawValue
|
|
37
|
+
* @returns {ValidationResult}
|
|
38
|
+
*/
|
|
39
|
+
function convert(schema, rawValue) {
|
|
40
|
+
switch (schema.type) {
|
|
41
|
+
case "text":
|
|
42
|
+
return { ok: true, value: String(rawValue) };
|
|
43
|
+
|
|
44
|
+
case "number": {
|
|
45
|
+
const text = String(rawValue).trim();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Number() also accepts "", "0x10", and "1e3",
|
|
49
|
+
* which nobody means when they type a count.
|
|
50
|
+
*/
|
|
51
|
+
if (!/^-?\d+(\.\d+)?$/.test(text)) {
|
|
52
|
+
return { ok: false, error: `'${text}' is not a number` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ok: true, value: Number(text) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case "confirm":
|
|
59
|
+
return typeof rawValue === "boolean"
|
|
60
|
+
? { ok: true, value: rawValue }
|
|
61
|
+
: { ok: false, error: `'${String(rawValue)}' is not true or false` };
|
|
62
|
+
|
|
63
|
+
case "select": {
|
|
64
|
+
const allowed = choicesOf(schema);
|
|
65
|
+
|
|
66
|
+
return typeof rawValue === "string" && allowed.includes(rawValue)
|
|
67
|
+
? { ok: true, value: rawValue }
|
|
68
|
+
: { ok: false, error: notAChoice(rawValue, allowed) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case "multiselect": {
|
|
72
|
+
const allowed = choicesOf(schema);
|
|
73
|
+
|
|
74
|
+
if (!Array.isArray(rawValue)) {
|
|
75
|
+
return { ok: false, error: `'${String(rawValue)}' is not a list` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const item of rawValue) {
|
|
79
|
+
if (!allowed.includes(item)) {
|
|
80
|
+
return { ok: false, error: notAChoice(item, allowed) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { ok: true, value: rawValue };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
default:
|
|
88
|
+
return { ok: false, error: `Unknown option type '${String(schema.type)}'` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {LayerOptionSchema} schema
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
function choicesOf(schema) {
|
|
97
|
+
return (schema.options ?? []).map((choice) => choice.value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @param {string[]} allowed
|
|
103
|
+
*/
|
|
104
|
+
function notAChoice(value, allowed) {
|
|
105
|
+
return `Invalid option '${String(value)}'. Must be one of: ${allowed.join(", ")}`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { beforeAll, describe, it, expect as hardExpect, afterAll } from "vitest";
|
|
2
|
+
import { expectIsSetup, generate, layers, reapply } from "#test-helpers";
|
|
3
|
+
import { packageJson } from "ember-apply";
|
|
4
|
+
|
|
5
|
+
import type { Project } from "ember.nvp";
|
|
6
|
+
import { rm } from "node:fs/promises";
|
|
7
|
+
|
|
8
|
+
const expect = hardExpect.soft;
|
|
9
|
+
|
|
10
|
+
let layer = layers.find((layer) => layer.name === "eslint-bundled")!;
|
|
11
|
+
|
|
12
|
+
const CONFIG_PACKAGES = {
|
|
13
|
+
ember: "ember-eslint",
|
|
14
|
+
nvp: "@nullvoxpopuli/eslint-configs",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe.each(Object.entries(CONFIG_PACKAGES))("preset: %s", (preset, configPackage) => {
|
|
18
|
+
const options = { "eslint-bundled": { preset } };
|
|
19
|
+
|
|
20
|
+
describe("starting without eslint-bundled", () => {
|
|
21
|
+
let project: Project;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
project = await generate({
|
|
25
|
+
type: "app",
|
|
26
|
+
packageManager: "pnpm",
|
|
27
|
+
layers: ["git"],
|
|
28
|
+
options,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await rm(project.directory, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("applying eslint-bundled", async () => {
|
|
37
|
+
await reapply(project, ["eslint-bundled"]);
|
|
38
|
+
|
|
39
|
+
await expectIsSetup(project, layer);
|
|
40
|
+
expect(await project.gitHasDiff()).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("starting with eslint-bundled", () => {
|
|
45
|
+
let project: Project;
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
project = await generate({
|
|
49
|
+
type: "app",
|
|
50
|
+
packageManager: "pnpm",
|
|
51
|
+
layers: ["eslint-bundled", "git"],
|
|
52
|
+
options,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(async () => {
|
|
57
|
+
await rm(project.directory, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("installs only this preset's config package", async () => {
|
|
61
|
+
let manifest = await packageJson.read(project.directory);
|
|
62
|
+
let installed = Object.values(CONFIG_PACKAGES).filter(
|
|
63
|
+
(name) => manifest.devDependencies?.[name],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(installed).toEqual([configPackage]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("detects the preset", async () => {
|
|
70
|
+
expect(await layer.options?.preset?.detect?.(project)).toBe(preset);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reapplying eslint-bundled causes no changes", async () => {
|
|
74
|
+
await reapply(project, ["eslint-bundled"]);
|
|
75
|
+
|
|
76
|
+
await expectIsSetup(project, layer);
|
|
77
|
+
expect(await project.gitHasDiff()).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("package.json scripts", () => {
|
|
82
|
+
let project: Project;
|
|
83
|
+
|
|
84
|
+
beforeAll(async () => {
|
|
85
|
+
project = await generate({
|
|
86
|
+
type: "app",
|
|
87
|
+
packageManager: "pnpm",
|
|
88
|
+
layers: ["eslint-bundled", "git"],
|
|
89
|
+
options,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let { exitCode } = await project.run("pnpm install");
|
|
93
|
+
|
|
94
|
+
hardExpect(exitCode).toBe(0);
|
|
95
|
+
|
|
96
|
+
await expectIsSetup(project, layer);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(async () => {
|
|
100
|
+
await rm(project.directory, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("linting and fixing works", async () => {
|
|
104
|
+
{
|
|
105
|
+
let { exitCode } = await project.run("pnpm lint:eslint --fix");
|
|
106
|
+
|
|
107
|
+
expect(exitCode).toBe(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
let { exitCode } = await project.run("pnpm lint:eslint");
|
|
112
|
+
|
|
113
|
+
expect(exitCode).toBe(0);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe.each([
|
|
120
|
+
["ember", "nvp"],
|
|
121
|
+
["nvp", "ember"],
|
|
122
|
+
] as const)("switching presets: %s to %s", (from, to) => {
|
|
123
|
+
let project: Project;
|
|
124
|
+
|
|
125
|
+
beforeAll(async () => {
|
|
126
|
+
let before = await generate({
|
|
127
|
+
type: "app",
|
|
128
|
+
packageManager: "pnpm",
|
|
129
|
+
layers: ["eslint-bundled", "git"],
|
|
130
|
+
options: { "eslint-bundled": { preset: from } },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
project = await generate({
|
|
134
|
+
directory: before.directory,
|
|
135
|
+
type: "app",
|
|
136
|
+
packageManager: "pnpm",
|
|
137
|
+
layers: ["eslint-bundled", "git"],
|
|
138
|
+
options: { "eslint-bundled": { preset: to } },
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterAll(async () => {
|
|
143
|
+
await rm(project.directory, { recursive: true, force: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("replaces the config package and the config", async () => {
|
|
147
|
+
let manifest = await packageJson.read(project.directory);
|
|
148
|
+
|
|
149
|
+
expect(manifest.devDependencies).toHaveProperty(CONFIG_PACKAGES[to]);
|
|
150
|
+
expect(manifest.devDependencies).not.toHaveProperty(CONFIG_PACKAGES[from]);
|
|
151
|
+
expect(await project.read("eslint.config.js")).toContain(CONFIG_PACKAGES[to]);
|
|
152
|
+
expect(await project.read("eslint.config.js")).not.toContain(CONFIG_PACKAGES[from]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("is setup, and detects the new preset", async () => {
|
|
156
|
+
await expectIsSetup(project, layer);
|
|
157
|
+
expect(await layer.options?.preset?.detect?.(project)).toBe(to);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("reapplying causes no changes", async () => {
|
|
161
|
+
await reapply(project, ["eslint-bundled", "git"]);
|
|
162
|
+
|
|
163
|
+
expect(await project.gitHasDiff()).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("linting and fixing works", async () => {
|
|
167
|
+
let install = await project.run("pnpm install");
|
|
168
|
+
hardExpect(install.exitCode).toBe(0);
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
let { exitCode } = await project.run("pnpm lint:eslint --fix");
|
|
172
|
+
|
|
173
|
+
expect(exitCode).toBe(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
let { exitCode } = await project.run("pnpm lint:eslint");
|
|
178
|
+
|
|
179
|
+
expect(exitCode).toBe(0);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { getLatest } from "#utils/npm.js";
|
|
2
|
+
import { files, packageJson } from "ember-apply";
|
|
3
|
+
import { formatLabel } from "#utils/cli.js";
|
|
4
|
+
import { maybeLintWithConcurrently } from "#consolidators/linting.js";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { hasDevDeps, readManifest } from "#utils/manifest.js";
|
|
8
|
+
import { hasTypeScript6, syncTypeScript6, usesTypeScript7 } from "#utils/typescript.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The config package of each preset.
|
|
12
|
+
*
|
|
13
|
+
* Each preset also has a folder in `files/`.
|
|
14
|
+
*
|
|
15
|
+
* @type {Record<string, Record<string, string>>}
|
|
16
|
+
*/
|
|
17
|
+
const PRESETS = {
|
|
18
|
+
ember: { "ember-eslint": "^1.0.0" },
|
|
19
|
+
nvp: { "@nullvoxpopuli/eslint-configs": "^7.0.0" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Record<string, any>} manifest
|
|
24
|
+
* @returns {string | undefined}
|
|
25
|
+
*/
|
|
26
|
+
function presetIn(manifest) {
|
|
27
|
+
for (const [preset, deps] of Object.entries(PRESETS)) {
|
|
28
|
+
if (hasDevDeps(manifest, deps)) return preset;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {import('#utils/project.js').Project} project
|
|
36
|
+
* @returns {string | undefined} undefined when the layer is not selected
|
|
37
|
+
*/
|
|
38
|
+
function wantedPreset(project) {
|
|
39
|
+
if (!project.wantsLayer("eslint-bundled")) return undefined;
|
|
40
|
+
|
|
41
|
+
return project.getLayerOptions("eslint-bundled").preset;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @type {import('#types').Layer}
|
|
46
|
+
*/
|
|
47
|
+
export default {
|
|
48
|
+
label: formatLabel("ESLint", "encapsulated config"),
|
|
49
|
+
hint: `Minimal dependencies added to package.json`,
|
|
50
|
+
|
|
51
|
+
options: {
|
|
52
|
+
preset: {
|
|
53
|
+
type: "select",
|
|
54
|
+
prompt: "Which ESLint config?",
|
|
55
|
+
default: "ember",
|
|
56
|
+
options: [
|
|
57
|
+
{
|
|
58
|
+
value: "ember",
|
|
59
|
+
label: "ember-eslint",
|
|
60
|
+
hint: "Not official, but follows the official configuration",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
value: "nvp",
|
|
64
|
+
label: "@nullvoxpopuli/eslint-configs",
|
|
65
|
+
hint: "NullVoxPopuli's config for apps, libraries, TypeScript, and more",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
async detect(project) {
|
|
69
|
+
return presetIn(await readManifest(project));
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async run(project, { preset }) {
|
|
75
|
+
await files.applyFolder(join(import.meta.dirname, "files", preset), project.directory);
|
|
76
|
+
|
|
77
|
+
// Only one preset at a time, so that switching presets leaves nothing behind
|
|
78
|
+
for (const [other, deps] of Object.entries(PRESETS)) {
|
|
79
|
+
if (other === preset) continue;
|
|
80
|
+
|
|
81
|
+
await packageJson.removeDevDependencies(Object.keys(deps), project.directory);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await packageJson.addDevDependencies(
|
|
85
|
+
await getLatest({
|
|
86
|
+
...PRESETS[preset],
|
|
87
|
+
eslint: "^10.9.1",
|
|
88
|
+
}),
|
|
89
|
+
project.directory,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
await packageJson.addScripts(
|
|
93
|
+
{
|
|
94
|
+
"lint:eslint": "eslint . --cache",
|
|
95
|
+
"lint:eslint:fix": "eslint . --fix",
|
|
96
|
+
},
|
|
97
|
+
project.directory,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// typescript-eslint needs TypeScript 6
|
|
101
|
+
await syncTypeScript6(project);
|
|
102
|
+
|
|
103
|
+
await maybeLintWithConcurrently(project);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @overload
|
|
108
|
+
* @param {import('#utils/project.js').Project} project
|
|
109
|
+
* @param {true} explain
|
|
110
|
+
* @returns {Promise<{ isSetup: boolean; reasons: string[] }>}
|
|
111
|
+
*/
|
|
112
|
+
/**
|
|
113
|
+
* @overload
|
|
114
|
+
* @param {import('#utils/project.js').Project} project
|
|
115
|
+
* @param {boolean | undefined} [explain]
|
|
116
|
+
* @returns {Promise<boolean>}
|
|
117
|
+
*/
|
|
118
|
+
async isSetup(project, explain) {
|
|
119
|
+
const reasons = [];
|
|
120
|
+
|
|
121
|
+
if (!existsSync(join(project.directory, "eslint.config.js"))) {
|
|
122
|
+
reasons.push("eslint.config.js is missing");
|
|
123
|
+
|
|
124
|
+
if (!explain) return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let manifest = await packageJson.read(project.directory);
|
|
128
|
+
let wanted = wantedPreset(project);
|
|
129
|
+
|
|
130
|
+
if (!hasDevDeps(manifest, ["eslint"])) {
|
|
131
|
+
reasons.push("missing required dependency: eslint");
|
|
132
|
+
|
|
133
|
+
if (!explain) return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (wanted ? !hasDevDeps(manifest, PRESETS[wanted] ?? {}) : !presetIn(manifest)) {
|
|
137
|
+
reasons.push(`missing the config package of the ${wanted ?? "ember or nvp"} preset`);
|
|
138
|
+
|
|
139
|
+
if (!explain) return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (usesTypeScript7(project, manifest) && !hasTypeScript6(manifest)) {
|
|
143
|
+
reasons.push("missing typescript (TypeScript 6) for typescript-eslint");
|
|
144
|
+
|
|
145
|
+
if (!explain) return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let scripts = Object.values(manifest.scripts ?? {}).filter((script) =>
|
|
149
|
+
script.includes("eslint"),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (!scripts.some((script) => script.includes("--cache"))) {
|
|
153
|
+
reasons.push("missing eslint script with --cache flag");
|
|
154
|
+
|
|
155
|
+
if (!explain) return false;
|
|
156
|
+
}
|
|
157
|
+
if (!scripts.some((script) => script.includes("--fix"))) {
|
|
158
|
+
reasons.push("missing eslint script with --fix flag");
|
|
159
|
+
|
|
160
|
+
if (!explain) return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (explain) {
|
|
164
|
+
return {
|
|
165
|
+
isSetup: reasons.length === 0,
|
|
166
|
+
reasons,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return reasons.length === 0;
|
|
171
|
+
},
|
|
172
|
+
};
|