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.
- package/.release-plan.json +6 -6
- package/CHANGELOG.md +22 -0
- package/README.md +9 -0
- package/package.json +2 -1
- package/src/bases/index.js +32 -0
- package/src/bases/minimal-app/index.js +14 -1
- package/src/bases/minimal-custom-element/index.js +14 -0
- package/src/bases/minimal-extension/index.js +15 -1
- package/src/bases/minimal-library/index.js +20 -1
- package/src/cli/generator.js +2 -18
- package/src/cli/index.js +11 -7
- package/src/cli/questions/name.js +26 -4
- package/src/cli/questions/project-type.js +4 -2
- package/src/cli/questions/replace-or-update.js +51 -1
- package/src/utils/fs.js +18 -1
- package/src/utils/types.ts +12 -0
- package/test/ember.nvp/cli.test.ts +59 -1
- package/test/ember.nvp/detect-project-type.test.ts +86 -0
- package/test/ember.nvp/layer-qunit.test.ts +19 -11
- package/test/test-helpers.ts +2 -2
package/.release-plan.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"solution": {
|
|
3
3
|
"ember.nvp": {
|
|
4
|
-
"impact": "
|
|
5
|
-
"oldVersion": "1.
|
|
6
|
-
"newVersion": "1.
|
|
4
|
+
"impact": "minor",
|
|
5
|
+
"oldVersion": "1.12.0",
|
|
6
|
+
"newVersion": "1.13.0",
|
|
7
7
|
"tagName": "latest",
|
|
8
8
|
"constraints": [
|
|
9
9
|
{
|
|
10
|
-
"impact": "
|
|
11
|
-
"reason": "Appears in changelog section :
|
|
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.
|
|
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.
|
|
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
|
package/src/cli/generator.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { mkdir, rm } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
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 ${
|
|
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
|
-
|
|
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:
|
|
19
|
-
defaultValue:
|
|
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 ??
|
|
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
|
*
|
package/src/utils/types.ts
CHANGED
|
@@ -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(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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(
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 });
|
package/test/test-helpers.ts
CHANGED
|
@@ -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
|
}
|