ember.nvp 0.5.0 → 0.7.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 (91) hide show
  1. package/.github/workflows/ci.yml +17 -2
  2. package/.release-plan.json +84 -4
  3. package/CHANGELOG.md +68 -0
  4. package/README.md +29 -0
  5. package/package.json +4 -2
  6. package/packages/build-tooling-utils/README.md +37 -0
  7. package/packages/build-tooling-utils/index.ts +2 -0
  8. package/packages/build-tooling-utils/package.json +42 -0
  9. package/packages/build-tooling-utils/rolldown.config.js +18 -0
  10. package/packages/build-tooling-utils/src/extensions.ts +18 -0
  11. package/packages/build-tooling-utils/src/maybe-babel.ts +146 -0
  12. package/packages/rolldown/README.md +115 -0
  13. package/packages/rolldown/index.ts +56 -0
  14. package/packages/rolldown/package.json +59 -0
  15. package/packages/rolldown/rolldown.config.js +21 -0
  16. package/packages/rolldown/src/babel.ts +136 -0
  17. package/packages/rolldown/src/define-config.ts +32 -0
  18. package/packages/rolldown/src/externals.ts +83 -0
  19. package/packages/rolldown/src/isolated-declarations.ts +65 -0
  20. package/packages/rolldown/src/transform.ts +162 -0
  21. package/packages/vite/README.md +7 -21
  22. package/packages/vite/index.ts +1 -1
  23. package/packages/vite/package.json +4 -6
  24. package/packages/vite/src/ember.ts +129 -15
  25. package/packages/vite/src/test-html.ts +86 -0
  26. package/src/bases/minimal-app/files/app/router.ts +1 -0
  27. package/src/bases/minimal-app/files/tsconfig.json +1 -0
  28. package/src/bases/minimal-app/index.js +2 -2
  29. package/src/bases/minimal-library/files/README.md +41 -0
  30. package/src/bases/minimal-library/files/package.json +49 -0
  31. package/src/bases/minimal-library/files/tsconfig.json +10 -0
  32. package/src/bases/minimal-library/files/tsdown.config.js +6 -0
  33. package/src/bases/minimal-library/index.js +158 -2
  34. package/src/cli/args.js +7 -1
  35. package/src/cli/index.js +90 -16
  36. package/src/cli/questions/layers.js +5 -2
  37. package/src/cli/questions/project-type.js +0 -5
  38. package/src/cli/questions/write.js +169 -0
  39. package/src/index.js +1 -0
  40. package/src/layers/are-the-types-wrong/index.js +74 -0
  41. package/src/layers/git/files/gitignore +14 -1
  42. package/src/layers/git/index.js +6 -5
  43. package/src/layers/github-actions/index.js +12 -8
  44. package/src/layers/index.js +1 -1
  45. package/src/layers/inspector-support/index.js +101 -0
  46. package/src/layers/prettier/files/.prettierignore +4 -0
  47. package/src/layers/publint/index.js +68 -0
  48. package/src/layers/qunit/files/tests/unit/macros-test.ts +2 -2
  49. package/src/layers/qunit/index.js +107 -7
  50. package/src/layers/qunit/library-files/config/test/babel.config.js +41 -0
  51. package/src/layers/qunit/library-files/config/test/testem.cjs +26 -0
  52. package/src/layers/qunit/library-files/tests/test-helper.ts +36 -0
  53. package/src/layers/qunit/library-files/tests/unit/.gitkeep +0 -0
  54. package/src/layers/qunit/library-files/vite.config.mjs +6 -0
  55. package/src/layers/typescript/index.js +43 -7
  56. package/src/layers/vitest/files/vitest.config.mjs +11 -11
  57. package/src/layers/vitest/index.js +70 -32
  58. package/src/utils/babel.js +9 -6
  59. package/src/utils/fs.js +44 -3
  60. package/src/utils/inspector-support.js +196 -0
  61. package/src/utils/npm.js +21 -7
  62. package/src/utils/remove-types.js +5 -0
  63. package/src/utils/rewrite-imports.js +157 -0
  64. package/src/utils/stage.js +317 -0
  65. package/src/utils/tsdown-config.js +85 -0
  66. package/src/utils/types.ts +5 -1
  67. package/test/ember.nvp/base-library.test.ts +232 -0
  68. package/test/ember.nvp/generator-replace-or-update.test.ts +2 -3
  69. package/test/ember.nvp/inspector-support.test.ts +179 -0
  70. package/test/ember.nvp/layer-inspector-support.test.ts +69 -0
  71. package/test/ember.nvp/layer-publish-checks.test.ts +103 -0
  72. package/test/ember.nvp/layer-qunit.test.ts +376 -15
  73. package/test/ember.nvp/layer-vitest.test.ts +250 -0
  74. package/test/ember.nvp/layers.test.ts +16 -0
  75. package/test/ember.nvp/library-src-fixtures.ts +81 -0
  76. package/test/ember.nvp/permutations/app-1.test.ts +3 -0
  77. package/test/ember.nvp/permutations/app-2.test.ts +3 -0
  78. package/test/ember.nvp/permutations/library-1.test.ts +3 -0
  79. package/test/ember.nvp/permutations/library-2.test.ts +3 -0
  80. package/test/ember.nvp/permutations/shared.ts +239 -0
  81. package/test/ember.nvp/rewrite-imports.test.ts +235 -0
  82. package/test/ember.nvp/stage.test.ts +188 -0
  83. package/test/test-helpers/permutate.test.ts +17 -0
  84. package/test/test-helpers.ts +20 -7
  85. package/tsconfig.json +1 -0
  86. package/packages/vite/src/maybe-babel.ts +0 -219
  87. package/src/layers/qunit/files/run-tests.mjs +0 -30
  88. package/src/layers/vitest/files/tests/test-helper.ts +0 -5
  89. package/test/ember.nvp/permutations.test.ts +0 -200
  90. /package/src/{layers/vitest/files/tests/integration/.gitkeep → bases/minimal-library/files/src/index.ts} +0 -0
  91. /package/src/layers/{vitest/files/tests/unit → qunit/library-files/tests/rendering}/.gitkeep +0 -0
