create-jst 0.3.1 → 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 CHANGED
@@ -6,12 +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 the generated project: starter demo, package manager, dependency installation, and Git initialization.
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 --demo keep
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 non-interactive options. The initializer never overwrites a non-empty directory.
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.
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.1",
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
- "test": "node --test"
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",
@@ -27,5 +35,9 @@
27
35
  "dependencies": {
28
36
  "@clack/prompts": "^1.8.1",
29
37
  "degit": "^3.10.0"
38
+ },
39
+ "devDependencies": {
40
+ "@eslint/js": "^10.0.1",
41
+ "eslint": "^10.10.0"
30
42
  }
31
43
  }
@@ -1,28 +1,45 @@
1
- export function createProjectInitializer({ events, projectGateway, templateGateway }) {
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
- events.publish({ type: 'template.download.started', specification })
7
- await templateGateway.clone(specification.template, specification.destination)
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
- events.publish({ type: 'project.configuration.started', specification })
10
- await projectGateway.configureTemplate(specification)
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
- if (specification.packageManager !== 'npm') {
13
- events.publish({ type: 'package-manager.configuration.started', specification })
14
- await projectGateway.configurePackageManager(specification.destination, specification.packageManager)
15
- }
16
- if (specification.initGit) {
17
- events.publish({ type: 'git.initialization.started', specification })
18
- await projectGateway.initializeGit(specification.destination)
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
- if (specification.install) {
21
- events.publish({ type: 'dependencies.installation.started', specification })
22
- await projectGateway.installDependencies(specification.destination, specification.packageManager)
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,34 +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)) await run(process.argv.slice(2))
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, application = createProjectApplication()) {
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) return printHelp(environment.stdout)
17
- if (command.values.version) return environment.stdout.write(`${process.env.npm_package_version ?? '0.3.0'}\n`)
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) return reporter.plan(request.specification, request)
28
+ if (request.dryRun) {
29
+ return reporter.plan(request.specification, request)
30
+ }
21
31
 
22
32
  reporter.start(request)
23
- const unsubscribe = reporter.subscribe(application.events)
24
- try {
25
- await application.initializer.execute(request.specification)
26
- }
27
- finally {
28
- unsubscribe()
29
- }
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) {
32
- if (error instanceof Error && error.name === 'PromptCancelledError') return
37
+ if (error instanceof Error && error.name === 'PromptCancelledError') {
38
+ return
39
+ }
33
40
  reporter.error(error instanceof Error ? error.message : String(error))
34
41
  environment.exitCode = 1
35
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
+ }
36
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
- events,
11
- projectGateway: createNodeProjectGateway({ nodeExecutable }),
8
+ onProgress,
9
+ projectGateway: createNodeProjectGateway({ nodeExecutable, signal }),
12
10
  templateGateway: createDegitTemplateGateway(),
13
11
  })
14
- return { events, initializer }
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', 'yarn', 'bun']
4
- export const TEMPLATE_SOURCE = 'jst-stack/jst'
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
- template: input.template ?? TEMPLATE_SOURCE,
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) throw new Error('Project destination is required.')
30
- if (!specification.title?.trim()) throw new Error('Application title is required.')
31
- if (!COLOR_SCHEMES.includes(specification.colorScheme)) throw new Error(`Colour scheme must be one of: ${COLOR_SCHEMES.join(', ')}.`)
32
- if (!PRIMARY_COLORS.includes(specification.primaryColor)) throw new Error(`Primary colour must be one of: ${PRIMARY_COLORS.join(', ')}.`)
33
- if (!['keep', 'remove'].includes(specification.demo)) throw new Error('Demo mode must be either keep or remove.')
34
- if (!PACKAGE_MANAGERS.includes(specification.packageManager)) throw new Error(`Package manager must be one of: ${PACKAGE_MANAGERS.join(', ')}.`)
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
- configurePackageManager,
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)) return
29
+ if (!existsSync(destination)) {
30
+ return
31
+ }
26
32
  const stat = await lstat(destination)
