ember.nvp 1.11.1 → 1.12.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.
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "solution": {
3
3
  "ember.nvp": {
4
- "impact": "patch",
5
- "oldVersion": "1.11.0",
6
- "newVersion": "1.11.1",
4
+ "impact": "minor",
5
+ "oldVersion": "1.11.2",
6
+ "newVersion": "1.12.0",
7
7
  "tagName": "latest",
8
8
  "constraints": [
9
9
  {
10
- "impact": "patch",
11
- "reason": "Appears in changelog section :bug: Bug Fix"
10
+ "impact": "minor",
11
+ "reason": "Appears in changelog section :rocket: Enhancement"
12
12
  }
13
13
  ],
14
14
  "pkgJSONPath": "./package.json"
@@ -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.11.1 (patch)\n\n#### :bug: Bug Fix\n* `ember.nvp`\n * [#148](https://github.com/NullVoxPopuli/ember.nvp/pull/148) Keep update runs working for deprecated and non-registry deps ([@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-09-30)\n\n* ember.nvp 1.12.0 (minor)\n\n#### :rocket: Enhancement\n* `ember.nvp`\n * [#152](https://github.com/NullVoxPopuli/ember.nvp/pull/152) Ask about the project in the current directory first ([@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
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Release (2026-09-30)
4
4
 
5
+ * ember.nvp 1.12.0 (minor)
6
+
7
+ #### :rocket: Enhancement
8
+ * `ember.nvp`
9
+ * [#152](https://github.com/NullVoxPopuli/ember.nvp/pull/152) Ask about the project in the current directory first ([@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.11.2 (patch)
17
+
18
+ #### :bug: Bug Fix
19
+ * `ember.nvp`
20
+ * [#150](https://github.com/NullVoxPopuli/ember.nvp/pull/150) Keep the range when bumping a dependency ([@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
+
25
+ ## Release (2026-09-30)
26
+
5
27
  * ember.nvp 1.11.1 (patch)
6
28
 
7
29
  #### :bug: Bug Fix
package/README.md CHANGED
@@ -37,6 +37,15 @@ npx NullVoxPopuli/ember.nvp
37
37
  ### Updating an existing project
38
38
 
39
39
  You can run `ember.nvp` on top of an existing project to add layers to it.
40
+
41
+ Run it inside the project. When the current directory has a `package.json`, the first question is what to do with it:
42
+
43
+ - **update**: update the project in this directory. The project name starts as the `package.json` name.
44
+ - **replace**: delete this directory and generate a new project in it.
45
+ - **new**: generate a new project in another directory.
46
+
47
+ `--path` skips this question, and `--replaceOrUpdate` answers it.
48
+
40
49
  Nothing is written to your project until you confirm. Before finishing, you choose to:
41
50
 
42
51
  - **write the files**: apply all of the staged changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember.nvp",
3
- "version": "1.11.1",
3
+ "version": "1.12.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",
@@ -29,7 +29,8 @@
29
29
  "enhanced-resolve": "^5.24.2",
30
30
  "execa": "^9.6.1",
31
31
  "latest-version": "^9.0.0",
32
- "package-name-regex": "^5.0.0"
32
+ "package-name-regex": "^5.0.0",
33
+ "semver": "^7.8.5"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@ember/app-tsconfig": "^2.0.0",
@@ -37,6 +38,7 @@
37
38
  "@glint/ember-tsc": "^1.7.5",
38
39
  "@glint/template": "^1.7.7",
39
40
  "@glint/tsserver-plugin": "^2.5.5",
41
+ "@types/semver": "^7.8.0",
40
42
  "concurrently": "^9.2.1",
41
43
  "ember-source": "^7.0.0",
42
44
  "prettier": "^3.8.3",
package/src/cli/index.js CHANGED
@@ -12,10 +12,11 @@ import { askIfOK } from "./questions/ok.js";
12
12
  import { askToWrite } from "./questions/write.js";
13
13
  import { styleText } from "node:util";
14
14
  import { readFile, rm } from "node:fs/promises";
15
- import { join } from "node:path";
15
+ import { join, relative } from "node:path";
16
+ import { cwd } from "#utils/cwd.js";
16
17
  import { Project } from "#utils/project.js";
17
18
  import { Stage } from "#utils/stage.js";
18
- import { askReplaceOrUpdate } from "./questions/replace-or-update.js";
19
+ import { askAboutProjectHere, askReplaceOrUpdate } from "./questions/replace-or-update.js";
19
20
 
20
21
  /**
21
22
  * This whole file's primary purpose is to be an interactive CLI
@@ -29,9 +30,10 @@ async function main() {
29
30
 
30
31
  p.intro(styleText(["bgCyan", "black"], " ember.nvp "));
31
32
 
32
- const projectName = await askName();
33
- const projectPath = await askPath(projectName);
34
- const replaceOrUpdate = await askReplaceOrUpdate(projectPath);
33
+ const here = await askAboutProjectHere();
34
+ const projectName = await askName(here === "update" ? cwd : undefined);
35
+ const projectPath = here ? cwd : await askPath(projectName);
36
+ const replaceOrUpdate = here ?? (await askReplaceOrUpdate(projectPath));
35
37
  const projectType = await askProjectType();
36
38
  const selectedLayers = await askLayers(projectType);
37
39
  const packageManager = await askPackageManager();
@@ -158,8 +160,10 @@ async function main() {
158
160
  // no readable package.json -- skip the suggestion
159
161
  }
160
162
 
163
+ let projectDir = relative(cwd, projectPath);
164
+
161
165
  p.note(
162
- `cd ${projectName}\n` +
166
+ (projectDir ? `cd ${projectDir}\n` : "") +
163
167
  `${packageManager} install\n` +
164
168
  lintFixStep +
165
169
  `${packageManager} ${packageManager === "npm" ? "run " : ""}start`,
@@ -1,10 +1,15 @@
1
1
  import { cancel, isCancel, text } from "@clack/prompts";
2
2
  import packageNameRegex from "package-name-regex";
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
3
5
  import { answers, printArgInUse } from "#args";
4
6
 
5
7
  const DEFAULT = "my-app";
6
8
 
7
- export async function askName() {
9
+ /**
10
+ * @param {string} [existingProject] a directory whose package.json name fills in the answer
11
+ */
12
+ export async function askName(existingProject) {
8
13
  if (answers.name) {
9
14
  let isValid = packageNameRegex.test(answers.name);
10
15
  if (isValid) {
@@ -13,10 +18,15 @@ export async function askName() {
13
18
  return answers.name;
14
19
  }
15
20
  }
21
+
22
+ let existingName = existingProject ? readName(existingProject) : undefined;
23
+ let fallback = existingName ?? DEFAULT;
24
+
16
25
  const projectName = await text({
17
26
  message: "What is your project name?",
18
- placeholder: DEFAULT,
19
- defaultValue: DEFAULT,
27
+ placeholder: fallback,
28
+ defaultValue: fallback,
29
+ initialValue: existingName,
20
30
  validate(value) {
21
31
  if (!value || value.length === 0) return;
22
32
 
@@ -33,5 +43,17 @@ export async function askName() {
33
43
  return process.exit(0);
34
44
  }
35
45
 
36
- return projectName ?? DEFAULT;
46
+ return projectName ?? fallback;
47
+ }
48
+
49
+ /**
50
+ * @param {string} directory
51
+ * @returns {string | undefined}
52
+ */
53
+ function readName(directory) {
54
+ let { name } = JSON.parse(readFileSync(join(directory, "package.json"), "utf-8"));
55
+
56
+ if (typeof name === "string" && packageNameRegex.test(name)) {
57
+ return name;
58
+ }
37
59
  }
@@ -2,11 +2,13 @@ import * as p from "@clack/prompts";
2
2
 
3
3
  import { answers, printArgInUse } from "#args";
4
4
  import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { cwd } from "#utils/cwd.js";
5
7
 
6
8
  /**
7
9
  *
8
10
  * @param {string | undefined} value
9
- * @returns
11
+ * @returns {value is "replace" | "update"}
10
12
  */
11
13
  function isValid(value) {
12
14
  if (!value) return false;
@@ -54,3 +56,51 @@ export async function askReplaceOrUpdate(projectPath) {
54
56
 
55
57
  return answer;
56
58
  }
59
+
60
+ /**
61
+ * A package.json in the current directory is most likely the project to work on.
62
+ * This question comes before the name, so that an update can default to that project's name.
63
+ *
64
+ * Skipped when `--path` picks the target.
65
+ *
66
+ * @returns {Promise<"replace" | "update" | undefined>} undefined for a new project in another directory
67
+ */
68
+ export async function askAboutProjectHere() {
69
+ if (answers.path) return;
70
+ if (!existsSync(join(cwd, "package.json"))) return;
71
+
72
+ if (isValid(answers.replaceOrUpdate)) {
73
+ printArgInUse("replaceOrUpdate", answers.replaceOrUpdate);
74
+
75
+ return answers.replaceOrUpdate;
76
+ }
77
+
78
+ const answer = await p.select({
79
+ message: "This directory has a package.json. What would you like to do?",
80
+ initialValue: "update",
81
+ options: [
82
+ {
83
+ value: "update",
84
+ label: "update",
85
+ hint: "Updates the project in this directory",
86
+ },
87
+ {
88
+ value: "replace",
89
+ label: "replace",
90
+ hint: "Deletes this directory and generates a new project in it",
91
+ },
92
+ {
93
+ value: "new",
94
+ label: "new",
95
+ hint: "Generates a new project in another directory",
96
+ },
97
+ ],
98
+ });
99
+
100
+ if (p.isCancel(answer)) {
101
+ p.cancel("Operation cancelled");
102
+ return process.exit(0);
103
+ }
104
+
105
+ return isValid(answer) ? answer : undefined;
106
+ }
package/src/utils/npm.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import latestVersion, { VersionNotFoundError } from "latest-version";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
+ import semver from "semver";
4
5
 
5
6
  /**
6
7
  * @type {{ [name: string]: { [version: string]: string } }}
@@ -56,12 +57,12 @@ export async function getLatest(deps) {
56
57
  version =
57
58
  "link:" + resolve(join(import.meta.dirname, "../../packages", LOCAL_PACKAGES[dep]));
58
59
  } else if (alias) {
59
- version = `npm:${alias.name}@${await resolveVersion(alias.name, alias.range)}`;
60
+ version = `npm:${alias.name}@${await bump(alias.name, alias.range)}`;
60
61
  } else {
61
62
  if (range == "workspace:*") {
62
63
  range = "latest";
63
64
  }
64
- version = isFromRegistry(range) ? await resolveVersion(dep, range) : range;
65
+ version = isFromRegistry(range) ? await bump(dep, range) : range;
65
66
  }
66
67
 
67
68
  CACHE[dep] ||= {};
@@ -85,6 +86,28 @@ function isFromRegistry(range) {
85
86
  return !/[:/]/.test(range);
86
87
  }
87
88
 
89
+ /**
90
+ * Moves a range to the newest version it allows, and keeps its kind:
91
+ * - `^5.4.1` becomes `^5.5.2`, and `~` works the same way
92
+ * - an exact version stays exact
93
+ * - a dist-tag becomes the version it points at
94
+ *
95
+ * Other ranges (`>= 4.1.0`, `1.x`, `^1 || ^2`) stay as written.
96
+ * A new floor there would drop versions that the project supports.
97
+ *
98
+ * @param {string} name
99
+ * @param {string} range
100
+ */
101
+ async function bump(name, range) {
102
+ let isTag = !semver.validRange(range);
103
+ let isExact = Boolean(semver.valid(range));
104
+ let [, operator = ""] = /^([\^~])\s*v?\d[\w.+-]*$/.exec(range) ?? [];
105
+
106
+ if (!isTag && !isExact && !operator) return range;
107
+
108
+ return operator + (await resolveVersion(name, range));
109
+ }
110
+
88
111
  /**
89
112
  * Deprecated versions are skipped when the range allows another version.
90
113
  *
@@ -2,7 +2,7 @@ import { test, expect as hardExpect } from "vitest";
2
2
  import { cli, mktemp } from "#test-helpers";
3
3
  import { packageJson } from "ember-apply";
4
4
  import { existsSync } from "node:fs";
5
- import { rm } from "node:fs/promises";
5
+ import { readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
 
@@ -153,3 +153,54 @@ test("cli exits on an invalid layer option flag, before writing anything", async
153
153
  await rm(parent, { recursive: true, force: true });
154
154
  }
155
155
  });
156
+
157
+ test("in a project directory, asks to update first and fills in the project's name", async () => {
158
+ let dir = await mktemp("cli-update-here");
159
+ await writeFile(join(dir, "package.json"), JSON.stringify({ name: "@scope/my-lib" }));
160
+
161
+ // No --name or --path, so the CLI asks about the project in this directory
162
+ let args = [
163
+ "--type",
164
+ "library",
165
+ "--layers",
166
+ "prettier",
167
+ "--packageManager",
168
+ "pnpm",
169
+ "--confirm",
170
+ "yes",
171
+ "--write",
172
+ "yes",
173
+ ];
174
+ let prompts = ["This directory has a package.json", "What is your project name?"];
175
+
176
+ try {
177
+ let { execaPromise } = cli(args, { cwd: dir });
178
+ let output = "";
179
+
180
+ execaPromise.stdout?.on("data", (chunk) => {
181
+ output += chunk;
182
+
183
+ // Enter keeps the answer each prompt starts with: "update", then the name
184
+ if (prompts[0] && stripVTControlCharacters(output).includes(prompts[0])) {
185
+ prompts.shift();
186
+ execaPromise.stdin?.write("\r");
187
+ }
188
+ });
189
+
190
+ let res = await execaPromise;
191
+ let text = stripVTControlCharacters(output);
192
+
193
+ expect(res.exitCode).toBe(0);
194
+ expect(text.indexOf("This directory has a package.json")).toBeLessThan(
195
+ text.indexOf("What is your project name?"),
196
+ );
197
+ expect(text).not.toContain("Where would you like to place your project?");
198
+ expect(text).not.toContain("cd ");
199
+
200
+ let manifest = JSON.parse(await readFile(join(dir, "package.json"), "utf-8"));
201
+
202
+ expect(manifest.name).toBe("@scope/my-lib");
203
+ } finally {
204
+ await rm(dir, { recursive: true, force: true });
205
+ }
206
+ });
@@ -55,8 +55,8 @@ describe("layer: typescript, version 7", () => {
55
55
  it("installs TypeScript 7 as @typescript/native, and TypeScript 6 for typescript-eslint", async () => {
56
56
  let deps = await devDependencies(project);
57
57
 
58
- expect(deps["@typescript/native"]).toMatch(/^npm:typescript@7\./);
59
- expect(deps["typescript"]).toMatch(/^npm:@typescript\/typescript6@6\./);
58
+ expect(deps["@typescript/native"]).toMatch(/^npm:typescript@\^7\./);
59
+ expect(deps["typescript"]).toMatch(/^npm:@typescript\/typescript6@\^6\./);
60
60
  expect(deps).toHaveProperty("ember-content-mapper");
61
61
  expect(deps).not.toHaveProperty("@glint/tsserver-plugin");
62
62
  });
@@ -172,7 +172,7 @@ describe("layer: typescript, version 7", () => {
172
172
  let manifest = await packageJson.read(project.directory);
173
173
 
174
174
  expect(manifest.scripts?.["lint:types"]).toBe("tsc --noEmit --runExternalCode");
175
- expect(manifest.devDependencies?.["@typescript/native"]).toMatch(/^npm:typescript@7\./);
175
+ expect(manifest.devDependencies?.["@typescript/native"]).toMatch(/^npm:typescript@\^7\./);
176
176
  expect(manifest.devDependencies).not.toHaveProperty("typescript");
177
177
  expect(manifest.devDependencies).not.toHaveProperty("@glint/tsserver-plugin");
178
178
  });
@@ -6,13 +6,35 @@ describe("getLatest", () => {
6
6
  // ESLint deprecated all of 9.x once 10 was out
7
7
  const { eslint } = await getLatest({ eslint: "^9.39.2" });
8
8
 
9
- expect(eslint).toMatch(/^9\.39\.\d+$/);
9
+ expect(eslint).toMatch(/^\^9\.39\.\d+$/);
10
10
  });
11
11
 
12
12
  it("still prefers versions that are not deprecated", async () => {
13
13
  const { eslint } = await getLatest({ eslint: "^10.0.0" });
14
14
 
15
- expect(eslint).toMatch(/^10\.\d+\.\d+$/);
15
+ expect(eslint).toMatch(/^\^10\.\d+\.\d+$/);
16
+ });
17
+
18
+ it("bumps a range without changing its kind", async () => {
19
+ const result = await getLatest({
20
+ "@ember/test-helpers": "^5.4.1",
21
+ "ember-source": "~7.2.0",
22
+ "@embroider/macros": "1.20.2",
23
+ "@ember/test-waiters": ">= 4.1.0",
24
+ "ember-modifier": "^4.2.0 || ^5.0.0",
25
+ "decorator-transforms": "latest",
26
+ typescript: "npm:@typescript/typescript6@^6.0.2",
27
+ });
28
+
29
+ expect(result).toEqual({
30
+ "@ember/test-helpers": expect.stringMatching(/^\^5\.\d+\.\d+$/),
31
+ "ember-source": expect.stringMatching(/^~7\.2\.\d+$/),
32
+ "@embroider/macros": "1.20.2",
33
+ "@ember/test-waiters": ">= 4.1.0",
34
+ "ember-modifier": "^4.2.0 || ^5.0.0",
35
+ "decorator-transforms": expect.stringMatching(/^\d+\.\d+\.\d+$/),
36
+ typescript: expect.stringMatching(/^npm:@typescript\/typescript6@\^6\.\d+\.\d+$/),
37
+ });
16
38
  });
17
39
 
18
40
  it("keeps specs that do not come from the registry", async () => {
@@ -119,7 +119,7 @@ export async function generate({
119
119
 
120
120
  const cliPath = join(import.meta.dirname, "../src/cli/index.js");
121
121
 
122
- export function cli(args: string[] = []) {
122
+ export function cli(args: string[] = [], options: { cwd?: string } = {}) {
123
123
  let cmd = `node ${cliPath} ${args.join(" ")}`;
124
124
 
125
125
  console.log(`Running '${styleText("cyan", cmd)}'`);
@@ -127,7 +127,7 @@ export function cli(args: string[] = []) {
127
127
  let output = new MockWritable();
128
128
  let input = new MockReadable();
129
129
 
130
- let execaPromise = execa("node", [cliPath, ...args]);
130
+ let execaPromise = execa("node", [cliPath, ...args], options);
131
131
  if (execaPromise.stdout) {
132
132
  execaPromise.stdout.pipe(output);
133
133
  }