ember.nvp 1.13.0 → 1.15.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.
@@ -2,8 +2,8 @@
2
2
  "solution": {
3
3
  "ember.nvp": {
4
4
  "impact": "minor",
5
- "oldVersion": "1.12.0",
6
- "newVersion": "1.13.0",
5
+ "oldVersion": "1.14.0",
6
+ "newVersion": "1.15.0",
7
7
  "tagName": "latest",
8
8
  "constraints": [
9
9
  {
@@ -23,5 +23,5 @@
23
23
  "oldVersion": "1.2.0"
24
24
  }
25
25
  },
26
- "description": "## Release (2026-09-30)\n\n* ember.nvp 1.13.0 (minor)\n\n#### :rocket: Enhancement\n* `ember.nvp`\n * [#154](https://github.com/NullVoxPopuli/ember.nvp/pull/154) Default the project type to the detected type on update ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n\n#### Committers: 1\n- @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n"
26
+ "description": "## Release (2026-10-02)\n\n* ember.nvp 1.15.0 (minor)\n\n#### :rocket: Enhancement\n* `ember.nvp`\n * [#158](https://github.com/NullVoxPopuli/ember.nvp/pull/158) Migrate projects from the older blueprints ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n\n#### Committers: 1\n- @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n"
27
27
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## Release (2026-10-02)
4
+
5
+ * ember.nvp 1.15.0 (minor)
6
+
7
+ #### :rocket: Enhancement
8
+ * `ember.nvp`
9
+ * [#158](https://github.com/NullVoxPopuli/ember.nvp/pull/158) Migrate projects from the older blueprints ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
10
+
11
+ #### Committers: 1
12
+ - @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
13
+
14
+ ## Release (2026-09-30)
15
+
16
+ * ember.nvp 1.14.0 (minor)
17
+
18
+ #### :rocket: Enhancement
19
+ * `ember.nvp`
20
+ * [#156](https://github.com/NullVoxPopuli/ember.nvp/pull/156) Give TypeScript libraries with tests a publish tsconfig ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
21
+
22
+ #### Committers: 1
23
+ - @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
24
+
3
25
  ## Release (2026-09-30)
4
26
 
5
27
  * ember.nvp 1.13.0 (minor)
package/README.md CHANGED
@@ -59,6 +59,29 @@ The accepted changes land as uncommitted edits, for you to review with your own
59
59
  New projects skip the confirmation, and are written as soon as generation succeeds.
60
60
  For scripting, `--write yes` / `--write no` answers the confirmation up front.
61
61
 
62
+ ### Migrating from an older blueprint
63
+
64
+ Run ember.nvp in a project from one of these blueprints, and choose update:
65
+
66
+ | Blueprint | Becomes |
67
+ | ------------------------------------------------------------------------------- | --------- |
68
+ | `@ember/addon-blueprint`, and the addon package of `@embroider/addon-blueprint` | a library |
69
+ | `@ember-tooling/classic-build-addon-blueprint` (classic addons) | a library |
70
+ | `@ember/app-blueprint` (Vite with `@embroider/compat`) | an app |
71
+ | `@ember-tooling/classic-build-app-blueprint` (classic apps) | an app |
72
+
73
+ ember.nvp checks the project before it asks about layers.
74
+ When the project uses something that ember.nvp does not support, such as v1 addons or `.hbs` templates, ember.nvp lists each item with what to do, and writes nothing.
75
+ Most of these items also work in the old setup, so you can fix them there first.
76
+
77
+ Otherwise, the migration keeps your code, and replaces the old build:
78
+
79
+ - libraries build with tsdown, and keep `addon-main.cjs`, so that apps with ember-cli can still use them
80
+ - apps build with Vite, without `@embroider/compat`
81
+
82
+ The layers that replace the old tooling, such as qunit for the tests, start selected.
83
+ After writing, ember.nvp lists what is left for you, such as settings to copy by hand.
84
+
62
85
  ### Wrapping
63
86
 
64
87
  The provided CLI is only a wrapper around the exported `generateProject` function.
@@ -84,6 +107,11 @@ await generateProject(new Project(
84
107
 
85
108
  All parts of the generator are idempotent, so running generators on existing projects _can_ no-op.
86
109
 
110
+ A project from an older blueprint is migrated first.
111
+ `generateProject` returns `{ todo }`, the list of what is left for the user.
112
+ When the project needs changes before it can migrate, `generateProject` throws a `MigrationError` with a `findings` list, and changes nothing.
113
+ `checkMigration(project)` gives the same lists without changing anything, and `formatFindings(findings)` prints them the way the CLI does.
114
+
87
115
  To get the same don't-write-until-confirmed behavior as the CLI, wrap generation in a `Stage`.
88
116
  A stage is a scratch copy of the target directory (without `node_modules` and `.git`).
89
117
  Layers run against that copy, and the target directory is untouched until you commit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember.nvp",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "ember project generator, alternative to the official blueprints, but more modern -- hopefully one day to upstream back in to ember-cli / official blueprints",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,6 +13,7 @@
13
13
  "#bases/*": "./src/bases/*/index.js",
14
14
  "#consolidators/*": "./src/consolidators/*",
15
15
  "#layers": "./src/layers/index.js",
16
+ "#migration-from": "./src/migration-from/index.js",
16
17
  "#types": "./src/utils/types.ts",
17
18
  "#utils/*": "./src/utils/*",
18
19
  "#test-helpers": "./test/test-helpers.ts"
@@ -1,13 +1,19 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
 
3
3
  import { bases } from "#bases";
4
+ import { migrate } from "#migration-from";
4
5
  import { consolidateLintingScripts } from "../consolidators/linting.js";
5
6
  import { hasGit } from "#utils/git.js";
6
7
  /**
7
8
  * Generate project files by running layer functions
8
9
  *
10
+ * A project from an older blueprint is migrated first.
11
+ * When it uses features that ember.nvp does not support,
12
+ * this throws a `MigrationError` that lists them, before anything changes.
13
+ *
9
14
  * @param {import('#utils/project.js').Project} project
10
15
  * @param {string} [replaceOrUpdate]
16
+ * @returns {Promise<{ todo: import('#types').Finding[] }>} work left for the user after a migration
11
17
  */
12
18
  export async function generateProject(project, replaceOrUpdate) {
13
19
  if (replaceOrUpdate === "replace") {
@@ -16,6 +22,8 @@ export async function generateProject(project, replaceOrUpdate) {
16
22
 
17
23
  await mkdir(project.directory, { recursive: true });
18
24
 
25
+ let migration = await migrate(project);
26
+
19
27
  await bases[project.desires.type].run(project);
20
28
 
21
29
  /**
@@ -51,6 +59,8 @@ export async function generateProject(project, replaceOrUpdate) {
51
59
  `[ember.nvp] Consolidation commit -- Please report issues to https://github.com/NullVoxPopuli/ember.nvp/`,
52
60
  );
53
61
  }
62
+
63
+ return { todo: migration?.report.todo ?? [] };
54
64
  }
55
65
 
56
66
  /**
package/src/cli/index.js CHANGED
@@ -17,6 +17,8 @@ import { cwd } from "#utils/cwd.js";
17
17
  import { Project } from "#utils/project.js";
18
18
  import { Stage } from "#utils/stage.js";
19
19
  import { askAboutProjectHere, askReplaceOrUpdate } from "./questions/replace-or-update.js";
20
+ import { checkForMigration, showTodo } from "./questions/migration.js";
21
+ import { MigrationError } from "#migration-from";
20
22
 
21
23
  /**
22
24
  * This whole file's primary purpose is to be an interactive CLI
@@ -35,7 +37,11 @@ async function main() {
35
37
  const projectPath = here ? cwd : await askPath(projectName);
36
38
  const replaceOrUpdate = here ?? (await askReplaceOrUpdate(projectPath));
37
39
  const projectType = await askProjectType(replaceOrUpdate === "update" ? projectPath : undefined);
38
- const selectedLayers = await askLayers(projectType);
40
+ const migrationLayers =
41
+ replaceOrUpdate === "update"
42
+ ? await checkForMigration(projectPath, projectName, projectType)
43
+ : [];
44
+ const selectedLayers = await askLayers(projectType, migrationLayers);
39
45
  const packageManager = await askPackageManager();
40
46
 
41
47
  // Layer options start from what the project already uses
@@ -82,9 +88,12 @@ async function main() {
82
88
  const s = p.spinner();
83
89
  s.start("Creating your Ember app...");
84
90
 
91
+ /** @type {import('#types').Finding[]} */
92
+ let todo = [];
93
+
85
94
  try {
86
95
  // Generate the project (into the stage)
87
- await generateProject(project);
96
+ ({ todo } = await generateProject(project));
88
97
 
89
98
  s.stop("Project generated");
90
99
  } catch (err) {
@@ -92,6 +101,12 @@ async function main() {
92
101
 
93
102
  await stage.discard();
94
103
 
104
+ // the message is the list for the user, and a stack trace adds nothing to it
105
+ if (err instanceof MigrationError) {
106
+ p.cancel(err.message);
107
+ process.exit(1);
108
+ }
109
+
95
110
  if (err instanceof Error) {
96
111
  p.cancel(`Error: ${err.message}`);
97
112
  if (err.stack) {
@@ -141,6 +156,8 @@ async function main() {
141
156
 
142
157
  p.log.success(`Applied ${toApply.length} change${toApply.length === 1 ? "" : "s"}`);
143
158
 
159
+ showTodo(todo);
160
+
144
161
  /**
145
162
  * Converted / generated files are written expecting the project's own
146
163
  * formatting and lint pass to run right away.
@@ -5,8 +5,9 @@ import * as p from "@clack/prompts";
5
5
 
6
6
  /**
7
7
  * @param {import('#types').ProjectType} projectType
8
+ * @param {string[]} [suggested] layers to select by default, beyond each layer's own default
8
9
  */
9
- export async function askLayers(projectType) {
10
+ export async function askLayers(projectType, suggested = []) {
10
11
  const optionalLayers = (await discoverLayers()).filter(
11
12
  (layer) => typeof layer.run === "function",
12
13
  );
@@ -37,7 +38,7 @@ export async function askLayers(projectType) {
37
38
  const defaultValues = (
38
39
  await Promise.all(
39
40
  optionalLayers.map(async (layer) => {
40
- let result = await layer.defaultValue?.(projectType);
41
+ let result = suggested.includes(layer.name) || (await layer.defaultValue?.(projectType));
41
42
 
42
43
  return result && layer.name;
43
44
  }),
@@ -0,0 +1,69 @@
1
+ import * as p from "@clack/prompts";
2
+ import { styleText } from "node:util";
3
+ import { checkMigration, formatFindings } from "#migration-from";
4
+ import { Project } from "#utils/project.js";
5
+
6
+ /**
7
+ * A project from an older blueprint is migrated during the update.
8
+ *
9
+ * The check runs before the layer questions,
10
+ * so that a project that ember.nvp cannot migrate stops here,
11
+ * with the list of what to change first.
12
+ *
13
+ * @param {string} projectPath
14
+ * @param {string} name
15
+ * @param {import('#types').ProjectType} type
16
+ * @returns {Promise<string[]>} layers that replace the old blueprint's tooling
17
+ */
18
+ export async function checkForMigration(projectPath, name, type) {
19
+ let project = new Project(projectPath, {
20
+ name,
21
+ type,
22
+ path: projectPath,
23
+ layers: [],
24
+ packageManager: "pnpm",
25
+ });
26
+
27
+ let spinner = p.spinner();
28
+
29
+ spinner.start("Checking for an older blueprint");
30
+
31
+ let result = await checkMigration(project);
32
+
33
+ if (!result) {
34
+ spinner.stop("No older blueprint found");
35
+ return [];
36
+ }
37
+
38
+ let { migration, report } = result;
39
+
40
+ if (report.unsupported.length > 0) {
41
+ spinner.stop(`This project is from ${migration.label}`);
42
+ p.log.error(
43
+ `ember.nvp cannot migrate this project yet. Change these first:\n\n` +
44
+ formatFindings(report.unsupported),
45
+ );
46
+
47
+ if (report.todo.length > 0) {
48
+ p.log.info(
49
+ `After that, the migration leaves these for you:\n\n${formatFindings(report.todo)}`,
50
+ );
51
+ }
52
+
53
+ p.cancel("Nothing was written");
54
+ return process.exit(1);
55
+ }
56
+
57
+ spinner.stop(`Migrating from ${styleText("cyan", migration.label)}`);
58
+
59
+ return report.layers;
60
+ }
61
+
62
+ /**
63
+ * @param {import('#types').Finding[]} todo
64
+ */
65
+ export function showTodo(todo) {
66
+ if (todo.length === 0) return;
67
+
68
+ p.note(formatFindings(todo), "Left to do after the migration");
69
+ }
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { generateProject } from "./cli/generator.js";
2
2
  export { Project } from "./utils/project.js";
3
3
  export { Stage } from "./utils/stage.js";
4
+ export { checkMigration, formatFindings, MigrationError } from "./migration-from/index.js";
@@ -1,7 +1,8 @@
1
1
  import { applyFolderTo } from "#utils/fs.js";
2
2
  import { getLatest } from "#utils/npm.js";
3
3
  import { hasConfiguredPlugin, prependPlugin, removeConfiguredPlugin } from "#utils/babel.js";
4
- import { packageJson } from "ember-apply";
4
+ import { addTsdownConfigProperty } from "#utils/tsdown-config.js";
5
+ import { packageJson, tsconfig } from "ember-apply";
5
6
  import { join } from "node:path";
6
7
 
7
8
  const deps = {
@@ -39,6 +40,7 @@ const libraryTsDeps = {
39
40
  };
40
41
 
41
42
  const TEST_BABEL_CONFIG = "config/test/babel.config.js";
43
+ const PUBLISH_TSCONFIG = "config/tsconfig.publish.json";
42
44
 
43
45
  /**
44
46
  * @param {import('#utils/project.js').Project} project
@@ -72,6 +74,10 @@ export default {
72
74
 
73
75
  if (isLibrary) {
74
76
  await syncTestBabelConfig(project, ts);
77
+
78
+ if (ts) {
79
+ await usePublishTsconfig(project);
80
+ }
75
81
  }
76
82
 
77
83
  await packageJson.addDevDependencies(
@@ -141,6 +147,12 @@ export default {
141
147
  }
142
148
  }
143
149
 
150
+ if (project.isLibrary && project.wantsTypeScript && !project.hasFile(PUBLISH_TSCONFIG)) {
151
+ if (!explain) return false;
152
+
153
+ reasons.push(`${PUBLISH_TSCONFIG} is missing`);
154
+ }
155
+
144
156
  if (explain) {
145
157
  return {
146
158
  isSetup: reasons.length === 0,
@@ -203,3 +215,45 @@ async function syncTestBabelConfig(project, ts) {
203
215
  TEST_BABEL_CONFIG,
204
216
  );
205
217
  }
218
+
219
+ /**
220
+ * A library's tsconfig.json starts as its publish config:
221
+ * it covers only `src`, with `isolatedDeclarations` for the declarations tsdown emits.
222
+ *
223
+ * Tests live outside `src`, and editors, `lint:types`, and eslint read tsconfig.json.
224
+ * So in a TypeScript library, this layer:
225
+ * - widens tsconfig.json to `src` and `tests`, without the publish-only settings
226
+ * - moves those settings to `config/tsconfig.publish.json`, which the build uses
227
+ *
228
+ * @param {import('#utils/project.js').Project} project
229
+ */
230
+ async function usePublishTsconfig(project) {
231
+ await applyFolderTo(join(import.meta.dirname, "library-ts-files"), project);
232
+ await addTsdownConfigProperty(project, "tsconfig", `tsconfig: "./${PUBLISH_TSCONFIG}"`);
233
+
234
+ let config = await tsconfig.read(project.directory);
235
+
236
+ if (!needsWidening(config)) return;
237
+
238
+ await tsconfig.modify((json) => {
239
+ json.include ||= [];
240
+
241
+ if (!json.include.includes("tests")) {
242
+ json.include.push("tests");
243
+ }
244
+
245
+ delete json.compilerOptions?.rootDir;
246
+ delete json.compilerOptions?.isolatedDeclarations;
247
+ }, project.directory);
248
+ }
249
+
250
+ /**
251
+ * @param {Record<string, any>} config
252
+ */
253
+ function needsWidening(config) {
254
+ return (
255
+ !config.include?.includes("tests") ||
256
+ config.compilerOptions?.rootDir !== undefined ||
257
+ config.compilerOptions?.isolatedDeclarations !== undefined
258
+ );
259
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "include": ["../src"],
4
+ "compilerOptions": {
5
+ "rootDir": "../src",
6
+ "isolatedDeclarations": true
7
+ }
8
+ }
@@ -0,0 +1,154 @@
1
+ import { existsSync, globSync } from "node:fs";
2
+ import { join, sep } from "node:path";
3
+ import { readJSON } from "#utils/fs.js";
4
+
5
+ /**
6
+ * @typedef {object} Dependency
7
+ * @property {string} name
8
+ * @property {Record<string, any>} manifest
9
+ */
10
+
11
+ /**
12
+ * Packages with the shape of a v1 addon that work without ember-cli
13
+ */
14
+ const WORKS_WITHOUT_EMBER_CLI = new Set(["@embroider/macros"]);
15
+
16
+ /**
17
+ * ember-cli runs v1 addons as part of its build.
18
+ * ember.nvp builds without ember-cli, so a v1 addon never loads.
19
+ *
20
+ * @param {Record<string, any>} manifest
21
+ */
22
+ export function isV1Addon(manifest) {
23
+ return (
24
+ Boolean(manifest.keywords?.includes("ember-addon")) &&
25
+ manifest["ember-addon"]?.version !== 2 &&
26
+ !WORKS_WITHOUT_EMBER_CLI.has(manifest.name)
27
+ );
28
+ }
29
+
30
+ /**
31
+ * The manifests of the dependencies that are installed in the project.
32
+ *
33
+ * A migration decides from the project's own files.
34
+ * An install is extra: when there is one, it tells which dependencies are v1 addons,
35
+ * and which modules the addons add to an app.
36
+ *
37
+ * Only the top level of node_modules is read.
38
+ * Every package manager puts a project's direct dependencies there.
39
+ *
40
+ * @param {string} directory the project
41
+ * @param {Record<string, string>} dependencies the dependencies to look for, by name
42
+ * @returns {Dependency[]} empty when the project has no node_modules
43
+ */
44
+ export function installedDependencies(directory, dependencies) {
45
+ let modules = join(directory, "node_modules");
46
+
47
+ if (!existsSync(modules)) return [];
48
+
49
+ /** @type {Dependency[]} */
50
+ let found = [];
51
+
52
+ for (let folder of globSync(["*", "@*/*"], { cwd: modules })) {
53
+ let name = folder.split(sep).join("/");
54
+
55
+ if (!(name in dependencies)) continue;
56
+
57
+ let manifest = readJSON(join(modules, folder, "package.json"));
58
+
59
+ if (manifest) found.push({ name, manifest });
60
+ }
61
+
62
+ return found.sort((a, b) => a.name.localeCompare(b.name));
63
+ }
64
+
65
+ /**
66
+ * @param {Dependency[]} dependencies
67
+ * @returns {string[]} such as `ember-power-select@6.0.1`
68
+ */
69
+ export function v1AddonsIn(dependencies) {
70
+ /** @type {string[]} */
71
+ let v1 = [];
72
+
73
+ for (let { name, manifest } of dependencies) {
74
+ if (isV1Addon(manifest)) v1.push(`${name}@${manifest.version}`);
75
+ }
76
+
77
+ return v1;
78
+ }
79
+
80
+ /**
81
+ * @typedef {object} AppModule
82
+ * @property {string} addon the package that provides it
83
+ * @property {string} path such as `services/session`
84
+ * @property {string | undefined} specifier the module to import it from
85
+ */
86
+
87
+ /**
88
+ * Modules that v2 addons merge into an app through `ember-addon.app-js`.
89
+ *
90
+ * `@embroider/compat` merges them.
91
+ * An ember.nvp app does not, so the app must register the ones it looks up by name.
92
+ *
93
+ * @param {Dependency[]} dependencies
94
+ * @returns {AppModule[]}
95
+ */
96
+ export function appModulesIn(dependencies) {
97
+ /** @type {AppModule[]} */
98
+ let modules = [];
99
+
100
+ for (let { name, manifest } of dependencies) {
101
+ let appJs = manifest["ember-addon"]?.["app-js"] ?? {};
102
+
103
+ for (let [appPath, file] of Object.entries(appJs)) {
104
+ modules.push({
105
+ addon: name,
106
+ path: appPath.replace(/^\.\//, "").replace(/\.\w+$/, ""),
107
+ specifier: exportedAs(name, manifest, String(file)),
108
+ });
109
+ }
110
+ }
111
+
112
+ return modules;
113
+ }
114
+
115
+ /**
116
+ * The specifier that reaches `target` through the package's `exports`.
117
+ *
118
+ * exports: { "./*": "./dist/*.js" }, target: ./dist/_app_/services/x.js
119
+ * → pkg/_app_/services/x
120
+ *
121
+ * @param {string} name
122
+ * @param {Record<string, any>} manifest
123
+ * @param {string} target a file in the package, such as `./dist/_app_/services/x.js`
124
+ * @returns {string | undefined}
125
+ */
126
+ function exportedAs(name, manifest, target) {
127
+ let exports = manifest.exports;
128
+
129
+ if (!exports) return `${name}/${target.replace(/^\.\//, "")}`;
130
+ if (typeof exports !== "object") return;
131
+
132
+ for (let [key, value] of Object.entries(exports)) {
133
+ let path = typeof value === "string" ? value : (value?.import ?? value?.default);
134
+
135
+ if (typeof path !== "string" || typeof key !== "string") continue;
136
+
137
+ if (!path.includes("*")) {
138
+ if (path === target) return `${name}/${key.replace(/^\.\//, "")}`;
139
+ continue;
140
+ }
141
+
142
+ let [before = "", after = ""] = path.split("*");
143
+
144
+ if (
145
+ target.startsWith(before) &&
146
+ target.endsWith(after) &&
147
+ target.length >= before.length + after.length
148
+ ) {
149
+ let matched = target.slice(before.length, target.length - after.length);
150
+
151
+ return `${name}/${key.replace(/^\.\//, "").replace("*", matched)}`;
152
+ }
153
+ }
154
+ }