create-jst 0.3.0 → 0.4.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/README.md +14 -4
- package/package.json +17 -4
- package/src/application/projectInitializer.mjs +34 -17
- package/src/application/projectMigrator.mjs +16 -0
- package/src/cli.mjs +38 -13
- package/src/composition/createProjectApplication.mjs +4 -6
- package/src/domain/migrationCatalog.mjs +34 -0
- package/src/domain/projectSpecification.mjs +29 -10
- package/src/infrastructure/degitTemplateGateway.mjs +5 -0
- package/src/infrastructure/nodeMigrationGateway.mjs +68 -0
- package/src/infrastructure/nodeProjectGateway.mjs +107 -32
- package/src/jst.mjs +40 -0
- package/src/ui/command.mjs +106 -37
- package/src/ui/reporter.mjs +50 -8
- package/src/application/eventBus.mjs +0 -12
package/README.md
CHANGED
|
@@ -6,14 +6,24 @@ Create a web application from the [JST](https://github.com/jst-stack/jst) templa
|
|
|
6
6
|
npm create jst@latest my-app
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
The wizard derives application metadata from the project directory, so it only asks about choices that change
|
|
9
|
+
The wizard derives application metadata from the project directory, so it only asks about choices that change generated output: package manager, CSS or SCSS Modules, dependency installation, and Git initialization. Creation happens in a temporary sibling directory; failures and signals clean it up before the final atomic rename.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm create jst@latest my-app -- --yes
|
|
13
|
-
npm create jst@latest my-app -- --package-manager pnpm
|
|
13
|
+
npm create jst@latest my-app -- --package-manager pnpm
|
|
14
14
|
npm create jst@latest my-app -- --dry-run
|
|
15
|
+
npm create jst@latest my-app -- --example showcase
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
Use `--help` to view
|
|
18
|
+
Use `--help` to view deterministic flags for automation. The initializer never overwrites a non-empty directory and released versions resolve immutable template/showcase revisions.
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
## Migrations
|
|
21
|
+
|
|
22
|
+
Run migrations from a clean Git working tree:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx --package=create-jst jst migrate --from 0.3.0 --to 0.4.0 --dry-run
|
|
26
|
+
npx --package=create-jst jst migrate --from 0.3.0 --to 0.4.0
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Migrations reject unsupported paths and customized source shapes before writing. Applied files are restored if the project quality gate fails.
|
package/package.json
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-jst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Create a web application from the JST template.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"packageManager": "npm@11.6.2",
|
|
6
7
|
"bin": {
|
|
7
|
-
"create-jst": "src/cli.mjs"
|
|
8
|
+
"create-jst": "src/cli.mjs",
|
|
9
|
+
"jst": "src/jst.mjs"
|
|
8
10
|
},
|
|
9
11
|
"files": [
|
|
10
12
|
"src"
|
|
11
13
|
],
|
|
12
14
|
"engines": {
|
|
13
|
-
"node": ">=24 <25"
|
|
15
|
+
"node": ">=24.15.0 <25"
|
|
14
16
|
},
|
|
15
17
|
"scripts": {
|
|
16
|
-
"
|
|
18
|
+
"lint": "eslint . --max-warnings 0",
|
|
19
|
+
"benchmark": "node scripts/run-benchmarks.mjs",
|
|
20
|
+
"benchmark:update": "node scripts/run-benchmarks.mjs --write-baseline",
|
|
21
|
+
"test": "node --test test/integration.test.mjs test/migration.test.mjs test/options.test.mjs test/projectInitializer.test.mjs",
|
|
22
|
+
"test:generated": "node --test test/generatedProject.test.mjs",
|
|
23
|
+
"check": "npm run lint && npm test && npm pack --dry-run",
|
|
24
|
+
"prepublishOnly": "npm run check"
|
|
17
25
|
},
|
|
18
26
|
"repository": {
|
|
19
27
|
"type": "git",
|
|
@@ -25,6 +33,11 @@
|
|
|
25
33
|
"homepage": "https://github.com/jst-stack/jst#readme",
|
|
26
34
|
"license": "MIT",
|
|
27
35
|
"dependencies": {
|
|
36
|
+
"@clack/prompts": "^1.8.1",
|
|
28
37
|
"degit": "^3.10.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"eslint": "^10.10.0"
|
|
29
42
|
}
|
|
30
43
|
}
|
|
@@ -1,28 +1,45 @@
|
|
|
1
|
-
export function createProjectInitializer({
|
|
1
|
+
export function createProjectInitializer({ onProgress = () => undefined, projectGateway, templateGateway }) {
|
|
2
2
|
return {
|
|
3
3
|
async execute(specification) {
|
|
4
4
|
await projectGateway.assertDestinationIsEmpty(specification.destination)
|
|
5
|
+
const temporaryDestination = await projectGateway.prepareDestination(specification.destination)
|
|
6
|
+
const temporarySpecification = Object.freeze({ ...specification, destination: temporaryDestination })
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
try {
|
|
9
|
+
onProgress({ type: 'template.download.started', specification })
|
|
10
|
+
await templateGateway.clone(specification.template, temporaryDestination)
|
|
11
|
+
if (specification.example === 'clean') {
|
|
12
|
+
await projectGateway.validateTemplate(temporarySpecification)
|
|
13
|
+
}
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
onProgress({ type: 'project.configuration.started', specification })
|
|
16
|
+
if (specification.example === 'showcase') {
|
|
17
|
+
await projectGateway.configureExample(temporarySpecification)
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
await projectGateway.configureTemplate(temporarySpecification)
|
|
21
|
+
}
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
if (specification.packageManager !== 'npm') {
|
|
24
|
+
onProgress({ type: 'package-manager.configuration.started', specification })
|
|
25
|
+
await projectGateway.configurePackageManager(temporaryDestination, specification.packageManager)
|
|
26
|
+
}
|
|
27
|
+
if (specification.initGit) {
|
|
28
|
+
onProgress({ type: 'git.initialization.started', specification })
|
|
29
|
+
await projectGateway.initializeGit(temporaryDestination)
|
|
30
|
+
}
|
|
31
|
+
if (specification.install) {
|
|
32
|
+
onProgress({ type: 'dependencies.installation.started', specification })
|
|
33
|
+
await projectGateway.installDependencies(temporaryDestination, specification.packageManager)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await projectGateway.commitDestination(temporaryDestination, specification.destination)
|
|
37
|
+
onProgress({ type: 'project.initialized', specification })
|
|
19
38
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
catch (error) {
|
|
40
|
+
await projectGateway.cleanupDestination(temporaryDestination)
|
|
41
|
+
throw error
|
|
23
42
|
}
|
|
24
|
-
|
|
25
|
-
events.publish({ type: 'project.initialized', specification })
|
|
26
43
|
},
|
|
27
44
|
}
|
|
28
45
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function createProjectMigrator({ gateway }) {
|
|
2
|
+
return {
|
|
3
|
+
async execute({ directory, dryRun, from, migrations, to }) {
|
|
4
|
+
await gateway.assertSafeGitState(directory)
|
|
5
|
+
const files = await gateway.readMigrationFiles(directory, migrations)
|
|
6
|
+
for (const migration of migrations) {
|
|
7
|
+
migration.apply(files)
|
|
8
|
+
}
|
|
9
|
+
if (dryRun) {
|
|
10
|
+
return { changedFiles: [...files.keys()].sort(), from, to }
|
|
11
|
+
}
|
|
12
|
+
await gateway.writeTransaction(directory, files, () => gateway.verify(directory))
|
|
13
|
+
return { changedFiles: [...files.keys()].sort(), from, to }
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -3,33 +3,58 @@
|
|
|
3
3
|
import process from 'node:process'
|
|
4
4
|
import { realpathSync } from 'node:fs'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import packageMetadata from '../package.json' with { type: 'json' }
|
|
6
7
|
import { createProjectApplication } from './composition/createProjectApplication.mjs'
|
|
7
8
|
import { parseCommandLine, printHelp, resolveProjectRequest } from './ui/command.mjs'
|
|
8
9
|
import { createReporter } from './ui/reporter.mjs'
|
|
9
10
|
|
|
10
|
-
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
11
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
12
|
+
await run(process.argv.slice(2))
|
|
13
|
+
}
|
|
11
14
|
|
|
12
|
-
export async function run(argv, environment = process,
|
|
15
|
+
export async function run(argv, environment = process, injectedApplication) {
|
|
13
16
|
const reporter = createReporter(environment.stdout, environment.env)
|
|
17
|
+
const signals = registerCancellation(environment)
|
|
14
18
|
try {
|
|
15
19
|
const command = parseCommandLine(argv)
|
|
16
|
-
if (command.values.help)
|
|
17
|
-
|
|
20
|
+
if (command.values.help) {
|
|
21
|
+
return printHelp(environment.stdout)
|
|
22
|
+
}
|
|
23
|
+
if (command.values.version) {
|
|
24
|
+
return environment.stdout.write(`${packageMetadata.version}\n`)
|
|
25
|
+
}
|
|
18
26
|
|
|
19
27
|
const request = await resolveProjectRequest(command, environment)
|
|
20
|
-
if (request.dryRun)
|
|
21
|
-
|
|
22
|
-
reporter.start()
|
|
23
|
-
const unsubscribe = reporter.subscribe(application.events)
|
|
24
|
-
try {
|
|
25
|
-
await application.initializer.execute(request.specification)
|
|
26
|
-
}
|
|
27
|
-
finally {
|
|
28
|
-
unsubscribe()
|
|
28
|
+
if (request.dryRun) {
|
|
29
|
+
return reporter.plan(request.specification, request)
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
reporter.start(request)
|
|
33
|
+
const application = injectedApplication ?? createProjectApplication({ onProgress: event => reporter.progress(event), signal: signals.signal })
|
|
34
|
+
await application.initializer.execute(request.specification)
|
|
30
35
|
}
|
|
31
36
|
catch (error) {
|
|
37
|
+
if (error instanceof Error && error.name === 'PromptCancelledError') {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
32
40
|
reporter.error(error instanceof Error ? error.message : String(error))
|
|
33
41
|
environment.exitCode = 1
|
|
34
42
|
}
|
|
43
|
+
finally {
|
|
44
|
+
signals.dispose()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function registerCancellation(environment) {
|
|
49
|
+
const controller = new globalThis.AbortController()
|
|
50
|
+
const abort = () => controller.abort(new Error('Setup cancelled by signal.'))
|
|
51
|
+
environment.once?.('SIGINT', abort)
|
|
52
|
+
environment.once?.('SIGTERM', abort)
|
|
53
|
+
return {
|
|
54
|
+
dispose() {
|
|
55
|
+
environment.off?.('SIGINT', abort)
|
|
56
|
+
environment.off?.('SIGTERM', abort)
|
|
57
|
+
},
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
}
|
|
35
60
|
}
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import process from 'node:process'
|
|
2
|
-
import { createEventBus } from '../application/eventBus.mjs'
|
|
3
2
|
import { createProjectInitializer } from '../application/projectInitializer.mjs'
|
|
4
3
|
import { createDegitTemplateGateway } from '../infrastructure/degitTemplateGateway.mjs'
|
|
5
4
|
import { createNodeProjectGateway } from '../infrastructure/nodeProjectGateway.mjs'
|
|
6
5
|
|
|
7
|
-
export function createProjectApplication({ nodeExecutable = process.execPath } = {}) {
|
|
8
|
-
const events = createEventBus()
|
|
6
|
+
export function createProjectApplication({ nodeExecutable = process.execPath, onProgress, signal } = {}) {
|
|
9
7
|
const initializer = createProjectInitializer({
|
|
10
|
-
|
|
11
|
-
projectGateway: createNodeProjectGateway({ nodeExecutable }),
|
|
8
|
+
onProgress,
|
|
9
|
+
projectGateway: createNodeProjectGateway({ nodeExecutable, signal }),
|
|
12
10
|
templateGateway: createDegitTemplateGateway(),
|
|
13
11
|
})
|
|
14
|
-
return {
|
|
12
|
+
return { initializer }
|
|
15
13
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const CURRENT_PROJECT_VERSION = '0.4.0'
|
|
2
|
+
|
|
3
|
+
export const migrations = Object.freeze([
|
|
4
|
+
Object.freeze({
|
|
5
|
+
from: '0.3.0',
|
|
6
|
+
to: CURRENT_PROJECT_VERSION,
|
|
7
|
+
files: Object.freeze(['eslint.config.js']),
|
|
8
|
+
apply(files) {
|
|
9
|
+
const eslintConfig = files.get('eslint.config.js')
|
|
10
|
+
if (!eslintConfig?.includes("jst.configs['strict']")) {
|
|
11
|
+
throw new Error("Migration requires eslint.config.js to use jst.configs['strict']; migrate custom ESLint composition manually.")
|
|
12
|
+
}
|
|
13
|
+
const withPolicy = eslintConfig.includes("from './jst.config.ts'")
|
|
14
|
+
? eslintConfig
|
|
15
|
+
: `import policy from './jst.config.ts'\n${eslintConfig}`
|
|
16
|
+
files.set('eslint.config.js', withPolicy.replace("jst.configs['strict']", 'jst.createConfig(policy)'))
|
|
17
|
+
files.set('jst.config.ts', "import { defineConfig } from '@jst-stack/eslint-plugin/policy'\n\nexport default defineConfig({})\n")
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
export function resolveMigrationPath(from, to = CURRENT_PROJECT_VERSION) {
|
|
23
|
+
const path = []
|
|
24
|
+
let version = from
|
|
25
|
+
while (version !== to) {
|
|
26
|
+
const migration = migrations.find(candidate => candidate.from === version)
|
|
27
|
+
if (!migration || path.includes(migration)) {
|
|
28
|
+
throw new Error(`No supported migration path from ${from} to ${to}.`)
|
|
29
|
+
}
|
|
30
|
+
path.push(migration)
|
|
31
|
+
version = migration.to
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze(path)
|
|
34
|
+
}
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
export const COLOR_SCHEMES = ['light', 'dark', 'auto']
|
|
2
2
|
export const PRIMARY_COLORS = ['dark', 'gray', 'red', 'pink', 'grape', 'violet', 'indigo', 'blue', 'cyan', 'teal', 'green', 'lime', 'yellow', 'orange']
|
|
3
|
-
export const PACKAGE_MANAGERS = ['npm', 'pnpm'
|
|
4
|
-
export const
|
|
3
|
+
export const PACKAGE_MANAGERS = ['npm', 'pnpm']
|
|
4
|
+
export const STYLE_LANGUAGES = ['css', 'scss']
|
|
5
|
+
export const EXAMPLES = ['clean', 'showcase']
|
|
6
|
+
export const TEMPLATE_SOURCE = 'jst-stack/jst#v0.4.0'
|
|
7
|
+
export const SHOWCASE_SOURCE = 'jst-stack/jst-showcase#d3625bda10ddb9c39599cc061e3f49cdb6be3922'
|
|
5
8
|
|
|
6
9
|
export function buildProjectSpecification(input) {
|
|
7
10
|
const specification = Object.freeze({
|
|
8
11
|
colorScheme: input.colorScheme ?? 'dark',
|
|
9
|
-
demo: input.demo ?? 'remove',
|
|
10
12
|
description: input.description ?? `${input.title} web application.`,
|
|
11
13
|
destination: input.destination,
|
|
14
|
+
example: input.example ?? 'clean',
|
|
12
15
|
initGit: input.initGit ?? true,
|
|
13
16
|
install: input.install ?? true,
|
|
14
17
|
language: input.language ?? 'en',
|
|
15
18
|
name: input.name,
|
|
16
19
|
packageManager: input.packageManager ?? 'npm',
|
|
17
20
|
primaryColor: input.primaryColor ?? 'lime',
|
|
18
|
-
|
|
21
|
+
style: input.style ?? 'css',
|
|
22
|
+
template: input.template ?? (input.example === 'showcase' ? SHOWCASE_SOURCE : TEMPLATE_SOURCE),
|
|
19
23
|
title: input.title,
|
|
20
24
|
})
|
|
21
25
|
validateProjectSpecification(specification)
|
|
@@ -26,12 +30,27 @@ export function validateProjectSpecification(specification) {
|
|
|
26
30
|
if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(specification.name) || specification.name.length > 214) {
|
|
27
31
|
throw new Error(`Invalid npm package name: ${specification.name}`)
|
|
28
32
|
}
|
|
29
|
-
if (!specification.destination)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
if (!specification.destination) {
|
|
34
|
+
throw new Error('Project destination is required.')
|
|
35
|
+
}
|
|
36
|
+
if (!specification.title?.trim()) {
|
|
37
|
+
throw new Error('Application title is required.')
|
|
38
|
+
}
|
|
39
|
+
if (!COLOR_SCHEMES.includes(specification.colorScheme)) {
|
|
40
|
+
throw new Error(`Colour scheme must be one of: ${COLOR_SCHEMES.join(', ')}.`)
|
|
41
|
+
}
|
|
42
|
+
if (!PRIMARY_COLORS.includes(specification.primaryColor)) {
|
|
43
|
+
throw new Error(`Primary colour must be one of: ${PRIMARY_COLORS.join(', ')}.`)
|
|
44
|
+
}
|
|
45
|
+
if (!EXAMPLES.includes(specification.example)) {
|
|
46
|
+
throw new Error(`Example must be one of: ${EXAMPLES.join(', ')}.`)
|
|
47
|
+
}
|
|
48
|
+
if (!PACKAGE_MANAGERS.includes(specification.packageManager)) {
|
|
49
|
+
throw new Error(`Package manager must be one of: ${PACKAGE_MANAGERS.join(', ')}.`)
|
|
50
|
+
}
|
|
51
|
+
if (!STYLE_LANGUAGES.includes(specification.style)) {
|
|
52
|
+
throw new Error(`Style language must be one of: ${STYLE_LANGUAGES.join(', ')}.`)
|
|
53
|
+
}
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
export function normalizePackageName(value) {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import degit from 'degit'
|
|
2
|
+
import { cp } from 'node:fs/promises'
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
2
4
|
|
|
3
5
|
export function createDegitTemplateGateway() {
|
|
4
6
|
return {
|
|
5
7
|
clone(source, destination) {
|
|
8
|
+
if (isAbsolute(source) || source.startsWith('.')) {
|
|
9
|
+
return cp(resolve(source), destination, { recursive: true })
|
|
10
|
+
}
|
|
6
11
|
return degit(source, { disableCache: true, force: false, verbose: false }).clone(destination)
|
|
7
12
|
},
|
|
8
13
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
|
|
6
|
+
const executeFile = promisify(execFile)
|
|
7
|
+
|
|
8
|
+
export function createNodeMigrationGateway() {
|
|
9
|
+
return {
|
|
10
|
+
async assertSafeGitState(directory) {
|
|
11
|
+
try {
|
|
12
|
+
const { stdout } = await executeFile('git', ['status', '--porcelain'], { cwd: directory })
|
|
13
|
+
if (stdout.trim()) {
|
|
14
|
+
throw new Error('Migration requires a clean Git working tree.')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.message === 'Migration requires a clean Git working tree.') {
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
throw new Error('Migration requires the project to be inside a Git repository.', { cause: error })
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
async readMigrationFiles(directory, migrations) {
|
|
25
|
+
const names = new Set(migrations.flatMap(migration => migration.files))
|
|
26
|
+
const files = new Map()
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
files.set(name, await readFile(join(directory, name), 'utf8'))
|
|
29
|
+
}
|
|
30
|
+
return files
|
|
31
|
+
},
|
|
32
|
+
async verify(directory) {
|
|
33
|
+
const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'))
|
|
34
|
+
const packageManager = manifest.packageManager?.startsWith('pnpm@') ? 'pnpm' : 'npm'
|
|
35
|
+
return executeFile(packageManager, ['run', 'check'], { cwd: directory })
|
|
36
|
+
},
|
|
37
|
+
async writeTransaction(directory, files, verify) {
|
|
38
|
+
const originals = new Map()
|
|
39
|
+
for (const name of files.keys()) {
|
|
40
|
+
try {
|
|
41
|
+
originals.set(name, await readFile(join(directory, name), 'utf8'))
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code !== 'ENOENT') {
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
for (const [name, content] of files) {
|
|
51
|
+
await writeFile(join(directory, name), content)
|
|
52
|
+
}
|
|
53
|
+
await verify()
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
for (const name of files.keys()) {
|
|
57
|
+
if (originals.has(name)) {
|
|
58
|
+
await writeFile(join(directory, name), originals.get(name))
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
await rm(join(directory, name), { force: true })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -1,31 +1,50 @@
|
|
|
1
1
|
import { spawn, execFile } from 'node:child_process'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
|
-
import { lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
4
|
-
import { join } from 'node:path'
|
|
3
|
+
import { lstat, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { basename, dirname, join } from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
5
6
|
import { promisify } from 'node:util'
|
|
6
7
|
|
|
7
8
|
const executeFile = promisify(execFile)
|
|
8
9
|
const installCommands = {
|
|
9
|
-
bun: ['install'],
|
|
10
10
|
npm: ['install', '--no-audit', '--no-fund'],
|
|
11
11
|
pnpm: ['install'],
|
|
12
|
-
yarn: ['install'],
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
export function createNodeProjectGateway({ nodeExecutable }) {
|
|
14
|
+
export function createNodeProjectGateway({ nodeExecutable, signal }) {
|
|
16
15
|
return {
|
|
17
16
|
assertDestinationIsEmpty,
|
|
18
|
-
|
|
17
|
+
cleanupDestination,
|
|
18
|
+
commitDestination,
|
|
19
|
+
configureExample,
|
|
20
|
+
configurePackageManager: (destination, packageManager) => configurePackageManager(destination, packageManager, signal),
|
|
19
21
|
configureTemplate,
|
|
20
22
|
initializeGit,
|
|
21
23
|
installDependencies,
|
|
24
|
+
prepareDestination,
|
|
25
|
+
validateTemplate,
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
async function assertDestinationIsEmpty(destination) {
|
|
25
|
-
if (!existsSync(destination))
|
|
29
|
+
if (!existsSync(destination)) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
26
32
|
const stat = await lstat(destination)
|
|
27
|
-
if (!stat.isDirectory())
|
|
28
|
-
|
|
33
|
+
if (!stat.isDirectory()) {
|
|
34
|
+
throw new Error(`Destination is not a directory: ${destination}`)
|
|
35
|
+
}
|
|
36
|
+
if ((await readdir(destination)).length > 0) {
|
|
37
|
+
throw new Error(`Destination is not empty: ${destination}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cleanupDestination(destination) {
|
|
42
|
+
return rm(destination, { force: true, recursive: true })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function commitDestination(temporaryDestination, destination) {
|
|
46
|
+
await rm(destination, { force: true, recursive: true })
|
|
47
|
+
await rename(temporaryDestination, destination)
|
|
29
48
|
}
|
|
30
49
|
|
|
31
50
|
function configureTemplate(specification) {
|
|
@@ -38,45 +57,101 @@ export function createNodeProjectGateway({ nodeExecutable }) {
|
|
|
38
57
|
'--lang', specification.language,
|
|
39
58
|
'--color-scheme', specification.colorScheme,
|
|
40
59
|
'--primary-color', specification.primaryColor,
|
|
41
|
-
'--
|
|
42
|
-
], specification.destination)
|
|
60
|
+
'--style', specification.style,
|
|
61
|
+
], specification.destination, signal)
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
async function
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
({ stdout: version } = await executeFile(packageManager, ['--version']))
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
throw new Error(`Could not run ${packageManager}. Install it or choose another package manager.`)
|
|
52
|
-
}
|
|
53
|
-
const manifestPath = join(destination, 'package.json')
|
|
64
|
+
async function configureExample(specification) {
|
|
65
|
+
const manifestPath = join(specification.destination, 'package.json')
|
|
54
66
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
55
|
-
manifest.
|
|
67
|
+
manifest.name = specification.name
|
|
56
68
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, '\t')}\n`)
|
|
57
|
-
await rm(join(destination, 'package-lock.json'), { force: true })
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
function initializeGit(destination) {
|
|
61
|
-
return run('git', ['init', '--initial-branch=main'], destination)
|
|
72
|
+
return run('git', ['init', '--initial-branch=main'], destination, signal)
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
function installDependencies(destination, packageManager) {
|
|
65
|
-
return run(packageManager, installCommands[packageManager], destination,
|
|
76
|
+
return run(packageManager, installCommands[packageManager], destination, signal)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function validateTemplate(specification) {
|
|
80
|
+
const contract = JSON.parse(await readFile(join(specification.destination, 'jst.template.json'), 'utf8'))
|
|
81
|
+
if (contract.schemaVersion !== 1) {
|
|
82
|
+
throw new Error(`Unsupported template contract schema: ${contract.schemaVersion}.`)
|
|
83
|
+
}
|
|
84
|
+
const selected = {
|
|
85
|
+
colorSchemes: specification.colorScheme,
|
|
86
|
+
primaryColors: specification.primaryColor,
|
|
87
|
+
styles: specification.style,
|
|
88
|
+
}
|
|
89
|
+
for (const [option, value] of Object.entries(selected)) {
|
|
90
|
+
if (!contract.options?.[option]?.includes(value)) {
|
|
91
|
+
throw new Error(`Template ${contract.templateVersion} does not support ${option}: ${value}.`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
66
94
|
}
|
|
67
95
|
}
|
|
68
96
|
|
|
69
|
-
function
|
|
97
|
+
async function configurePackageManager(destination, packageManager, signal) {
|
|
98
|
+
const manifestPath = join(destination, 'package.json')
|
|
99
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
100
|
+
delete manifest.packageManager
|
|
101
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, '\t')}\n`)
|
|
102
|
+
await rm(join(destination, 'package-lock.json'), { force: true })
|
|
103
|
+
|
|
104
|
+
let version
|
|
105
|
+
try {
|
|
106
|
+
({ stdout: version } = await executeFile(packageManager, ['--version'], { cwd: destination, signal }))
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error(`Could not run ${packageManager}. Install it or choose another package manager.`)
|
|
110
|
+
}
|
|
111
|
+
const packageManagerEntry = ['packageManager', `${packageManager}@${version.trim()}`]
|
|
112
|
+
const entries = Object.entries(manifest).flatMap(entry => entry[0] === 'engines' ? [packageManagerEntry, entry] : [entry])
|
|
113
|
+
if (!manifest.engines) {
|
|
114
|
+
entries.push(packageManagerEntry)
|
|
115
|
+
}
|
|
116
|
+
await writeFile(manifestPath, `${JSON.stringify(Object.fromEntries(entries), null, '\t')}\n`)
|
|
117
|
+
if (packageManager === 'pnpm') {
|
|
118
|
+
await writeFile(
|
|
119
|
+
join(destination, 'pnpm-workspace.yaml'),
|
|
120
|
+
'minimumReleaseAge: 10080\nminimumReleaseAgeExcludePrune: true\ntrustPolicy: no-downgrade\ntrustPolicyIgnoreAfter: 10080\nshellEmulator: true\n',
|
|
121
|
+
)
|
|
122
|
+
await replaceReadmeCommands(destination, 'pnpm')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function replaceReadmeCommands(destination, packageManager) {
|
|
127
|
+
const path = join(destination, 'README.md')
|
|
128
|
+
try {
|
|
129
|
+
const readme = await readFile(path, 'utf8')
|
|
130
|
+
await writeFile(path, readme.replaceAll('npm run ', `${packageManager} run `).replaceAll('npm ci', `${packageManager} install --frozen-lockfile`))
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error?.code !== 'ENOENT') {
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function prepareDestination(destination) {
|
|
140
|
+
const temporaryDestination = join(dirname(destination), `.${basename(destination)}-${process.pid}-${Date.now()}.tmp`)
|
|
141
|
+
await rm(temporaryDestination, { force: true, recursive: true })
|
|
142
|
+
return temporaryDestination
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function run(command, args, cwd, signal) {
|
|
70
146
|
return new Promise((resolve, reject) => {
|
|
71
|
-
const child = spawn(command, args, { cwd, stdio:
|
|
72
|
-
let stderr = ''
|
|
73
|
-
child.stderr?.on('data', chunk => {
|
|
74
|
-
stderr += chunk
|
|
75
|
-
})
|
|
147
|
+
const child = spawn(command, args, { cwd, signal, stdio: 'inherit' })
|
|
76
148
|
child.once('error', error => reject(new Error(`Could not run ${command}: ${error.message}`)))
|
|
77
149
|
child.once('close', code => {
|
|
78
|
-
if (code === 0)
|
|
79
|
-
|
|
150
|
+
if (code === 0) {
|
|
151
|
+
resolve()
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}.`))
|
|
80
155
|
})
|
|
81
156
|
})
|
|
82
157
|
}
|
package/src/jst.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { parseArgs } from 'node:util'
|
|
6
|
+
import { createProjectMigrator } from './application/projectMigrator.mjs'
|
|
7
|
+
import { resolveMigrationPath } from './domain/migrationCatalog.mjs'
|
|
8
|
+
import { createNodeMigrationGateway } from './infrastructure/nodeMigrationGateway.mjs'
|
|
9
|
+
|
|
10
|
+
export async function run(argv, environment = process, migrator = createProjectMigrator({ gateway: createNodeMigrationGateway() })) {
|
|
11
|
+
try {
|
|
12
|
+
const command = parseCommand(argv)
|
|
13
|
+
if (command.help) {
|
|
14
|
+
environment.stdout.write('Usage: jst migrate --from <version> [--to <version>] [--dry-run]\n')
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
const migrations = resolveMigrationPath(command.from, command.to)
|
|
18
|
+
const result = await migrator.execute({ directory: environment.cwd(), dryRun: command.dryRun, from: command.from, migrations, to: command.to ?? migrations.at(-1)?.to ?? command.from })
|
|
19
|
+
environment.stdout.write(`${command.dryRun ? 'Would migrate' : 'Migrated'} ${result.from} → ${result.to}: ${result.changedFiles.join(', ')}\n`)
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
environment.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
23
|
+
environment.exitCode = 1
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCommand(argv) {
|
|
28
|
+
const { positionals, values } = parseArgs({ args: argv, allowPositionals: true, options: {
|
|
29
|
+
'dry-run': { type: 'boolean' }, from: { type: 'string' }, to: { type: 'string' }, help: { short: 'h', type: 'boolean' },
|
|
30
|
+
}, strict: true })
|
|
31
|
+
const help = Boolean(values.help || positionals[0] !== 'migrate')
|
|
32
|
+
if (!help && !values.from) {
|
|
33
|
+
throw new Error('--from is required.')
|
|
34
|
+
}
|
|
35
|
+
return { dryRun: values['dry-run'] ?? false, from: values.from, help, to: values.to }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
39
|
+
await run(process.argv.slice(2))
|
|
40
|
+
}
|
package/src/ui/command.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename, resolve } from 'node:path'
|
|
2
2
|
import { parseArgs } from 'node:util'
|
|
3
|
-
import
|
|
3
|
+
import * as prompts from '@clack/prompts'
|
|
4
|
+
import { buildProjectSpecification, EXAMPLES, normalizePackageName, PACKAGE_MANAGERS, STYLE_LANGUAGES, toTitle } from '../domain/projectSpecification.mjs'
|
|
4
5
|
|
|
5
6
|
export function parseCommandLine(args) {
|
|
6
7
|
return parseArgs({
|
|
@@ -8,9 +9,9 @@ export function parseCommandLine(args) {
|
|
|
8
9
|
args,
|
|
9
10
|
options: {
|
|
10
11
|
'color-scheme': { type: 'string' },
|
|
11
|
-
'demo': { type: 'string' },
|
|
12
12
|
'description': { type: 'string' },
|
|
13
13
|
'dry-run': { type: 'boolean' },
|
|
14
|
+
'example': { type: 'string' },
|
|
14
15
|
'git': { type: 'boolean' },
|
|
15
16
|
'help': { short: 'h', type: 'boolean' },
|
|
16
17
|
'lang': { type: 'string' },
|
|
@@ -21,6 +22,7 @@ export function parseCommandLine(args) {
|
|
|
21
22
|
'package-manager': { type: 'string' },
|
|
22
23
|
'primary-color': { type: 'string' },
|
|
23
24
|
'skip-install': { type: 'boolean' },
|
|
25
|
+
'style': { type: 'string' },
|
|
24
26
|
'template': { type: 'string' },
|
|
25
27
|
'title': { type: 'string' },
|
|
26
28
|
'version': { short: 'v', type: 'boolean' },
|
|
@@ -32,38 +34,36 @@ export function parseCommandLine(args) {
|
|
|
32
34
|
|
|
33
35
|
export async function resolveProjectRequest(command, environment) {
|
|
34
36
|
const { positionals, values } = command
|
|
35
|
-
|
|
36
|
-
if (values.git && values['no-git']) throw new Error('Use either --git or --no-git, not both.')
|
|
37
|
-
if (values['no-install'] && values['skip-install']) throw new Error('Use either --no-install or --skip-install, not both.')
|
|
37
|
+
validateCommand(positionals, values)
|
|
38
38
|
|
|
39
39
|
const prompt = await createPrompt(environment, values)
|
|
40
40
|
try {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
?? await prompt?.choice('
|
|
47
|
-
??
|
|
48
|
-
const packageManager = requestedPackageManager === 'auto' ? detectedPackageManager : requestedPackageManager
|
|
49
|
-
const initGit = values.git ?? !values['no-git']
|
|
50
|
-
const install = !(values['no-install'] || values['skip-install'])
|
|
41
|
+
const identity = await resolveProjectIdentity(positionals, values, environment, prompt)
|
|
42
|
+
const packageManager = await resolvePackageManager(values, environment, prompt)
|
|
43
|
+
const decisions = await resolveProjectDecisions(values, prompt)
|
|
44
|
+
const example = values.example ?? 'clean'
|
|
45
|
+
const style = values.style
|
|
46
|
+
?? await prompt?.choice('CSS Modules language', STYLE_LANGUAGES, 'css')
|
|
47
|
+
?? 'css'
|
|
51
48
|
|
|
52
49
|
return {
|
|
53
50
|
dryRun: values['dry-run'] ?? false,
|
|
51
|
+
interactive: Boolean(prompt),
|
|
52
|
+
terminal: Boolean(environment.stdin.isTTY),
|
|
54
53
|
specification: buildProjectSpecification({
|
|
55
54
|
colorScheme: values['color-scheme'],
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
initGit:
|
|
60
|
-
install:
|
|
55
|
+
description: values.description ?? `${identity.title} web application.`,
|
|
56
|
+
destination: resolve(environment.cwd(), identity.directory),
|
|
57
|
+
example,
|
|
58
|
+
initGit: decisions.initGit,
|
|
59
|
+
install: decisions.install,
|
|
61
60
|
language: values.lang,
|
|
62
|
-
name,
|
|
61
|
+
name: identity.name,
|
|
63
62
|
packageManager,
|
|
64
63
|
primaryColor: values['primary-color'],
|
|
64
|
+
style,
|
|
65
65
|
template: values.template,
|
|
66
|
-
title,
|
|
66
|
+
title: identity.title,
|
|
67
67
|
}),
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -74,9 +74,9 @@ export async function resolveProjectRequest(command, environment) {
|
|
|
74
74
|
|
|
75
75
|
export function detectPackageManager(environment = {}) {
|
|
76
76
|
const userAgent = environment.npm_config_user_agent ?? ''
|
|
77
|
-
if (userAgent.startsWith('pnpm/'))
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
if (userAgent.startsWith('pnpm/')) {
|
|
78
|
+
return 'pnpm'
|
|
79
|
+
}
|
|
80
80
|
return 'npm'
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -90,11 +90,11 @@ The interactive wizard only asks about decisions that affect the generated proje
|
|
|
90
90
|
Application title and description are derived from the directory; pass flags only when automation needs overrides.
|
|
91
91
|
|
|
92
92
|
Options:
|
|
93
|
-
--
|
|
94
|
-
--package-manager <manager> auto, npm, pnpm, yarn, or bun
|
|
93
|
+
--package-manager <manager> auto, npm, or pnpm
|
|
95
94
|
--no-install create the project without installing dependencies
|
|
96
95
|
--git, --no-git initialize a Git repository (default: enabled)
|
|
97
96
|
--dry-run show the resolved plan without writing files
|
|
97
|
+
--example <name> clean or showcase (default: clean)
|
|
98
98
|
--no-interactive, -y, --yes accept defaults without prompts
|
|
99
99
|
--title <title> override the derived application title
|
|
100
100
|
--description <description> override the derived SEO description
|
|
@@ -102,6 +102,7 @@ Options:
|
|
|
102
102
|
--lang <language> document language (default: en)
|
|
103
103
|
--color-scheme <scheme> light, dark, or auto
|
|
104
104
|
--primary-color <colour> Mantine primary colour
|
|
105
|
+
--style <language> css or scss (default: css)
|
|
105
106
|
--template <owner/repo[#ref]> use a compatible template source
|
|
106
107
|
-v, --version show the CLI version
|
|
107
108
|
-h, --help show this help
|
|
@@ -109,22 +110,90 @@ Options:
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
async function createPrompt(environment, values) {
|
|
112
|
-
if (!environment.stdin.isTTY || values.yes || values['no-interactive'])
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
if (!environment.stdin.isTTY || values.yes || values['no-interactive']) {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
prompts.intro('create-jst')
|
|
115
117
|
return {
|
|
116
118
|
async choice(label, choices, fallback) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
return resolvePrompt(await prompts.select({
|
|
120
|
+
initialValue: fallback,
|
|
121
|
+
message: label,
|
|
122
|
+
options: choices.map(value => ({ hint: choiceHint(value), label: value, value })),
|
|
123
|
+
}))
|
|
122
124
|
},
|
|
123
125
|
close() {
|
|
124
|
-
|
|
126
|
+
return undefined
|
|
127
|
+
},
|
|
128
|
+
async confirm(label, initialValue) {
|
|
129
|
+
return resolvePrompt(await prompts.confirm({ initialValue, message: label }))
|
|
125
130
|
},
|
|
126
131
|
async text(label, fallback) {
|
|
127
|
-
return (await
|
|
132
|
+
return resolvePrompt(await prompts.text({ initialValue: fallback, message: label }))
|
|
128
133
|
},
|
|
129
134
|
}
|
|
130
135
|
}
|
|
136
|
+
|
|
137
|
+
function choiceHint(value) {
|
|
138
|
+
return {
|
|
139
|
+
npm: 'default',
|
|
140
|
+
}[value]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolvePrompt(value) {
|
|
144
|
+
if (!prompts.isCancel(value)) {
|
|
145
|
+
return value
|
|
146
|
+
}
|
|
147
|
+
prompts.cancel('Setup cancelled.')
|
|
148
|
+
const error = new Error('Setup cancelled.')
|
|
149
|
+
error.name = 'PromptCancelledError'
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function validateCommand(positionals, values) {
|
|
154
|
+
if (positionals.length > 1) {
|
|
155
|
+
throw new Error('Only one project directory can be provided.')
|
|
156
|
+
}
|
|
157
|
+
if (values.git && values['no-git']) {
|
|
158
|
+
throw new Error('Use either --git or --no-git, not both.')
|
|
159
|
+
}
|
|
160
|
+
if (values['no-install'] && values['skip-install']) {
|
|
161
|
+
throw new Error('Use either --no-install or --skip-install, not both.')
|
|
162
|
+
}
|
|
163
|
+
if (values.example && !EXAMPLES.includes(values.example)) {
|
|
164
|
+
throw new Error(`Example must be one of: ${EXAMPLES.join(', ')}.`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function resolveProjectIdentity(positionals, values, environment, prompt) {
|
|
169
|
+
const directory = positionals[0]
|
|
170
|
+
?? await prompt?.text('Project directory', 'my-jst-app')
|
|
171
|
+
?? 'my-jst-app'
|
|
172
|
+
const name = values.name
|
|
173
|
+
?? normalizePackageName(basename(resolve(environment.cwd(), directory)))
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
directory,
|
|
177
|
+
name,
|
|
178
|
+
title: values.title ?? toTitle(name),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function resolvePackageManager(values, environment, prompt) {
|
|
183
|
+
const detected = detectPackageManager(environment.env)
|
|
184
|
+
const requested = values['package-manager']
|
|
185
|
+
?? await prompt?.choice('Package manager', PACKAGE_MANAGERS, detected)
|
|
186
|
+
?? detected
|
|
187
|
+
|
|
188
|
+
return requested === 'auto' ? detected : requested
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function resolveProjectDecisions(values, prompt) {
|
|
192
|
+
const initGit = values.git ?? !values['no-git']
|
|
193
|
+
const install = !(values['no-install'] || values['skip-install'])
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
initGit: prompt ? await prompt.confirm('Initialize a Git repository?', initGit) : initGit,
|
|
197
|
+
install: prompt ? await prompt.confirm('Install dependencies?', install) : install,
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/ui/reporter.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
|
+
import * as prompts from '@clack/prompts'
|
|
2
3
|
|
|
3
4
|
const progressMessages = {
|
|
4
5
|
'dependencies.installation.started': specification => `Installing dependencies with ${specification.packageManager}`,
|
|
@@ -12,31 +13,72 @@ export function createReporter(output, environment = {}) {
|
|
|
12
13
|
const color = Boolean(output.isTTY) && !environment.NO_COLOR
|
|
13
14
|
const paint = (code, value) => color ? `\u001B[${code}m${value}\u001B[0m` : value
|
|
14
15
|
const dim = value => paint('2', value)
|
|
16
|
+
let activeSpinner
|
|
17
|
+
let usesClack = false
|
|
15
18
|
|
|
16
19
|
return {
|
|
17
20
|
error(message) {
|
|
21
|
+
if (usesClack) {
|
|
22
|
+
activeSpinner?.stop('Failed')
|
|
23
|
+
prompts.cancel(message)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
18
26
|
output.write(`\n${paint('31', '✕')} ${message}\n`)
|
|
19
27
|
},
|
|
20
|
-
plan(specification) {
|
|
28
|
+
plan(specification, request) {
|
|
29
|
+
usesClack = request.terminal
|
|
30
|
+
if (usesClack) {
|
|
31
|
+
if (!request.interactive) {
|
|
32
|
+
prompts.intro('create-jst')
|
|
33
|
+
}
|
|
34
|
+
prompts.note([
|
|
35
|
+
`Template: ${specification.template}`,
|
|
36
|
+
`Package manager: ${specification.packageManager}`,
|
|
37
|
+
`Install dependencies: ${specification.install ? 'yes' : 'no'}`,
|
|
38
|
+
`Initialize Git: ${specification.initGit ? 'yes' : 'no'}`,
|
|
39
|
+
].join('\n'), specification.destination)
|
|
40
|
+
prompts.outro('Dry run complete — no files were written.')
|
|
41
|
+
return
|
|
42
|
+
}
|
|
21
43
|
output.write(`\n${paint('38;5;203', 'JST')} ${dim('plan only — no files will be written')}\n\n`)
|
|
22
44
|
output.write(` destination ${specification.destination}\n`)
|
|
23
45
|
output.write(` template ${specification.template}\n`)
|
|
24
46
|
output.write(` package manager ${specification.packageManager}\n`)
|
|
25
|
-
output.write(` starter demo ${specification.demo}\n`)
|
|
26
47
|
output.write(` install ${specification.install ? 'yes' : 'no'}\n`)
|
|
27
48
|
output.write(` git ${specification.initGit ? 'yes' : 'no'}\n\n`)
|
|
28
49
|
},
|
|
29
|
-
start() {
|
|
50
|
+
start(request) {
|
|
51
|
+
usesClack = request.terminal
|
|
52
|
+
if (usesClack) {
|
|
53
|
+
if (!request.interactive) {
|
|
54
|
+
prompts.intro('create-jst')
|
|
55
|
+
}
|
|
56
|
+
return
|
|
57
|
+
}
|
|
30
58
|
output.write(`\n${paint('38;5;203', 'JST')} ${dim('A foundation for replaceable layers')}\n\n`)
|
|
31
59
|
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
progress(event) {
|
|
61
|
+
if (event.type === 'project.initialized') {
|
|
62
|
+
return this.success(event.specification)
|
|
63
|
+
}
|
|
35
64
|
const message = progressMessages[event.type]
|
|
36
|
-
if (message)
|
|
37
|
-
|
|
65
|
+
if (!message) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (usesClack) {
|
|
69
|
+
activeSpinner?.stop()
|
|
70
|
+
activeSpinner = prompts.spinner()
|
|
71
|
+
activeSpinner.start(message(event.specification))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
output.write(`${dim(' ◌')} ${message(event.specification)}\n`)
|
|
38
75
|
},
|
|
39
76
|
success(specification) {
|
|
77
|
+
if (usesClack) {
|
|
78
|
+
activeSpinner?.stop('Project created')
|
|
79
|
+
prompts.outro(`Ready. Run \`${specification.packageManager} run dev\` in ${basename(specification.destination)}.`)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
40
82
|
output.write(`\n${paint('38;5;114', 'Ready.')} ${specification.title} is in ${paint('38;5;203', basename(specification.destination))}.\n\n`)
|
|
41
83
|
output.write(` ${dim('$')} cd ${basename(specification.destination)}\n`)
|
|
42
84
|
output.write(` ${dim('$')} ${specification.packageManager} run dev\n\n`)
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
export function createEventBus() {
|
|
2
|
-
const subscribers = new Set()
|
|
3
|
-
return {
|
|
4
|
-
publish(event) {
|
|
5
|
-
for (const subscriber of subscribers) subscriber(event)
|
|
6
|
-
},
|
|
7
|
-
subscribe(subscriber) {
|
|
8
|
-
subscribers.add(subscriber)
|
|
9
|
-
return () => subscribers.delete(subscriber)
|
|
10
|
-
},
|
|
11
|
-
}
|
|
12
|
-
}
|