@@ -1,10 +1,166 @@
1
+ import { packageJson } from "ember-apply";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getLatest } from "#utils/npm.js";
5
+ import { applyFolderTo } from "#utils/fs.js";
6
+ import { removeConfiguredPlugin } from "#utils/babel.js";
7
+
8
+ /**
9
+ * Minimal Library Base
10
+ *
11
+ * Provides the absolute bare minimum for an Ember v2 library (addon):
12
+ * - type: "module" in package.json
13
+ * - built with tsdown + @nullvoxpopuli/ember-rolldown
14
+ * - a sample `<template>` component and a plain module
15
+ * - No babel.config.js: ember() covers TS stripping, template compilation,
16
+ * and decorators
17
+ * - No testing framework
18
+ * - No linting or formatting
19
+ *
20
+ * Testing, linting, formatting, etc. are opt-in via layers.
21
+ */
1
22
  export default {
2
23
  label: "Minimal Library Base",
3
- description: "Bare minimum library structure",
24
+ description: "Bare minimum Ember v2 library (addon) structure",
4
25
 
5
26
  /**
27
+ * 1. Apply files
28
+ * a. Remove TS if needed
29
+ * 2. Update name(s)
30
+ * 3. Make publishable
31
+ * 4. Remove TS deps/files/config if needed
32
+ * 5. Upgrade in-range dependencies
33
+ * 6. Update an existing babel config, if any
6
34
  *
7
35
  * @param {import('#utils/project.js').Project} project
8
36
  */
9
- async run(project) {},
37
+ async run(project) {
38
+ await applyFiles(project);
39
+ await updateName(project);
40
+ await makePublishable(project);
41
+ await makeJavaScript(project);
42
+ await upgradeDependencies(project);
43
+ await updateBabelConfig(project);
44
+ },
10
45
  };
