ember.nvp 1.11.2 → 1.13.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.1",
6
- "newVersion": "1.11.2",
4
+ "impact": "minor",
5
+ "oldVersion": "1.12.0",
6
+ "newVersion": "1.13.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.2 (patch)\n\n#### :bug: Bug Fix\n* `ember.nvp`\n * [#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))\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.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"
27
27
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Release (2026-09-30)
4
4
 
5
+ * ember.nvp 1.13.0 (minor)
6
+
7
+ #### :rocket: Enhancement
8
+ * `ember.nvp`
9
+ * [#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))
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.12.0 (minor)
17
+
18
+ #### :rocket: Enhancement
19
+ * `ember.nvp`
20
+ * [#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))
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.2 (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 name starts as the `package.json` name, and the type starts as the type that ember.nvp detects (app, library, browser extension, or custom element).
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.2",
3
+ "version": "1.13.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",
@@ -9,6 +9,7 @@
9
9
  "type": "module",
10
10
  "imports": {
11
11
  "#args": "./src/cli/args.js",
12
+ "#bases": "./src/bases/index.js",
12
13
  "#bases/*": "./src/bases/*/index.js",
13
14
  "#consolidators/*": "./src/consolidators/*",
14
15
  "#layers": "./src/layers/index.js",
@@ -0,0 +1,32 @@
1
+ import app from "./minimal-app/index.js";
2
+ import customElement from "./minimal-custom-element/index.js";
3
+ import extension from "./minimal-extension/index.js";
4
+ import library from "./minimal-library/index.js";
5
+
6
+ /**
7
+ * The base for each project type.
8
+ *
9
+ * Detection asks the bases in this order,
10
+ * so each base comes before the bases whose markers it also has:
11
+ * - an extension has the index.html and app/ folder of an app
12
+ * - a custom element builds with tsdown, like a library
13
+ * - a library can have an app/ folder (classic addons) or an index.html (a docs app)
14
+ *
15
+ * @type {Record<import('#types').ProjectType, import('#types').Base>}
16
+ */
17
+ export const bases = {
18
+ extension,
19
+ "custom-element": customElement,
20
+ library,
21
+ app,
22
+ };
23
+
24
+ /**
25
+ * @param {string} directory an existing project
26
+ * @returns {import('#types').ProjectType | undefined}
27
+ */
28
+ export function detectProjectType(directory) {
29
+ for (let [type, base] of Object.entries(bases)) {
30
+ if (base.detect(directory)) return /** @type {import('#types').ProjectType} */ (type);
31
+ }
32
+ }
@@ -1,7 +1,8 @@
1
1
  import { packageJson, js } from "ember-apply";
2
+ import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { getLatest } from "#utils/npm.js";
4
- import { applyFolderTo } from "#utils/fs.js";
5
+ import { applyFolderTo, readJSON } from "#utils/fs.js";
5
6
  import { removeConfiguredPlugin } from "#utils/babel.js";
6
7
 
7
8
  /**
@@ -14,11 +15,23 @@ import { removeConfiguredPlugin } from "#utils/babel.js";
14
15
  * - No linting or formatting
15
16
  * - No ember-welcome-page
16
17
  * - No warp-drive (opt-in if needed)
18
+ *
19
+ * @type {import('#types').Base}
17
20
  */
18
21
  export default {
19
22
  label: "Minimal App Base",
20
23
  description: "Bare minimum Ember app structure",
21
24
 
25
+ /**
26
+ * @param {string} directory
27
+ */
28
+ detect(directory) {
29
+ return (
30
+ readJSON(join(directory, "package.json"))?.["ember-addon"]?.type === "app" ||
31
+ existsSync(join(directory, "app"))
32
+ );
33
+ },
34
+
22
35
  /**
23
36
  * 1. Apply files
24
37
  * a. Remove TS if needed
@@ -1,4 +1,5 @@
1
1
  import { packageJson } from "ember-apply";
2
+ import { existsSync, readFileSync } from "node:fs";
2
3
  import { readFile, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { getLatest } from "#utils/npm.js";
@@ -26,11 +27,24 @@ const TEMPLATE_TAG_NAME = "minimal-custom-element";
26
27
  * - No linting or formatting
27
28
  *
28
29
  * Testing, linting, formatting, etc. are opt-in via layers.
30
+ *
31
+ * @type {import('#types').Base}
29
32
  */
30
33
  export default {
31
34
  label: "Minimal Custom Element Base",
32
35
  description: "An Ember component packaged as a custom element (web component)",
33
36
 
37
+ /**
38
+ * @param {string} directory
39
+ */
40
+ detect(directory) {
41
+ return ["src/register.ts", "src/register.js"].some((file) => {
42
+ let path = join(directory, file);
43
+
44
+ return existsSync(path) && readFileSync(path, "utf-8").includes("customElements.define");
45
+ });
46
+ },
47
+
34
48
  /**
35
49
  * 1. Apply files
36
50
  * a. Remove TS if needed
@@ -2,7 +2,7 @@ import { packageJson } from "ember-apply";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { getLatest } from "#utils/npm.js";
5
- import { applyFolderTo } from "#utils/fs.js";
5
+ import { applyFolderTo, readJSON } from "#utils/fs.js";
6
6
  import { removeConfiguredPlugin } from "#utils/babel.js";
7
7
 
8
8
  /**
@@ -17,11 +17,25 @@ import { removeConfiguredPlugin } from "#utils/babel.js";
17
17
  * - No testing framework
18
18
  * - No linting or formatting
19
19
  * - No ember-welcome-page
20
+ *
21
+ * @type {import('#types').Base}
20
22
  */
21
23
  export default {
22
24
  label: "Minimal Extension Base",
23
25
  description: "Bare minimum browser extension structure with an Ember popup",
24
26
 
27
+ /**
28
+ * A web app can have a manifest.json too (for a PWA),
29
+ * but only a browser extension's manifest has `manifest_version`.
30
+ *
31
+ * @param {string} directory
32
+ */
33
+ detect(directory) {
34
+ return ["manifest.json", "public/manifest.json"].some(
35
+ (file) => readJSON(join(directory, file))?.manifest_version !== undefined,
36
+ );
37
+ },
38
+
25
39
  /**
26
40
  * 1. Apply files
27
41
  * a. Remove TS if needed
@@ -1,8 +1,9 @@
1
1
  import { packageJson } from "ember-apply";
2
+ import { existsSync } from "node:fs";
2
3
  import { readFile, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { getLatest } from "#utils/npm.js";
5
- import { applyFolderTo } from "#utils/fs.js";
6
+ import { applyFolderTo, readJSON } from "#utils/fs.js";
6
7
  import { removeConfiguredPlugin } from "#utils/babel.js";
7
8
 
8
9
  /**
@@ -18,11 +19,29 @@ import { removeConfiguredPlugin } from "#utils/babel.js";
18
19
  * - No linting or formatting
19
20
  *
20
21
  * Testing, linting, formatting, etc. are opt-in via layers.
22
+ *
23
+ * @type {import('#types').Base}
21
24
  */
22
25
  export default {
23
26
  label: "Minimal Library Base",
24
27
  description: "Bare minimum Ember v2 library (addon) structure",
25
28
 
29
+ /**
30
+ * `ember-addon` marks v2 and classic addons.
31
+ * A library from this base has no `ember-addon`, only a tsdown config.
32
+ *
33
+ * @param {string} directory
34
+ */
35
+ detect(directory) {
36
+ let manifest = readJSON(join(directory, "package.json"));
37
+
38
+ return (
39
+ manifest?.["ember-addon"]?.type === "addon" ||
40
+ Boolean(manifest?.keywords?.includes("ember-addon")) ||
41
+ existsSync(join(directory, "tsdown.config.js"))
42
+ );
43
+ },
44
+
26
45
  /**
27
46
  * 1. Apply files
28
47
  * a. Remove TS if needed
@@ -1,9 +1,6 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
 
3
- import baseApp from "#bases/minimal-app";
4
- import baseExtension from "#bases/minimal-extension";
5
- import baseLibrary from "#bases/minimal-library";
6
- import baseCustomElement from "#bases/minimal-custom-element";
3
+ import { bases } from "#bases";
7
4
  import { consolidateLintingScripts } from "../consolidators/linting.js";
8
5
  import { hasGit } from "#utils/git.js";
9
6
  /**
@@ -19,20 +16,7 @@ export async function generateProject(project, replaceOrUpdate) {
19
16
 
20
17
  await mkdir(project.directory, { recursive: true });
21
18
 
22
- switch (project.desires.type) {
23
- case "app":
24
- await baseApp.run(project);
25
- break;
26
- case "library":
27
- await baseLibrary.run(project);
28
- break;
29
- case "extension":
30
- await baseExtension.run(project);
31
- break;
32
- case "custom-element":
33
- await baseCustomElement.run(project);
34
- break;
35
- }
19
+ await bases[project.desires.type].run(project);
36
20
 
37
21
  /**
38
22
  * We could run these in a loop until there is no git diff
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,10 +30,11 @@ 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);
35
- const projectType = await askProjectType();
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));
37
+ const projectType = await askProjectType(replaceOrUpdate === "update" ? projectPath : undefined);
36
38
  const selectedLayers = await askLayers(projectType);
37
39
  const packageManager = await askPackageManager();
38
40
 
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { answers, printArgInUse } from "#args";
3
+ import { detectProjectType } from "#bases";
3
4
 
4
5
  /**
5
6
  * addon is alias for library
@@ -27,10 +28,10 @@ function isValid(selected) {
27
28
  }
28
29
 
29
30
  /**
30
- *
31
+ * @param {string} [existingProject] a directory whose project type becomes the default answer
31
32
  * @return {Promise<import('#types').ProjectType>}
32
33
  */
33
- export async function askProjectType() {
34
+ export async function askProjectType(existingProject) {
34
35
  if (isValid(answers.type)) {
35
36
  printArgInUse("type", answers.type);
36
37
 
@@ -43,6 +44,7 @@ export async function askProjectType() {
43
44
 
44
45
  const answer = await p.select({
45
46
  message: "Which type of project?",
47
+ initialValue: existingProject ? detectProjectType(existingProject) : undefined,
46
48
  options: [
47
49
  { value: "app", label: "web app", hint: "generates html, js, and css to deploy to the web" },
48
50
  {
@@ -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/fs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import { readFile, glob, mkdir, writeFile } from "node:fs/promises";
3
3
  import { join, dirname } from "node:path";
4
4
  import { parse as parsePath } from "node:path";
@@ -15,6 +15,23 @@ import { rewriteImportsToMatchFiles } from "./rewrite-imports.js";
15
15
  */
16
16
  const EVERY_FILE = ["**/*", "**/.*", "**/.*/**/*"];
17
17
 
18
+ /**
19
+ * Project detection reads files that can be missing or broken,
20
+ * and a guess must not stop the CLI.
21
+ *
22
+ * @param {string} path
23
+ * @returns {any} undefined when the file is missing or is not JSON
24
+ */
25
+ export function readJSON(path) {
26
+ if (!existsSync(path)) return;
27
+
28
+ try {
29
+ return JSON.parse(readFileSync(path, "utf-8"));
30
+ } catch {
31
+ return;
32
+ }
33
+ }
34
+
18
35
  /**
19
36
  * Modified version of applyFolder from ember-apply.
20
37
  *
@@ -89,6 +89,18 @@ export interface Layer {
89
89
  >;
90
90
  }
91
91
 
92
+ export interface Base {
93
+ label: string;
94
+ description: string;
95
+ /**
96
+ * Whether the directory holds a project of this base's type.
97
+ *
98
+ * When updating a project, the type question starts at the first base that detects it.
99
+ */
100
+ detect: (directory: string) => boolean;
101
+ run: (project: Project) => Promise<void>;
102
+ }
103
+
92
104
  export interface DiscoveredLayer extends Layer {
93
105
  /**
94
106
  * The unique name of the layer.
@@ -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,61 @@ 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 defaults to the project's name and type", async () => {
158
+ let dir = await mktemp("cli-update-here");
159
+ await writeFile(
160
+ join(dir, "package.json"),
161
+ JSON.stringify({ name: "@scope/my-lib", "ember-addon": { version: 2, type: "addon" } }),
162
+ );
163
+
164
+ // No --name, --path, or --type, so the CLI asks about the project in this directory
165
+ let args = [
166
+ "--layers",
167
+ "prettier",
168
+ "--packageManager",
169
+ "pnpm",
170
+ "--confirm",
171
+ "yes",
172
+ "--write",
173
+ "yes",
174
+ ];
175
+ let prompts = [
176
+ "This directory has a package.json",
177
+ "What is your project name?",
178
+ "Which type of project?",
179
+ ];
180
+
181
+ try {
182
+ let { execaPromise } = cli(args, { cwd: dir });
183
+ let output = "";
184
+
185
+ execaPromise.stdout?.on("data", (chunk) => {
186
+ output += chunk;
187
+
188
+ // Enter keeps the answer each prompt starts with: "update", the name, then the type
189
+ if (prompts[0] && stripVTControlCharacters(output).includes(prompts[0])) {
190
+ prompts.shift();
191
+ execaPromise.stdin?.write("\r");
192
+ }
193
+ });
194
+
195
+ let res = await execaPromise;
196
+ let text = stripVTControlCharacters(output);
197
+
198
+ expect(res.exitCode).toBe(0);
199
+ expect(text.indexOf("This directory has a package.json")).toBeLessThan(
200
+ text.indexOf("What is your project name?"),
201
+ );
202
+ expect(text).not.toContain("Where would you like to place your project?");
203
+ expect(text).toMatch(/Which type of project\?\n│ library\n/);
204
+ expect(existsSync(join(dir, "tsdown.config.js")), "the library base ran").toBe(true);
205
+ expect(text).not.toContain("cd ");
206
+
207
+ let manifest = JSON.parse(await readFile(join(dir, "package.json"), "utf-8"));
208
+
209
+ expect(manifest.name).toBe("@scope/my-lib");
210
+ } finally {
211
+ await rm(dir, { recursive: true, force: true });
212
+ }
213
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, afterAll } from "vitest";
2
+ import { generate, mktemp } from "#test-helpers";
3
+ import { detectProjectType } from "#bases";
4
+ import { mkdir, rm, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import type { ProjectType } from "#types";
7
+
8
+ const dirs: string[] = [];
9
+
10
+ afterAll(async () => {
11
+ if (process.env.CI) return;
12
+
13
+ for (const dir of dirs) {
14
+ await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
15
+ }
16
+ });
17
+
18
+ async function fixture(files: Record<string, string>) {
19
+ let dir = await mktemp("detect-project-type");
20
+ dirs.push(dir);
21
+
22
+ for (let [file, contents] of Object.entries(files)) {
23
+ await mkdir(dirname(join(dir, file)), { recursive: true });
24
+ await writeFile(join(dir, file), contents);
25
+ }
26
+
27
+ return dir;
28
+ }
29
+
30
+ describe("detectProjectType", () => {
31
+ const types: ProjectType[] = ["app", "library", "extension", "custom-element"];
32
+
33
+ for (const type of types) {
34
+ it(`detects a generated ${type}`, async () => {
35
+ const project = await generate({ type });
36
+ dirs.push(project.directory);
37
+
38
+ expect(detectProjectType(project.directory)).toBe(type);
39
+ });
40
+ }
41
+
42
+ it("detects a v2 addon with a docs app as a library", async () => {
43
+ const dir = await fixture({
44
+ "package.json": JSON.stringify({ "ember-addon": { version: 2, type: "addon" } }),
45
+ "index.html": "<!doctype html>",
46
+ "src/index.ts": "",
47
+ });
48
+
49
+ expect(detectProjectType(dir)).toBe("library");
50
+ });
51
+
52
+ it("detects a classic addon as a library", async () => {
53
+ const dir = await fixture({
54
+ "package.json": JSON.stringify({ keywords: ["ember-addon"] }),
55
+ "addon/index.js": "",
56
+ "app/index.js": "",
57
+ });
58
+
59
+ expect(detectProjectType(dir)).toBe("library");
60
+ });
61
+
62
+ it("detects a vite app", async () => {
63
+ const dir = await fixture({
64
+ "package.json": JSON.stringify({ "ember-addon": { version: 2, type: "app" } }),
65
+ "index.html": "<!doctype html>",
66
+ });
67
+
68
+ expect(detectProjectType(dir)).toBe("app");
69
+ });
70
+
71
+ it("detects an app with a web app manifest as an app", async () => {
72
+ const dir = await fixture({
73
+ "package.json": "{}",
74
+ "app/app.ts": "",
75
+ "public/manifest.json": JSON.stringify({ name: "my-pwa", display: "standalone" }),
76
+ });
77
+
78
+ expect(detectProjectType(dir)).toBe("app");
79
+ });
80
+
81
+ it("makes no guess for a project it does not know", async () => {
82
+ const dir = await fixture({ "package.json": JSON.stringify({ name: "a-node-script" }) });
83
+
84
+ expect(detectProjectType(dir)).toBe(undefined);
85
+ });
86
+ });
@@ -131,7 +131,7 @@ describe("layer: qunit", () => {
131
131
  });
132
132
 
133
133
  describe("app", () => {
134
- it("it boots and run tests", async () => {
134
+ it("it boots and run tests", { timeout: 300_000 }, async () => {
135
135
  const project = await generate({ type: "app", layers: ["qunit"] });
136
136
  dirs.push(project.directory);
137
137
 
@@ -271,11 +271,15 @@ describe("layer: qunit", () => {
271
271
  `);
272
272
  });
273
273
 
274
- it("installs, and tests generated against the library's exports pass", async () => {
275
- await writeLibrarySource(project, "typescript");
276
- await writeExampleTests(project, "typescript");
277
- await installAndTest(project);
278
- });
274
+ it(
275
+ "installs, and tests generated against the library's exports pass",
276
+ { timeout: 300_000 },
277
+ async () => {
278
+ await writeLibrarySource(project, "typescript");
279
+ await writeExampleTests(project, "typescript");
280
+ await installAndTest(project);
281
+ },
282
+ );
279
283
 
280
284
  it("the publish build still ships precompileTemplate", async () => {
281
285
  let build = await execa("pnpm build", { cwd: project.directory, shell: true });
@@ -363,11 +367,15 @@ describe("layer: qunit", () => {
363
367
  `);
364
368
  });
365
369
 
366
- it("installs, and tests generated against the library's exports pass", async () => {
367
- await writeLibrarySource(project, "javascript");
368
- await writeExampleTests(project, "javascript");
369
- await installAndTest(project);
370
- });
370
+ it(
371
+ "installs, and tests generated against the library's exports pass",
372
+ { timeout: 300_000 },
373
+ async () => {
374
+ await writeLibrarySource(project, "javascript");
375
+ await writeExampleTests(project, "javascript");
376
+ await installAndTest(project);
377
+ },
378
+ );
371
379
 
372
380
  it("the publish build still ships precompileTemplate", async () => {
373
381
  let build = await execa("pnpm build", { cwd: project.directory, shell: true });
@@ -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
  }