create-stator 1.1.0 → 1.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/index.js CHANGED
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // Stator scaffolder. Interactive (clack) when run bare; every prompt has a
3
- // flag so CI and scripts stay prompt-free:
4
- // pnpm create stator → prompts for directory + template
5
- // pnpm create stator my-app --template minimal → no prompts
6
- import { cp, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
2
+ // Stator scaffolder. First-party templates live in the monorepo's
3
+ // `examples/` directory and are FETCHED at scaffold time (create-astro
4
+ // style) they update with every push, and `--template` accepts any giget
5
+ // source (`github:user/repo/path`) so community templates work for free.
6
+ //
7
+ // Interactive (clack) when run bare; every prompt has a flag so CI and
8
+ // scripts stay prompt-free:
9
+ // pnpm create stator → prompts
10
+ // pnpm create stator my-app --template todomvc → no prompts
11
+ // pnpm create stator my-app -t github:you/your-template
12
+ import { spawn, spawnSync } from 'node:child_process'
13
+ import { readdir, readFile, writeFile } from 'node:fs/promises'
7
14
  import { basename, join, resolve } from 'node:path'
8
- import { fileURLToPath } from 'node:url'
9
15
  import { parseArgs } from 'node:util'
10
16
  import * as p from '@clack/prompts'
17
+ import { downloadTemplate } from 'giget'
11
18
 
12
19
  const TEMPLATES = [
13
20
  {
@@ -15,20 +22,81 @@ const TEMPLATES = [
15
22
  label: 'Minimal',
16
23
  hint: 'one machine, one page — the smallest real app',
17
24
  },
25
+ {
26
+ value: 'todomvc',
27
+ label: 'TodoMVC',
28
+ hint: 'the classic, with server-owned todos and zero-JS editing',
29
+ },
30
+ {
31
+ value: 'desksmith',
32
+ label: 'Desksmith',
33
+ hint: "the tutorial's finished app — catalog, cart, checkout, theme island",
34
+ },
35
+ {
36
+ value: 'live-poll',
37
+ label: 'Live poll',
38
+ hint: 'shared app-machine state, pushed to every visitor over SSE',
39
+ },
40
+ {
41
+ value: 'with-auth',
42
+ label: 'With auth',
43
+ hint: 'accounts, guarded login, roles, session rotation (Node 24+)',
44
+ },
18
45
  ]
19
46
 
47
+ /** First-party templates resolve into the monorepo's examples/. */
48
+ const FIRST_PARTY = 'gh:statorjs/stator/examples'
49
+ /** Scaffolded apps get a real semver for the framework (the examples
50
+ * themselves use workspace linking in-repo). Bumped with releases. */
51
+ const STATOR_RANGE = '^1.1.0'
52
+
20
53
  const { values: flags, positionals } = parseArgs({
21
54
  allowPositionals: true,
22
55
  options: {
23
56
  template: { type: 'string', short: 't' },
57
+ ref: { type: 'string' },
58
+ install: { type: 'boolean' },
59
+ 'no-install': { type: 'boolean' },
60
+ git: { type: 'boolean' },
61
+ 'no-git': { type: 'boolean' },
62
+ yes: { type: 'boolean', short: 'y' },
24
63
  help: { type: 'boolean', short: 'h' },
25
64
  },
26
65
  })
27
66
 
67
+ /** The package manager that LAUNCHED us (`pnpm create stator` → pnpm), via
68
+ * the standard npm_config_user_agent header. Defaults to npm. */
69
+ function detectPackageManager() {
70
+ const agent = process.env.npm_config_user_agent ?? ''
71
+ if (agent.startsWith('pnpm')) return 'pnpm'
72
+ if (agent.startsWith('yarn')) return 'yarn'
73
+ if (agent.startsWith('bun')) return 'bun'
74
+ return 'npm'
75
+ }
76
+ const pm = detectPackageManager()
77
+ const runCmd = (script) => (pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`)
78
+
79
+ /** Resolve a yes/no choice: explicit flag > --yes > prompt (TTY) > safe
80
+ * default for non-interactive runs. */
81
+ async function decide({ yesFlag, noFlag, message, fallback }) {
82
+ if (yesFlag) return true
83
+ if (noFlag) return false
84
+ if (flags.yes) return true
85
+ if (!process.stdout.isTTY) return fallback
86
+ const answer = await p.confirm({ message, initialValue: true })
87
+ bailIfCancelled(answer)
88
+ return answer
89
+ }
90
+
28
91
  if (flags.help) {
29
92
  console.log(
30
93
  `Usage: pnpm create stator [directory] [--template ${TEMPLATES.map((t) => t.value).join('|')}]`,
31
94
  )
95
+ console.log(' pnpm create stator my-app --template github:user/repo/path')
96
+ console.log(' --ref <branch|tag> fetch first-party templates from a specific ref')
97
+ console.log(' --install/--no-install install dependencies after scaffolding')
98
+ console.log(' --git/--no-git initialize a git repository')
99
+ console.log(' -y, --yes accept all defaults (install + git)')
32
100
  process.exit(0)
33
101
  }
34
102
 
@@ -59,44 +127,102 @@ try {
59
127
 
60
128
  // --- template ---
61
129
  let template = flags.template
62
- if (template && !TEMPLATES.some((t) => t.value === template)) {
63
- p.cancel(`Unknown template "${template}". Available: ${TEMPLATES.map((t) => t.value).join(', ')}`)
130
+ const isRemoteSource = (t) => t.includes(':') || t.includes('/')
131
+ if (template && !isRemoteSource(template) && !TEMPLATES.some((t) => t.value === template)) {
132
+ p.cancel(
133
+ `Unknown template "${template}". Available: ${TEMPLATES.map((t) => t.value).join(', ')} ` +
134
+ `(or any giget source, e.g. github:user/repo/path)`,
135
+ )
64
136
  process.exit(1)
65
137
  }
66
138
  if (!template) {
67
- if (TEMPLATES.length === 1) {
68
- template = TEMPLATES[0].value
69
- } else {
70
- const answer = await p.select({ message: 'Which template?', options: TEMPLATES })
71
- bailIfCancelled(answer)
72
- template = answer
73
- }
139
+ const answer = await p.select({ message: 'Which template?', options: TEMPLATES })
140
+ bailIfCancelled(answer)
141
+ template = answer
74
142
  }
75
143
 
76
- // --- scaffold ---
144
+ // --- fetch ---
145
+ const ref = flags.ref ? `#${flags.ref}` : ''
146
+ const source = isRemoteSource(template) ? template : `${FIRST_PARTY}/${template}${ref}`
77
147
  const s = p.spinner()
78
- s.start('Copying template')
79
- const templateDir = fileURLToPath(new URL(`./templates/${template}`, import.meta.url))
80
- await cp(templateDir, dest, { recursive: true })
81
-
82
- // npm strips dotfiles from published packages; ship as _gitignore and rename.
148
+ s.start(`Fetching ${template}`)
83
149
  try {
84
- await stat(join(dest, '_gitignore'))
85
- await rename(join(dest, '_gitignore'), join(dest, '.gitignore'))
86
- } catch {
87
- // already named .gitignore (running from the repo)
150
+ await downloadTemplate(source, { dir: dest, force: true })
151
+ } catch (err) {
152
+ s.stop('Fetch failed')
153
+ p.cancel(
154
+ `Could not download "${source}" — scaffolding needs network access.\n ${String(err?.message ?? err)}`,
155
+ )
156
+ process.exit(1)
88
157
  }
89
158
 
159
+ // --- stamp ---
90
160
  const name = basename(dest)
91
161
  .toLowerCase()
92
162
  .replace(/[^a-z0-9-]/g, '-')
93
163
  const pkgPath = join(dest, 'package.json')
94
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
95
- pkg.name = name
96
- await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
97
- s.stop(`Scaffolded ${name} (${template})`)
164
+ try {
165
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
166
+ pkg.name = name
167
+ // In-repo examples link the framework via the workspace; a scaffolded
168
+ // app pins the published release instead.
169
+ if (pkg.dependencies?.['@statorjs/stator'] === 'workspace:*') {
170
+ pkg.dependencies['@statorjs/stator'] = STATOR_RANGE
171
+ }
172
+ delete pkg.private
173
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
174
+ s.stop(`Scaffolded ${name} (${template})`)
175
+ } catch {
176
+ s.stop(`Scaffolded ${name} (${template}) — no package.json to stamp`)
177
+ }
178
+
179
+ // --- install? ---
180
+ const wantInstall = await decide({
181
+ yesFlag: flags.install,
182
+ noFlag: flags['no-install'],
183
+ message: `Install dependencies with ${pm}?`,
184
+ fallback: false,
185
+ })
186
+ let installed = false
187
+ if (wantInstall) {
188
+ const si = p.spinner()
189
+ si.start(`Installing with ${pm}`)
190
+ installed = await new Promise((done) => {
191
+ const child = spawn(pm, ['install'], { cwd: dest, stdio: 'ignore', shell: true })
192
+ child.on('close', (code) => done(code === 0))
193
+ child.on('error', () => done(false))
194
+ })
195
+ si.stop(installed ? 'Dependencies installed' : `${pm} install failed — run it yourself`)
196
+ }
197
+
198
+ // --- git? ---
199
+ const wantGit = await decide({
200
+ yesFlag: flags.git,
201
+ noFlag: flags['no-git'],
202
+ message: 'Initialize a git repository?',
203
+ fallback: false,
204
+ })
205
+ if (wantGit) {
206
+ const hasGit = spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0
207
+ if (hasGit) {
208
+ spawnSync('git', ['init', '-q'], { cwd: dest, stdio: 'ignore' })
209
+ spawnSync('git', ['add', '-A'], { cwd: dest, stdio: 'ignore' })
210
+ // May no-op if user.name/email are unset — an initialized repo without
211
+ // a first commit is still a win.
212
+ spawnSync('git', ['commit', '-q', '-m', 'Initial commit from create-stator'], {
213
+ cwd: dest,
214
+ stdio: 'ignore',
215
+ })
216
+ p.log.success('Initialized a git repository')
217
+ } else {
218
+ p.log.warn('git not found — skipped repository setup')
219
+ }
220
+ }
98
221
 
99
- p.note([`cd ${target}`, 'pnpm install', 'pnpm dev'].join('\n'), 'Next steps')
222
+ p.note(
223
+ [`cd ${target}`, ...(installed ? [] : [`${pm} install`]), runCmd('dev')].join('\n'),
224
+ 'Next steps',
225
+ )
100
226
  p.outro('Docs: https://docs.statorjs.dev · Demo: https://demo.statorjs.dev')
101
227
 
102
228
  function bailIfCancelled(v) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stator",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Scaffold a new Stator app: pnpm create stator my-app",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,8 +8,7 @@
8
8
  "create-stator": "./index.js"
9
9
  },
10
10
  "files": [
11
- "index.js",
12
- "templates"
11
+ "index.js"
13
12
  ],
14
13
  "repository": {
15
14
  "type": "git",
@@ -21,6 +20,7 @@
21
20
  "node": ">=20"
22
21
  },
23
22
  "dependencies": {
24
- "@clack/prompts": "^1.7.0"
23
+ "@clack/prompts": "^1.7.0",
24
+ "giget": "^1.2.3"
25
25
  }
26
26
  }
@@ -1,31 +0,0 @@
1
- # A Stator app
2
-
3
- ```sh
4
- pnpm install
5
- pnpm dev # dev server with live reload + the wire inspector
6
- pnpm typecheck # sync generated component types, then tsc
7
- pnpm build # production build into dist/
8
- pnpm start # serve the production build
9
- ```
10
-
11
- ## The mental model
12
-
13
- State lives **on the server**, in machines. Pages declare what they read;
14
- when an event changes a machine, the server diffs and sends small JSON
15
- patches to the browser. There is no client renderer and no API layer.
16
-
17
- - `machines/counter.ts` — a session-scoped machine: typed events, guarded
18
- transitions, selectors. Session state survives a page reload because the
19
- server owns it.
20
- - `routes/index.stator` — a page: frontmatter declares `Stator.reads`, the
21
- template calls `read(machine, selector)` wherever live values render.
22
- - `templates/layout.stator` — a component with a `<children />` slot.
23
-
24
- Try it: run `pnpm dev`, click **+1**, then reload the page — the count
25
- persists. Open the inspector (bottom of the page) and click again to watch
26
- the patch arrive on the wire.
27
-
28
- ## Learn more
29
-
30
- - Docs: https://docs.statorjs.dev — start with the tutorial
31
- - Reference app: https://demo.statorjs.dev ([source](https://github.com/statorjs/stator/tree/main/apps/store))
@@ -1,3 +0,0 @@
1
- node_modules/
2
- dist/
3
- .stator/
@@ -1,7 +0,0 @@
1
- import { dirname, resolve } from 'node:path'
2
- import { fileURLToPath } from 'node:url'
3
- import { buildApp } from '@statorjs/stator/build'
4
-
5
- const here = dirname(fileURLToPath(import.meta.url))
6
- const result = await buildApp({ root: here, outDir: resolve(here, 'dist') })
7
- console.log(`stator: built ${result.compiled} components → ${result.outDir}`)
@@ -1,27 +0,0 @@
1
- import { defineMachine } from '@statorjs/stator/server'
2
-
3
- type Events = { type: 'INCREMENT' } | { type: 'RESET' }
4
-
5
- export default defineMachine({
6
- name: 'CounterMachine',
7
- lifecycle: 'session',
8
- events: {} as Events,
9
- context: { count: 0 },
10
- initial: 'idle',
11
- states: {
12
- idle: {
13
- on: {
14
- INCREMENT: (ctx) => {
15
- ctx.count += 1
16
- },
17
- RESET: (ctx) => {
18
- ctx.count = 0
19
- },
20
- },
21
- },
22
- },
23
- selectors: {
24
- count: (ctx) => ctx.count,
25
- label: (ctx) => `count is ${ctx.count}`,
26
- },
27
- })
@@ -1,23 +0,0 @@
1
- {
2
- "name": "my-stator-app",
3
- "version": "0.0.0",
4
- "private": true,
5
- "type": "module",
6
- "scripts": {
7
- "sync": "tsx sync.ts",
8
- "typecheck": "tsx sync.ts && tsc --noEmit",
9
- "dev": "tsx watch server.ts",
10
- "build": "tsx build.ts",
11
- "start": "tsx start.ts"
12
- },
13
- "dependencies": {
14
- "@statorjs/stator": "^1.1.0"
15
- },
16
- "devDependencies": {
17
- "@types/node": "^22.7.0",
18
- "tsx": "^4.19.0",
19
- "typescript": "^5.6.0",
20
- "vite": "^6.0.0",
21
- "pino-pretty": "^13.1.3"
22
- }
23
- }
@@ -1,14 +0,0 @@
1
- ---
2
- import Counter from '../machines/counter.ts'
3
- import Layout from '../templates/layout.stator'
4
-
5
- const [counter] = Stator.reads([Counter])
6
- ---
7
- <Layout>
8
- <section class="counter">
9
- <h1>Hello, Stator</h1>
10
- <p class="count">{read(counter, (c) => c.label)}</p>
11
- <button on:click={() => counter.send({ type: 'INCREMENT' })}>+1</button>
12
- <button on:click={() => counter.send({ type: 'RESET' })}>reset</button>
13
- </section>
14
- </Layout>
@@ -1,19 +0,0 @@
1
- import { dirname, resolve } from 'node:path'
2
- import { fileURLToPath } from 'node:url'
3
- import { createDevApp } from '@statorjs/stator/dev'
4
-
5
- const here = dirname(fileURLToPath(import.meta.url))
6
- const port = Number(process.env.PORT ?? 3000)
7
-
8
- // Dev server: Vite compiles `.stator` on the way in, with live reload and the
9
- // inspector toolbar. Production runs `pnpm build && pnpm start` instead.
10
- // Sessions default to the in-memory store — swap in RedisStore (from
11
- // '@statorjs/stator/server') to survive restarts.
12
- const app = await createDevApp({
13
- root: here,
14
- machinesDir: resolve(here, 'machines'),
15
- routesDir: resolve(here, 'routes'),
16
- staticDir: resolve(here, 'static'),
17
- })
18
-
19
- await app.listen(port)
@@ -1,25 +0,0 @@
1
- import { stat } from 'node:fs/promises'
2
- import { dirname, resolve } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import { loadProductionHead } from '@statorjs/stator/build'
5
- import { createApp, logger } from '@statorjs/stator/server'
6
-
7
- const here = dirname(fileURLToPath(import.meta.url))
8
- const dist = resolve(here, 'dist')
9
- const port = Number(process.env.PORT ?? 3000)
10
-
11
- try {
12
- await stat(resolve(dist, 'routes'))
13
- } catch {
14
- logger.error({}, 'no dist/ found — run `pnpm build` first')
15
- process.exit(1)
16
- }
17
-
18
- const app = await createApp({
19
- machinesDir: resolve(dist, 'machines'),
20
- routesDir: resolve(dist, 'routes'),
21
- staticDir: resolve(dist, 'static'),
22
- headExtras: await loadProductionHead(dist),
23
- })
24
-
25
- await app.listen(port)
@@ -1,5 +0,0 @@
1
- :root { font-family: system-ui, sans-serif; color-scheme: light dark; }
2
- body { margin: 0; display: grid; place-items: center; min-height: 100vh; }
3
- .counter { text-align: center; }
4
- .count { font-size: 1.5rem; font-variant-numeric: tabular-nums; }
5
- button { font: inherit; padding: 0.4rem 1rem; margin: 0 0.25rem; cursor: pointer; }
@@ -1,11 +0,0 @@
1
- /**
2
- * Ambient type for `.stator` single-file components, for this app's TypeScript
3
- * project. (Stator apps include one of these, like Vite apps include env.d.ts.)
4
- */
5
- declare module '*.stator' {
6
- import type { HtmlFragment } from '@statorjs/stator/template'
7
-
8
- // biome-ignore lint/suspicious/noExplicitAny: the wildcard fallback must admit every component's props — `Record<string, unknown>` would reject interfaces without index signatures
9
- const component: (props?: any) => HtmlFragment
10
- export default component
11
- }
@@ -1,7 +0,0 @@
1
- import { dirname } from 'node:path'
2
- import { fileURLToPath } from 'node:url'
3
- import { syncTypes } from '@statorjs/stator/build'
4
-
5
- const here = dirname(fileURLToPath(import.meta.url))
6
- const { written } = await syncTypes(here)
7
- console.log(`stator: wrote ${written} .stator.d.ts files`)
@@ -1,12 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>stator app</title>
7
- <link rel="stylesheet" href="/static/app.css" />
8
- </head>
9
- <body>
10
- <main><children /></main>
11
- </body>
12
- </html>
@@ -1,33 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "Bundler",
6
- "lib": [
7
- "ES2022",
8
- "DOM",
9
- "DOM.Iterable"
10
- ],
11
- "strict": true,
12
- "noUncheckedIndexedAccess": true,
13
- "esModuleInterop": true,
14
- "skipLibCheck": true,
15
- "isolatedModules": true,
16
- "allowImportingTsExtensions": true,
17
- "noEmit": true,
18
- "rootDirs": [
19
- ".",
20
- ".stator/types"
21
- ],
22
- "jsx": "preserve"
23
- },
24
- "include": [
25
- "**/*.ts",
26
- "**/*.stator"
27
- ],
28
- "exclude": [
29
- "node_modules",
30
- "static",
31
- "dist"
32
- ]
33
- }