46
+
47
+ /**
48
+ * @param {import('#utils/project.js').Project} project
49
+ */
50
+ async function applyFiles(project) {
51
+ let filePath = join(import.meta.dirname, "files");
52
+
53
+ await applyFolderTo(filePath, project);
54
+ }
55
+
56
+ /**
57
+ * Operates on known files where the name matters
58
+ *
59
+ * @param {import('#utils/project.js').Project} project
60
+ */
61
+ async function updateName(project) {
62
+ await packageJson.modify((json) => {
63
+ json.name = project.desires.name;
64
+ }, project.directory);
65
+ }
66
+
67
+ /**
68
+ * The template's package.json is `private: true` so it can never be
69
+ * published from this repo; the generated library exists to be published,
70
+ * so the flag is removed entirely.
71
+ *
72
+ * @param {import('#utils/project.js').Project} project
73
+ */
74
+ async function makePublishable(project) {
75
+ await packageJson.modify((json) => {
76
+ delete json.private;
77
+ }, project.directory);
78
+ }
79
+
80
+ /**
81
+ * When the project is JavaScript (no typescript layer), remove the TS
82
+ * toolchain, drop the tsconfig, and point the build at the emitted `.js`
83
+ * entry (declarations can't be produced without types).
84
+ *
85
+ * @param {import('#utils/project.js').Project} project
86
+ */
87
+ async function makeJavaScript(project) {
88
+ if (await project.hasOrWantsLayer("typescript")) return;
89
+
90
+ await packageJson.removeDevDependencies(
91
+ ["@babel/plugin-transform-typescript", "@ember/library-tsconfig", "typescript"],
92
+ project.directory,
93
+ );
94
+
95
+ await project.removeFile("tsconfig.json");
96
+
97
+ await removeTypesExports(project);
98
+ await pointBuildAtJavaScript(project);
99
+ }
100
+
101
+ /**
102
+ * No declarations are emitted for a JavaScript library, so the `types`
103
+ * export conditions would point at files that never exist.
104
+ *
105
+ * @param {import('#utils/project.js').Project} project
106
+ */
107
+ async function removeTypesExports(project) {
108
+ await packageJson.modify((json) => {
109
+ for (let condition of Object.values(json.exports ?? {})) {
110
+ if (condition && typeof condition === "object") {
111
+ delete condition.types;
112
+ }
113
+ }
114
+ }, project.directory);
115
+ }
116
+
117
+ /**
118
+ * Rewrites the tsdown config so it builds the JavaScript entry and stops
119
+ * emitting declarations (defineConfig defaults to `dts: true`).
120
+ *
121
+ * @param {import('#utils/project.js').Project} project
122
+ */
123
+ async function pointBuildAtJavaScript(project) {
124
+ let configPath = project.path("tsdown.config.js");
125
+ let contents = await readFile(configPath, "utf-8");
126
+
127
+ contents = contents.replace(
128
+ `entry: ["./src/index.ts"],`,
129
+ `entry: ["./src/index.js"],\n dts: false,`,
130
+ );
131
+
132
+ await writeFile(configPath, contents);
133
+ }
134
+
135
+ /**
136
+ * Bumps in-range only.
137
+ * Majors will need to go through PR to this repo.
138
+ *
139
+ * @param {import('#utils/project.js').Project} project
140
+ */
141
+ async function upgradeDependencies(project) {
142
+ let existing = await packageJson.read(project.directory);
143
+
144
+ await packageJson.modify(async (json) => {
145
+ if (json.dependencies) {
146
+ Object.assign(json.dependencies, await getLatest(existing.dependencies));
147
+ }
148
+ if (json.devDependencies) {
149
+ Object.assign(json.devDependencies, await getLatest(existing.devDependencies));
150
+ }
151
+ }, project.directory);
152
+ }
153
+
154
+ /**
155
+ * The base doesn't emit a babel.config.js, but this can run over an
156
+ * existing project that has one -- and a JavaScript project's config
157
+ * must not reference the TS plugin.
158
+ *
159
+ * @param {import('#utils/project.js').Project} project
160
+ */
161
+ async function updateBabelConfig(project) {
162
+ if (await project.hasOrWantsLayer("typescript")) return;
163
+ if (!project.hasFile("babel.config.js")) return;
164
+
165
+ await removeConfiguredPlugin(project, "@babel/plugin-transform-typescript");
166
+ }
package/src/cli/args.js CHANGED
@@ -35,10 +35,15 @@ const { values } = parseArgs({
35
35
  type: "string",
36
36
  choices: ["replace", "update"],
37
37
  },
38
+
39
+ write: {
40
+ type: "string",
41
+ choices: ["yes", "no"],
42
+ },
38
43
  },