27
- if (!stat.isDirectory()) throw new Error(`Destination is not a directory: ${destination}`)
28
- if ((await readdir(destination)).length > 0) throw new Error(`Destination is not empty: ${destination}`)
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,49 +57,101 @@ export function createNodeProjectGateway({ nodeExecutable }) {
38
57
  '--lang', specification.language,
39
58
  '--color-scheme', specification.colorScheme,
40
59
  '--primary-color', specification.primaryColor,
41
- '--demo', specification.demo,
42
- ], specification.destination)
60
+ '--style', specification.style,
61
+ ], specification.destination, signal)
43
62
  }
44
63
 
45
- async function configurePackageManager(destination, packageManager) {
46
- let version
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.packageManager = `${packageManager}@${version.trim()}`
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
+ }
94
+ }
95
+ }
96
+
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')
66
123
  }
67
124
  }
68
125
 
69
- function run(command, args, cwd) {
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: 'pipe' })
72
- let stdout = ''
73
- let stderr = ''
74
- child.stdout?.on('data', chunk => {
75
- stdout += chunk
76
- })
77
- child.stderr?.on('data', chunk => {
78
- stderr += chunk
79
- })
147
+ const child = spawn(command, args, { cwd, signal, stdio: 'inherit' })
80
148
  child.once('error', error => reject(new Error(`Could not run ${command}: ${error.message}`)))
