create-jst 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JST Stack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # create-jst
2
+
3
+ Create a web application from the [JST](https://github.com/jst-stack/jst) template.
4
+
5
+ ```bash
6
+ npm create jst@latest my-app
7
+ ```
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.
10
+
11
+ ```bash
12
+ npm create jst@latest my-app -- --yes
13
+ npm create jst@latest my-app -- --package-manager pnpm --demo keep
14
+ npm create jst@latest my-app -- --dry-run
15
+ ```
16
+
17
+ Use `--help` to view non-interactive options. The initializer never overwrites a non-empty directory.
18
+
19
+ See [Architecture](./ARCHITECTURE.md) for the dependency boundaries and replaceable gateways.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "create-jst",
3
+ "version": "0.3.0",
4
+ "description": "Create a web application from the JST template.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-jst": "src/cli.mjs"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "engines": {
13
+ "node": ">=24 <25"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/jst-stack/create-jst.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/jst-stack/create-jst/issues"
24
+ },
25
+ "homepage": "https://github.com/jst-stack/jst#readme",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "degit": "^3.10.0"
29
+ }
30
+ }
@@ -0,0 +1,12 @@
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
+ }
@@ -0,0 +1,28 @@
1
+ export function createProjectInitializer({ events, projectGateway, templateGateway }) {
2
+ return {
3
+ async execute(specification) {
4
+ await projectGateway.assertDestinationIsEmpty(specification.destination)
5
+
6
+ events.publish({ type: 'template.download.started', specification })
7
+ await templateGateway.clone(specification.template, specification.destination)
8
+
9
+ events.publish({ type: 'project.configuration.started', specification })
10
+ await projectGateway.configureTemplate(specification)
11
+
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)
19
+ }
20
+ if (specification.install) {
21
+ events.publish({ type: 'dependencies.installation.started', specification })
22
+ await projectGateway.installDependencies(specification.destination, specification.packageManager)
23
+ }
24
+
25
+ events.publish({ type: 'project.initialized', specification })
26
+ },
27
+ }
28
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from 'node:process'
4
+ import { realpathSync } from 'node:fs'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createProjectApplication } from './composition/createProjectApplication.mjs'
7
+ import { parseCommandLine, printHelp, resolveProjectRequest } from './ui/command.mjs'
8
+ import { createReporter } from './ui/reporter.mjs'
9
+
10
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) await run(process.argv.slice(2))
11
+
12
+ export async function run(argv, environment = process, application = createProjectApplication()) {
13
+ const reporter = createReporter(environment.stdout, environment.env)
14
+ try {
15
+ 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`)
18
+
19
+ const request = await resolveProjectRequest(command, environment)
20
+ if (request.dryRun) return reporter.plan(request.specification)
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()
29
+ }
30
+ }
31
+ catch (error) {
32
+ reporter.error(error instanceof Error ? error.message : String(error))
33
+ environment.exitCode = 1
34
+ }
35
+ }
@@ -0,0 +1,15 @@
1
+ import process from 'node:process'
2
+ import { createEventBus } from '../application/eventBus.mjs'
3
+ import { createProjectInitializer } from '../application/projectInitializer.mjs'
4
+ import { createDegitTemplateGateway } from '../infrastructure/degitTemplateGateway.mjs'
5
+ import { createNodeProjectGateway } from '../infrastructure/nodeProjectGateway.mjs'
6
+
7
+ export function createProjectApplication({ nodeExecutable = process.execPath } = {}) {
8
+ const events = createEventBus()
9
+ const initializer = createProjectInitializer({
10
+ events,
11
+ projectGateway: createNodeProjectGateway({ nodeExecutable }),
12
+ templateGateway: createDegitTemplateGateway(),
13
+ })
14
+ return { events, initializer }
15
+ }
@@ -0,0 +1,43 @@
1
+ export const COLOR_SCHEMES = ['light', 'dark', 'auto']
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'
5
+
6
+ export function buildProjectSpecification(input) {
7
+ const specification = Object.freeze({
8
+ colorScheme: input.colorScheme ?? 'dark',
9
+ demo: input.demo ?? 'remove',
10
+ description: input.description ?? `${input.title} web application.`,
11
+ destination: input.destination,
12
+ initGit: input.initGit ?? true,
13
+ install: input.install ?? true,
14
+ language: input.language ?? 'en',
15
+ name: input.name,
16
+ packageManager: input.packageManager ?? 'npm',
17
+ primaryColor: input.primaryColor ?? 'lime',
18
+ template: input.template ?? TEMPLATE_SOURCE,
19
+ title: input.title,
20
+ })
21
+ validateProjectSpecification(specification)
22
+ return specification
23
+ }
24
+
25
+ export function validateProjectSpecification(specification) {
26
+ if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(specification.name) || specification.name.length > 214) {
27
+ throw new Error(`Invalid npm package name: ${specification.name}`)
28
+ }
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(', ')}.`)
35
+ }
36
+
37
+ export function normalizePackageName(value) {
38
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'jst-app'
39
+ }
40
+
41
+ export function toTitle(value) {
42
+ return value.replace(/^@[^/]+\//, '').split(/[-_.]+/).filter(Boolean).map(word => word[0].toUpperCase() + word.slice(1)).join(' ')
43
+ }
@@ -0,0 +1,9 @@
1
+ import degit from 'degit'
2
+
3
+ export function createDegitTemplateGateway() {
4
+ return {
5
+ clone(source, destination) {
6
+ return degit(source, { disableCache: true, force: false, verbose: false }).clone(destination)
7
+ },
8
+ }
9
+ }
@@ -0,0 +1,82 @@
1
+ import { spawn, execFile } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ const executeFile = promisify(execFile)
8
+ const installCommands = {
9
+ bun: ['install'],
10
+ npm: ['install', '--no-audit', '--no-fund'],
11
+ pnpm: ['install'],
12
+ yarn: ['install'],
13
+ }
14
+
15
+ export function createNodeProjectGateway({ nodeExecutable }) {
16
+ return {
17
+ assertDestinationIsEmpty,
18
+ configurePackageManager,
19
+ configureTemplate,
20
+ initializeGit,
21
+ installDependencies,
22
+ }
23
+
24
+ async function assertDestinationIsEmpty(destination) {
25
+ if (!existsSync(destination)) return
26
+ 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}`)
29
+ }
30
+
31
+ function configureTemplate(specification) {
32
+ return run(nodeExecutable, [
33
+ 'scripts/setup-template.mjs',
34
+ '--yes',
35
+ '--name', specification.name,
36
+ '--title', specification.title,
37
+ '--description', specification.description,
38
+ '--lang', specification.language,
39
+ '--color-scheme', specification.colorScheme,
40
+ '--primary-color', specification.primaryColor,
41
+ '--demo', specification.demo,
42
+ ], specification.destination)
43
+ }
44
+
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')
54
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
55
+ manifest.packageManager = `${packageManager}@${version.trim()}`
56
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, '\t')}\n`)
57
+ await rm(join(destination, 'package-lock.json'), { force: true })
58
+ }
59
+
60
+ function initializeGit(destination) {
61
+ return run('git', ['init', '--initial-branch=main'], destination)
62
+ }
63
+
64
+ function installDependencies(destination, packageManager) {
65
+ return run(packageManager, installCommands[packageManager], destination, true)
66
+ }
67
+ }
68
+
69
+ function run(command, args, cwd, inheritOutput = false) {
70
+ return new Promise((resolve, reject) => {
71
+ const child = spawn(command, args, { cwd, stdio: inheritOutput ? 'inherit' : 'pipe' })
72
+ let stderr = ''
73
+ child.stderr?.on('data', chunk => {
74
+ stderr += chunk
75
+ })
76
+ child.once('error', error => reject(new Error(`Could not run ${command}: ${error.message}`)))
77
+ child.once('close', code => {
78
+ if (code === 0) resolve()
79
+ else reject(new Error(`${command} ${args.join(' ')} failed.${stderr ? `\n${stderr.trim()}` : ''}`))
80
+ })
81
+ })
82
+ }
@@ -0,0 +1,130 @@
1
+ import { basename, resolve } from 'node:path'
2
+ import { parseArgs } from 'node:util'
3
+ import { buildProjectSpecification, normalizePackageName, PACKAGE_MANAGERS, toTitle } from '../domain/projectSpecification.mjs'
4
+
5
+ export function parseCommandLine(args) {
6
+ return parseArgs({
7
+ allowPositionals: true,
8
+ args,
9
+ options: {
10
+ 'color-scheme': { type: 'string' },
11
+ 'demo': { type: 'string' },
12
+ 'description': { type: 'string' },
13
+ 'dry-run': { type: 'boolean' },
14
+ 'git': { type: 'boolean' },
15
+ 'help': { short: 'h', type: 'boolean' },
16
+ 'lang': { type: 'string' },
17
+ 'name': { type: 'string' },
18
+ 'no-git': { type: 'boolean' },
19
+ 'no-install': { type: 'boolean' },
20
+ 'no-interactive': { type: 'boolean' },
21
+ 'package-manager': { type: 'string' },
22
+ 'primary-color': { type: 'string' },
23
+ 'skip-install': { type: 'boolean' },
24
+ 'template': { type: 'string' },
25
+ 'title': { type: 'string' },
26
+ 'version': { short: 'v', type: 'boolean' },
27
+ 'yes': { short: 'y', type: 'boolean' },
28
+ },
29
+ strict: true,
30
+ })
31
+ }
32
+
33
+ export async function resolveProjectRequest(command, environment) {
34
+ const { positionals, values } = command
35
+ if (positionals.length > 1) throw new Error('Only one project directory can be provided.')
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.')
38
+
39
+ const prompt = await createPrompt(environment, values)
40
+ try {
41
+ const directory = positionals[0] ?? await prompt?.text('Project directory', 'my-jst-app') ?? 'my-jst-app'
42
+ const name = values.name ?? normalizePackageName(basename(resolve(environment.cwd(), directory)))
43
+ const title = values.title ?? toTitle(name)
44
+ const detectedPackageManager = detectPackageManager(environment.env)
45
+ const requestedPackageManager = values['package-manager']
46
+ ?? await prompt?.choice('Package manager', PACKAGE_MANAGERS, detectedPackageManager)
47
+ ?? detectedPackageManager
48
+ const packageManager = requestedPackageManager === 'auto' ? detectedPackageManager : requestedPackageManager
49
+ const initGit = values.git ?? !values['no-git']
50
+ const install = !(values['no-install'] || values['skip-install'])
51
+
52
+ return {
53
+ dryRun: values['dry-run'] ?? false,
54
+ specification: buildProjectSpecification({
55
+ colorScheme: values['color-scheme'],
56
+ demo: values.demo ?? await prompt?.choice('Start from', ['remove', 'keep'], 'remove') ?? 'remove',
57
+ description: values.description ?? `${title} web application.`,
58
+ destination: resolve(environment.cwd(), directory),
59
+ initGit: prompt ? await prompt.choice('Initialize a Git repository', ['yes', 'no'], initGit ? 'yes' : 'no') === 'yes' : initGit,
60
+ install: prompt ? await prompt.choice('Install dependencies', ['yes', 'no'], install ? 'yes' : 'no') === 'yes' : install,
61
+ language: values.lang,
62
+ name,
63
+ packageManager,
64
+ primaryColor: values['primary-color'],
65
+ template: values.template,
66
+ title,
67
+ }),
68
+ }
69
+ }
70
+ finally {
71
+ prompt?.close()
72
+ }
73
+ }
74
+
75
+ export function detectPackageManager(environment = {}) {
76
+ const userAgent = environment.npm_config_user_agent ?? ''
77
+ if (userAgent.startsWith('pnpm/')) return 'pnpm'
78
+ if (userAgent.startsWith('yarn/')) return 'yarn'
79
+ if (userAgent.startsWith('bun/')) return 'bun'
80
+ return 'npm'
81
+ }
82
+
83
+ export function printHelp(output) {
84
+ output.write(`Create a web application from the JST template.
85
+
86
+ Usage:
87
+ npm create jst@latest [directory] [options]
88
+
89
+ The interactive wizard only asks about decisions that affect the generated project.
90
+ Application title and description are derived from the directory; pass flags only when automation needs overrides.
91
+
92
+ Options:
93
+ --demo <keep|remove> retain or remove the starter demo
94
+ --package-manager <manager> auto, npm, pnpm, yarn, or bun
95
+ --no-install create the project without installing dependencies
96
+ --git, --no-git initialize a Git repository (default: enabled)
97
+ --dry-run show the resolved plan without writing files
98
+ --no-interactive, -y, --yes accept defaults without prompts
99
+ --title <title> override the derived application title
100
+ --description <description> override the derived SEO description
101
+ --name <name> override the derived npm package name
102
+ --lang <language> document language (default: en)
103
+ --color-scheme <scheme> light, dark, or auto
104
+ --primary-color <colour> Mantine primary colour
105
+ --template <owner/repo[#ref]> use a compatible template source
106
+ -v, --version show the CLI version
107
+ -h, --help show this help
108
+ `)
109
+ }
110
+
111
+ async function createPrompt(environment, values) {
112
+ if (!environment.stdin.isTTY || values.yes || values['no-interactive']) return undefined
113
+ const { createInterface } = await import('node:readline/promises')
114
+ const reader = createInterface({ input: environment.stdin, output: environment.stdout })
115
+ return {
116
+ async choice(label, choices, fallback) {
117
+ const rendered = choices.map((choice, index) => `${index + 1}:${choice}`).join(' ')
118
+ const answer = (await reader.question(` ${label} (${rendered}) [${fallback}]: `)).trim()
119
+ if (!answer) return fallback
120
+ const index = Number.parseInt(answer, 10)
121
+ return choices[index - 1] ?? answer
122
+ },
123
+ close() {
124
+ reader.close()
125
+ },
126
+ async text(label, fallback) {
127
+ return (await reader.question(` ${label} [${fallback}]: `)).trim() || fallback
128
+ },
129
+ }
130
+ }
@@ -0,0 +1,45 @@
1
+ import { basename } from 'node:path'
2
+
3
+ const progressMessages = {
4
+ 'dependencies.installation.started': specification => `Installing dependencies with ${specification.packageManager}`,
5
+ 'git.initialization.started': () => 'Initialising Git',
6
+ 'package-manager.configuration.started': specification => `Preparing ${specification.packageManager}`,
7
+ 'project.configuration.started': specification => `Configuring ${specification.title}`,
8
+ 'template.download.started': specification => `Downloading ${specification.template}`,
9
+ }
10
+
11
+ export function createReporter(output, environment = {}) {
12
+ const color = Boolean(output.isTTY) && !environment.NO_COLOR
13
+ const paint = (code, value) => color ? `\u001B[${code}m${value}\u001B[0m` : value
14
+ const dim = value => paint('2', value)
15
+
16
+ return {
17
+ error(message) {
18
+ output.write(`\n${paint('31', '✕')} ${message}\n`)
19
+ },
20
+ plan(specification) {
21
+ output.write(`\n${paint('38;5;203', 'JST')} ${dim('plan only — no files will be written')}\n\n`)
22
+ output.write(` destination ${specification.destination}\n`)
23
+ output.write(` template ${specification.template}\n`)
24
+ output.write(` package manager ${specification.packageManager}\n`)
25
+ output.write(` starter demo ${specification.demo}\n`)
26
+ output.write(` install ${specification.install ? 'yes' : 'no'}\n`)
27
+ output.write(` git ${specification.initGit ? 'yes' : 'no'}\n\n`)
28
+ },
29
+ start() {
30
+ output.write(`\n${paint('38;5;203', 'JST')} ${dim('A foundation for replaceable layers')}\n\n`)
31
+ },
32
+ subscribe(events) {
33
+ return events.subscribe(event => {
34
+ if (event.type === 'project.initialized') return this.success(event.specification)
35
+ const message = progressMessages[event.type]
36
+ if (message) output.write(`${dim(' ◌')} ${message(event.specification)}\n`)
37
+ })
38
+ },
39
+ success(specification) {
40
+ output.write(`\n${paint('38;5;114', 'Ready.')} ${specification.title} is in ${paint('38;5;203', basename(specification.destination))}.\n\n`)
41
+ output.write(` ${dim('$')} cd ${basename(specification.destination)}\n`)
42
+ output.write(` ${dim('$')} ${specification.packageManager} run dev\n\n`)
43
+ },
44
+ }
45
+ }