39
44
  });
40
45
 
41
- const { replaceOrUpdate, name, type, layers = [], packageManager, path, confirm } = values;
46
+ const { replaceOrUpdate, name, type, layers = [], packageManager, path, confirm, write } = values;
42
47
 
43
48
  export const answers = {
44
49
  name,
@@ -48,6 +53,7 @@ export const answers = {
48
53
  path,
49
54
  confirm,
50
55
  replaceOrUpdate,
56
+ write,
51
57
  };
52
58
 
53
59
  /**
package/src/cli/index.js CHANGED
@@ -8,8 +8,12 @@ import { askProjectType } from "./questions/project-type.js";
8
8
  import { askPackageManager } from "./questions/package-manager.js";
9
9
  import { askPath } from "./questions/path.js";
10
10
  import { askIfOK } from "./questions/ok.js";
11
+ import { askToWrite } from "./questions/write.js";
11
12
  import { styleText } from "node:util";
13
+ import { readFile, rm } from "node:fs/promises";
14
+ import { join } from "node:path";
12
15
  import { Project } from "#utils/project.js";
16
+ import { Stage } from "#utils/stage.js";
13
17
  import { askReplaceOrUpdate } from "./questions/replace-or-update.js";
14
18
 
15
19
  /**
@@ -28,10 +32,29 @@ async function main() {
28
32
  const projectPath = await askPath(projectName);
29
33
  const replaceOrUpdate = await askReplaceOrUpdate(projectPath);
30
34
  const projectType = await askProjectType();
31
- const selectedLayers = await askLayers();
35
+ const selectedLayers = await askLayers(projectType);
32
36
  const packageManager = await askPackageManager();
33
37
 
34
- let project = new Project(projectPath, {
38
+ await askIfOK();
39
+
40
+ /**
41
+ * Everything is generated in a stage: a real directory (in the OS temp
42
+ * dir) acting as a writable overlay of the target directory.
43
+ *
44
+ * When updating an existing project, the stage is seeded with a full
45
+ * copy of it (sans node_modules/.git), so the bases and layers run
46
+ * against the project's current state -- isSetup checks, package.json
47
+ * reads, file existence checks, etc. all see the existing files, and
48
+ * the codemods modify them in the stage exactly as if they were
49
+ * operating in place. "replace" starts from an empty stage instead.
50
+ *
51
+ * The target itself is not touched until stage.commit() below, which
52
+ * applies the difference between the stage and the target (or the
53
+ * subset of it the user accepts during review).
54
+ */
55
+ const stage = await Stage.create(projectPath, { seed: replaceOrUpdate !== "replace" });
56
+
57
+ let project = new Project(stage.directory, {
35
58
  name: projectName,
36
59
  type: projectType,
37
60
  path: projectPath,
@@ -39,28 +62,19 @@ async function main() {
39
62
  packageManager,
40
63
  });
41
64
 
42
- await askIfOK();
43
-
44
65
  const s = p.spinner();
45
66
  s.start("Creating your Ember app...");
46
67
 
47
68
  try {
48
- // Generate the project
49
- await generateProject(project, replaceOrUpdate);
50
-
51
- s.stop("Project created!");
52
-
53
- p.note(
54
- `cd ${projectName}\n` +
55
- `${packageManager} install\n` +
56
- `${packageManager} ${packageManager === "npm" ? "run " : ""}start`,
57
- "Next steps",
58
- );
69
+ // Generate the project (into the stage)
70
+ await generateProject(project);
59
71
 
60
- p.outro(styleText("green", "✓") + " Project ready! " + styleText("dim", "Happy coding!"));
72
+ s.stop("Project generated");
61
73
  } catch (err) {
62
74
  s.stop("Failed to create project");
63
75
 
76
+ await stage.discard();
77
+
64
78
  if (err instanceof Error) {
65
79
  p.cancel(`Error: ${err.message}`);
66
80
  if (err.stack) {
@@ -73,6 +87,66 @@ async function main() {
73
87
  p.cancel(`Error: ${String(err)}`);
74
88
  process.exit(1);
75
89
  }
90
+
91
+ const changes = await stage.changes();
92
+
93
+ if (changes.length === 0 && replaceOrUpdate !== "replace") {
94
+ await stage.discard();
95
+
96
+ p.outro(styleText("green", "✓") + " Already up to date -- nothing to write.");
97
+ return;
98
+ }
99
+
100
+ /**
101
+ * New projects (and "replace", which was already confirmed) are written
102
+ * without asking. Updates to an existing project must be confirmed:
103
+ * write all / review each change (accept/reject) / cancel.
104
+ */
105
+ const isUpdate = !stage.isNew && replaceOrUpdate !== "replace";
106
+
107
+ const toApply = isUpdate ? await askToWrite(stage, changes) : changes;
108
+
109
+ if (!toApply || toApply.length === 0) {
110
+ await stage.discard();
111
+
112
+ p.cancel("Cancelled -- no files were written");
113
+ return process.exit(0);
114
+ }
115
+
116
+ if (replaceOrUpdate === "replace") {
117
+ await rm(projectPath, { recursive: true, force: true });
118
+ }
119
+
120
+ await stage.commit(toApply);
121
+
122
+ p.log.success(`Applied ${toApply.length} change${toApply.length === 1 ? "" : "s"}`);
123
+
124
+ /**
125
+ * Converted / generated files are written expecting the project's own
126
+ * formatting and lint pass to run right away (e.g. blank lines between
127
+ * import groups are `eslint --fix`'s job) -- so when the project has a
128
+ * lint:fix script, surface it as the step right after install.
129
+ */
130
+ let lintFixStep = "";
131
+ try {
132
+ const manifest = JSON.parse(await readFile(join(projectPath, "package.json"), "utf-8"));
133
+
134
+ if (manifest.scripts?.["lint:fix"]) {
135
+ lintFixStep = `${packageManager} ${packageManager === "npm" ? "run " : ""}lint:fix\n`;
136
+ }
137
+ } catch {
138
+ // no readable package.json -- skip the suggestion
139
+ }
140
+
141
+ p.note(
142
+ `cd ${projectName}\n` +
143
+ `${packageManager} install\n` +
144
+ lintFixStep +
145
+ `${packageManager} ${packageManager === "npm" ? "run " : ""}start`,
146
+ "Next steps",
147
+ );
148
+
149
+ p.outro(styleText("green", "✓") + " Project ready! " + styleText("dim", "Happy coding!"));
76
150
  }
77
151
 
78
152
  main().catch((err) => {
@@ -3,7 +3,10 @@ import { discoverLayers } from "#layers";
3
3
 
4
4
  import * as p from "@clack/prompts";
5
5
 
6
- export async function askLayers() {
6
+ /**
7
+ * @param {import('#types').ProjectType} projectType
8
+ */
9
+ export async function askLayers(projectType) {
7
10
  const optionalLayers = (await discoverLayers()).filter(
8
11
  (layer) => typeof layer.run === "function",
9
12
  );
@@ -34,7 +37,7 @@ export async function askLayers() {
34
37
  const defaultValues = (
35
38
  await Promise.all(
36
39
  optionalLayers.map(async (layer) => {
37
- let result = await layer.defaultValue?.();
40
+ let result = await layer.defaultValue?.(projectType);
38
41
 
39
42
  return result && layer.name;
40
43
  }),
@@ -41,11 +41,6 @@ export async function askProjectType() {
41
41
  ],
42
42
  });
43
43
 
44
- if (answer === "library") {
45
- p.cancel("Not implemented yet, apologies!");
46
- return process.exit(0);
47
- }
48
-
49
44
  if (p.isCancel(answer)) {
50
45
  p.cancel("Operation cancelled");
51
46
  return process.exit(0);
@@ -0,0 +1,169 @@
1
+ import * as p from "@clack/prompts";
2
+ import { styleText } from "node:util";
3
+
4
+ import { answers, printArgInUse } from "#args";
5
+
6
+ const SUPPORTED = new Set(["yes", "no"]);
7
+
8
+ /**
9
+ *
10
+ * @param {string | undefined} selected
11
+ * @returns {selected is 'yes' | 'no'}
12
+ */
13
+ function isValid(selected) {
14
+ if (!selected) return false;
15
+
16
+ return SUPPORTED.has(selected);
17
+ }
18
+
19
+ /**
20
+ * Nothing has been written to the target directory yet -- everything so
21
+ * far only exists in the stage. Ask whether to write the staged changes,
22
+ * with the option to review the diff change-by-change, accepting or
23
+ * rejecting each one.
24
+ *
25
+ * @param {import('#utils/stage.js').Stage} stage
26
+ * @param {import('#utils/stage.js').Change[]} changes
27
+ * @returns {Promise<import('#utils/stage.js').Change[] | null>}
28
+ * the changes to write, or null to cancel without writing anything
29
+ */
30
+ export async function askToWrite(stage, changes) {
31
+ if (isValid(answers.write)) {
32
+ printArgInUse("write", answers.write);
33
+
34
+ return answers.write === "yes" ? changes : null;
35
+ }
36
+
37
+ p.note(summarize(changes), `${changes.length} staged change${changes.length === 1 ? "" : "s"}`);
38
+
39
+ const answer = await p.select({
40
+ message: `Write these changes to ${stage.targetDirectory}?`,
41
+ options: [
42
+ { value: "yes", label: "write the files", hint: "apply all staged changes" },
43
+ {
44
+ value: "review",
45
+ label: "review the diff",
46
+ hint: "step through each change, accepting or rejecting it",
47
+ },
48
+ { value: "no", label: "cancel", hint: "discard the staged changes, touching nothing" },
49
+ ],
50
+ });
51
+
52
+ if (p.isCancel(answer) || answer === "no") {
53
+ return null;
54
+ }
55
+
56
+ if (answer === "yes") {
57
+ return changes;
58
+ }
59
+
60
+ return reviewEachChange(stage, changes);
61
+ }
62
+
63
+ /**
64
+ * Show each change's diff and ask what to do with it. "accept remaining"
65
+ * and "reject remaining" finish the review early, so they require an
66
+ * extra confirmation; declining the confirmation returns to the change
67
+ * being reviewed.
68
+ *
69
+ * @param {import('#utils/stage.js').Stage} stage
70
+ * @param {import('#utils/stage.js').Change[]} changes
71
+ * @returns {Promise<import('#utils/stage.js').Change[] | null>}
72
+ * the accepted changes, or null to cancel the whole run
73
+ */
74
+ async function reviewEachChange(stage, changes) {
75
+ /** @type {import('#utils/stage.js').Change[]} */
76
+ const accepted = [];
77
+ let index = 0;
78
+
79
+ while (index < changes.length) {
80
+ const change = changes[index];
81
+
82
+ if (!change) break;
83
+
84
+ const remaining = changes.length - index;
85
+
86
+ // Bypass clack for the diff itself: diffs are long, and clack's
87
+ // log decorations wrap every line.
88
+ console.log("\n" + (await stage.diff([change])));
89
+
90
+ const answer = await p.select({
91
+ message: `${change.path} (${index + 1} of ${changes.length})`,
92
+ options: [
93
+ { value: "accept", label: "accept", hint: "write this change" },
94
+ { value: "reject", label: "reject", hint: "skip this change" },
95
+ ...(remaining > 1
96
+ ? /** @type {const} */ ([
97
+ {
98
+ value: "accept-remaining",
99
+ label: "accept remaining",
100
+ hint: `accept this and the ${remaining - 1} unreviewed change${remaining - 1 === 1 ? "" : "s"} after it`,
101
+ },
102
+ {
103
+ value: "reject-remaining",
104
+ label: "reject remaining",
105
+ hint: `reject this and the ${remaining - 1} unreviewed change${remaining - 1 === 1 ? "" : "s"} after it`,
106
+ },
107
+ ])
108
+ : []),
109
+ ],
110
+ });
111
+
112
+ if (p.isCancel(answer)) {
113
+ return null;
114
+ }
115
+
116
+ if (answer === "accept") {
117
+ accepted.push(change);
118
+ index++;
119
+ continue;
120
+ }
121
+
122
+ if (answer === "reject") {
123
+ index++;
124
+ continue;
125
+ }
126
+
127
+ const bulkAccept = answer === "accept-remaining";
128
+ const confirmed = await p.confirm({
129
+ message: `${bulkAccept ? "Accept" : "Reject"} all ${remaining} remaining change${remaining === 1 ? "" : "s"} without reviewing them?`,
130
+ });
131
+
132
+ if (p.isCancel(confirmed) || !confirmed) {
133
+ // return to the change being reviewed
134
+ continue;
135
+ }
136
+
137
+ if (bulkAccept) {
138
+ accepted.push(...changes.slice(index));
139
+ }
140
+
141
+ break;
142
+ }
143
+
144
+ p.log.info(
145
+ `Accepted ${accepted.length} of ${changes.length} change${changes.length === 1 ? "" : "s"}`,
146
+ );
147
+
148
+ return accepted;
149
+ }
150
+
151
+ /**
152
+ * @param {import('#utils/stage.js').Change[]} changes
153
+ * @returns {string}
154
+ */
155
+ function summarize(changes) {
156
+ const colors = /** @type {const} */ ({
157
+ added: "green",
158
+ modified: "yellow",
159
+ deleted: "red",
160
+ });
161
+
162
+ return changes
163
+ .map((change) => {
164
+ const letter = change.status[0]?.toUpperCase() ?? "?";
165
+
166
+ return `${styleText(colors[change.status], letter)} ${change.path}`;
167
+ })
168
+ .join("\n");
169
+ }
package/src/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { generateProject } from "./cli/generator.js";
2
2
  export { Project } from "./utils/project.js";
3
+ export { Stage } from "./utils/stage.js";
@@ -0,0 +1,74 @@
1
+ import { packageJson } from "ember-apply";
2
+ import { getLatest } from "#utils/npm.js";
3
+ import { addTsdownConfigProperty, hasTsdownConfigProperty } from "#utils/tsdown-config.js";
4
+
5
+ const deps = {
6
+ "@arethetypeswrong/core": "^0.18.5",
7
+ };
8
+
9
+ /**
10
+ * @type {import('#types').Layer}
11
+ */
12
+ export default {
13
+ label: "Are The Types Wrong?",
14
+ hint: "validate the published declarations on every build",
15
+
16
+ async run(project) {
17
+ // Publish checks: apps aren't published
18
+ if (project.type !== "library") return;
19
+
20
+ // Declarations are the thing being checked
21
+ if (!(await project.hasOrWantsLayer("typescript"))) return;
22
+
23
+ // ESM-only packages don't offer the CJS resolution modes the strict
24
+ // profile requires
25
+ await addTsdownConfigProperty(project, "attw", `attw: { profile: "esm-only" }`);
26
+ await packageJson.addDevDependencies(await getLatest(deps), project.directory);
27
+ },
28
+
29
+ /**
30
+ * @overload
31
+ * @param {import('#utils/project.js').Project} project
32
+ * @param {true} explain
33
+ * @returns {Promise<{ isSetup: boolean; reasons: string[] }>}
34
+ */
35
+ /**
36
+ * @overload
37
+ * @param {import('#utils/project.js').Project} project
38
+ * @param {boolean | undefined} [explain]
39
+ * @returns {Promise<boolean>}
40
+ */
41
+ async isSetup(project, explain) {
42
+ /** @type {string[]} */
43
+ const reasons = [];
44
+
45
+ // Nothing to set up outside TypeScript libraries, so nothing can be
46
+ // missing
47
+ if (project.type !== "library" || !(await project.hasOrWantsLayer("typescript"))) {
48
+ return explain ? { isSetup: true, reasons } : true;
49
+ }
50
+
51
+ if (!(await hasTsdownConfigProperty(project, "attw"))) {
52
+ if (!explain) return false;
53
+
54
+ reasons.push("tsdown.config.js does not configure attw");
55
+ }
56
+
57
+ let manifest = await packageJson.read(project.directory);
58
+
59
+ if (!manifest.devDependencies?.["@arethetypeswrong/core"]) {
60
+ if (!explain) return false;
61
+
62
+ reasons.push("package.json is missing @arethetypeswrong/core in devDependencies");
63
+ }
64
+
65
+ if (explain) {
66
+ return {
67
+ isSetup: reasons.length === 0,
68
+ reasons,
69
+ };
70
+ }
71
+
72
+ return reasons.length === 0;
73
+ },
74
+ };
@@ -1 +1,14 @@
1
- node_modules
1
+ # compiled output
2
+ /dist/
3
+ /dist-*
4
+
5
+ # dependencies
6
+ /node_modules/
7
+
8
+ # misc
9
+ *.local
10
+ /.pnp*
11
+ /.eslintcache
12
+ /coverage/
13
+ /npm-debug.log*
14
+ /testem.log
@@ -1,5 +1,6 @@
1
1
  import { hasGit, initGit, isInGit } from "#utils/git.js";
2
2
  import { formatLabel } from "#utils/cli.js";
3
+ import { existsSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { cp } from "node:fs/promises";
5
6
 
@@ -7,10 +8,7 @@ export default {
7
8
  label: formatLabel("git init"),
8
9
  hint: "set up this project as a fresh git repository",
9
10
 
10
- /**
11
- * @param {import('#utils/project.js').Project} project
12
- */
13
- async defaultValue(project) {
11
+ async defaultValue() {
14
12
  return !isInGit();
15
13
  },
16
14
 
@@ -27,7 +25,10 @@ export default {
27
25
  let gitIgnorePath = join(import.meta.dirname, "files/gitignore");
28
26
  let targetPath = join(project.directory, ".gitignore");
29
27
 
30
- await cp(gitIgnorePath, targetPath, { force: true });
28
+ // Don't clobber a .gitignore provided by a base or by the user
29
+ if (!existsSync(targetPath)) {
30
+ await cp(gitIgnorePath, targetPath);
31
+ }
31
32
  }
32
33
 
33
34
  return initOk;