81
149
  child.once('close', code => {
82
- if (code === 0) resolve()
83
- else reject(new Error(`${command} ${args.join(' ')} failed.${[stdout, stderr].filter(Boolean).join('\n').trim() ? `\n${[stdout, stderr].filter(Boolean).join('\n').trim()}` : ''}`))
150
+ if (code === 0) {
151
+ resolve()
152
+ return
153
+ }
154
+ reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}.`))
84
155
  })
85
156
  })
86
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
+ }
@@ -1,7 +1,7 @@
1
1
  import { basename, resolve } from 'node:path'
2
2
  import { parseArgs } from 'node:util'
3
3
  import * as prompts from '@clack/prompts'
4
- import { buildProjectSpecification, normalizePackageName, PACKAGE_MANAGERS, toTitle } from '../domain/projectSpecification.mjs'
4
+ import { buildProjectSpecification, EXAMPLES, normalizePackageName, PACKAGE_MANAGERS, STYLE_LANGUAGES, toTitle } from '../domain/projectSpecification.mjs'
5
5
 
6
6
  export function parseCommandLine(args) {
7
7
  return parseArgs({
@@ -9,9 +9,9 @@ export function parseCommandLine(args) {
9
9
  args,
10
10
  options: {
11
11
  'color-scheme': { type: 'string' },
12
- 'demo': { type: 'string' },
13
12
  'description': { type: 'string' },
14
13
  'dry-run': { type: 'boolean' },
14
+ 'example': { type: 'string' },
15
15
  'git': { type: 'boolean' },
16
16
  'help': { short: 'h', type: 'boolean' },
17
17
  'lang': { type: 'string' },
@@ -22,6 +22,7 @@ export function parseCommandLine(args) {
22
22
  'package-manager': { type: 'string' },
23
23
  'primary-color': { type: 'string' },
24
24
  'skip-install': { type: 'boolean' },
25
+ 'style': { type: 'string' },
25
26
  'template': { type: 'string' },
26
27
  'title': { type: 'string' },
27
28
  'version': { short: 'v', type: 'boolean' },
@@ -33,22 +34,17 @@ export function parseCommandLine(args) {
33
34
 
34
35
  export async function resolveProjectRequest(command, environment) {
35
36
  const { positionals, values } = command
36
- if (positionals.length > 1) throw new Error('Only one project directory can be provided.')
37
- if (values.git && values['no-git']) throw new Error('Use either --git or --no-git, not both.')
38
- if (values['no-install'] && values['skip-install']) throw new Error('Use either --no-install or --skip-install, not both.')
37
+ validateCommand(positionals, values)
39
38
 
40
39
  const prompt = await createPrompt(environment, values)
41
40
  try {
42
- const directory = positionals[0] ?? await prompt?.text('Project directory', 'my-jst-app') ?? 'my-jst-app'
43
- const name = values.name ?? normalizePackageName(basename(resolve(environment.cwd(), directory)))
44
- const title = values.title ?? toTitle(name)
45
- const detectedPackageManager = detectPackageManager(environment.env)
46
- const requestedPackageManager = values['package-manager']
47
- ?? await prompt?.choice('Package manager', PACKAGE_MANAGERS, detectedPackageManager)
48
- ?? detectedPackageManager
49
- const packageManager = requestedPackageManager === 'auto' ? detectedPackageManager : requestedPackageManager
50
- const initGit = values.git ?? !values['no-git']
51
- 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'
52
48
 
53
49
  return {
54
50
  dryRun: values['dry-run'] ?? false,
@@ -56,17 +52,18 @@ export async function resolveProjectRequest(command, environment) {
56
52
  terminal: Boolean(environment.stdin.isTTY),
57
53
  specification: buildProjectSpecification({
58
54
  colorScheme: values['color-scheme'],
59
- demo: values.demo ?? await prompt?.choice('Start from', ['remove', 'keep'], 'remove') ?? 'remove',
60
- description: values.description ?? `${title} web application.`,
61
- destination: resolve(environment.cwd(), directory),
62
- initGit: prompt ? await prompt.confirm('Initialize a Git repository?', initGit) : initGit,
63
- install: prompt ? await prompt.confirm('Install dependencies?', install) : 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,
64
60
  language: values.lang,
65
- name,
61
+ name: identity.name,
66
62
  packageManager,
67
63
  primaryColor: values['primary-color'],
64
+ style,
68
65
  template: values.template,
69
- title,
66
+ title: identity.title,
70
67
  }),
71
68
  }
72
69
  }
@@ -77,9 +74,9 @@ export async function resolveProjectRequest(command, environment) {
77
74
 
78
75
  export function detectPackageManager(environment = {}) {
79
76
  const userAgent = environment.npm_config_user_agent ?? ''
80
- if (userAgent.startsWith('pnpm/')) return 'pnpm'
81
- if (userAgent.startsWith('yarn/')) return 'yarn'
82
- if (userAgent.startsWith('bun/')) return 'bun'
77
+ if (userAgent.startsWith('pnpm/')) {
78
+ return 'pnpm'
79
+ }
83
80
  return 'npm'
84
81
  }
85
82
 
@@ -93,11 +90,11 @@ The interactive wizard only asks about decisions that affect the generated proje
93
90
  Application title and description are derived from the directory; pass flags only when automation needs overrides.
94
91
 
95
92
  Options:
96
- --demo <keep|remove> retain or remove the starter demo
97
- --package-manager <manager> auto, npm, pnpm, yarn, or bun
93
+ --package-manager <manager> auto, npm, or pnpm
98
94
  --no-install create the project without installing dependencies
99
95
  --git, --no-git initialize a Git repository (default: enabled)
100
96
  --dry-run show the resolved plan without writing files
97
+ --example <name> clean or showcase (default: clean)
101
98
  --no-interactive, -y, --yes accept defaults without prompts
102
99
  --title <title> override the derived application title
103
100
  --description <description> override the derived SEO description
@@ -105,6 +102,7 @@ Options:
105
102
  --lang <language> document language (default: en)
106
103
  --color-scheme <scheme> light, dark, or auto
107
104
  --primary-color <colour> Mantine primary colour
105
+ --style <language> css or scss (default: css)
108
106
  --template <owner/repo[#ref]> use a compatible template source
109
107
  -v, --version show the CLI version
110
108
  -h, --help show this help
@@ -112,14 +110,16 @@ Options:
112
110
  }
113
111
 
114
112
  async function createPrompt(environment, values) {
115
- if (!environment.stdin.isTTY || values.yes || values['no-interactive']) return undefined
113
+ if (!environment.stdin.isTTY || values.yes || values['no-interactive']) {
114
+ return undefined
115
+ }
116
116
  prompts.intro('create-jst')
117
117
  return {
118
118
  async choice(label, choices, fallback) {
119
119
  return resolvePrompt(await prompts.select({
120
120
  initialValue: fallback,
121
121
  message: label,
122
- options: choices.map(value => ({ hint: choiceHint(value), label: choiceLabel(value), value })),
122
+ options: choices.map(value => ({ hint: choiceHint(value), label: value, value })),
123
123
  }))
124
124
  },
125
125
  close() {
@@ -134,24 +134,66 @@ async function createPrompt(environment, values) {
134
134
  }
135
135
  }
136
136
 
137
- function choiceLabel(value) {
138
- return {
139
- keep: 'Keep the JST demo',
140
- remove: 'Start with a clean application',
141
- }[value] ?? value
142
- }
143
-
144
137
  function choiceHint(value) {
145
138
  return {
146
139
  npm: 'default',
147
- remove: 'recommended',
148
140
  }[value]
149
141
  }
150
142
 
151
143
  function resolvePrompt(value) {
152
- if (!prompts.isCancel(value)) return value
144
+ if (!prompts.isCancel(value)) {
145
+ return value
146
+ }
153
147
  prompts.cancel('Setup cancelled.')
154
148
  const error = new Error('Setup cancelled.')
155
149
  error.name = 'PromptCancelledError'
156
150
  throw error
157
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
+ }
@@ -28,11 +28,12 @@ export function createReporter(output, environment = {}) {
28
28
  plan(specification, request) {
29
29
  usesClack = request.terminal
30
30
  if (usesClack) {
31
- if (!request.interactive) prompts.intro('create-jst')
31
+ if (!request.interactive) {
32
+ prompts.intro('create-jst')
33
+ }
32
34
  prompts.note([
33
35
  `Template: ${specification.template}`,
34
36
  `Package manager: ${specification.packageManager}`,
35
- `Starter demo: ${specification.demo}`,
36
37
  `Install dependencies: ${specification.install ? 'yes' : 'no'}`,
37
38
  `Initialize Git: ${specification.initGit ? 'yes' : 'no'}`,
38
39
  ].join('\n'), specification.destination)
@@ -43,23 +44,27 @@ export function createReporter(output, environment = {}) {
43
44
  output.write(` destination ${specification.destination}\n`)
44
45
  output.write(` template ${specification.template}\n`)
45
46
  output.write(` package manager ${specification.packageManager}\n`)
46
- output.write(` starter demo ${specification.demo}\n`)
47
47
  output.write(` install ${specification.install ? 'yes' : 'no'}\n`)
48
48
  output.write(` git ${specification.initGit ? 'yes' : 'no'}\n\n`)
49
49
  },
50
50
  start(request) {
51
51
  usesClack = request.terminal
52
52
  if (usesClack) {
53
- if (!request.interactive) prompts.intro('create-jst')
53
+ if (!request.interactive) {
54
+ prompts.intro('create-jst')
55
+ }
54
56
  return
55
57
  }
56
58
  output.write(`\n${paint('38;5;203', 'JST')} ${dim('A foundation for replaceable layers')}\n\n`)
57
59
  },
58
- subscribe(events) {
59
- return events.subscribe(event => {
60
- if (event.type === 'project.initialized') return this.success(event.specification)
60
+ progress(event) {
61
+ if (event.type === 'project.initialized') {
62
+ return this.success(event.specification)
63
+ }
61
64
  const message = progressMessages[event.type]
62
- if (!message) return
65
+ if (!message) {
66
+ return
67
+ }
63
68
  if (usesClack) {
64
69
  activeSpinner?.stop()
65
70
  activeSpinner = prompts.spinner()
@@ -67,7 +72,6 @@ export function createReporter(output, environment = {}) {
67
72
  return
68
73
  }
69
74
  output.write(`${dim(' ◌')} ${message(event.specification)}\n`)
70
- })
71
75
  },
72
76
  success(specification) {
73
77
  if (usesClack) {
@@ -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
- }