@solidrt/cli 0.0.21 → 0.0.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -22,12 +22,12 @@
22
22
  "qrcode-generator": "^2.0.4"
23
23
  },
24
24
  "optionalDependencies": {
25
- "@solidrt/darwin-arm64": "0.0.21",
26
- "@solidrt/linux-x64-gnu": "0.0.21",
27
- "@solidrt/win32-x64-msvc": "0.0.21"
25
+ "@solidrt/darwin-arm64": "0.0.22",
26
+ "@solidrt/linux-x64-gnu": "0.0.22",
27
+ "@solidrt/win32-x64-msvc": "0.0.22"
28
28
  },
29
29
  "peerDependencies": {
30
- "@solidrt/core": "0.0.21",
30
+ "@solidrt/core": "0.0.22",
31
31
  "typescript": "^6"
32
32
  },
33
33
  "devDependencies": {
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.21",
13
- "@solidrt/components": "0.0.21"
12
+ "@solidrt/core": "0.0.22",
13
+ "@solidrt/components": "0.0.22"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.21",
17
- "@solidrt/flux-types": "0.0.21",
16
+ "@solidrt/cli": "0.0.22",
17
+ "@solidrt/flux-types": "0.0.22",
18
18
  "typescript": "^6"
19
19
  }
20
20
  }
@@ -0,0 +1,485 @@
1
+ import {
2
+ render,
3
+ createSignal,
4
+ createEffect,
5
+ untrack,
6
+ env,
7
+ capabilities,
8
+ } from "@solidrt/core"
9
+ import {
10
+ policy,
11
+ setPolicy,
12
+ type DensityPolicy,
13
+ type MotionPolicy,
14
+ Window,
15
+ View,
16
+ Text,
17
+ Image,
18
+ Button,
19
+ TextInput,
20
+ Switch,
21
+ Checkbox,
22
+ RadioGroup,
23
+ Radio,
24
+ Slider,
25
+ QrCode,
26
+ Card,
27
+ Divider,
28
+ Badge,
29
+ Spinner,
30
+ ProgressBar,
31
+ Tooltip,
32
+ Select,
33
+ ContextMenu,
34
+ Icon,
35
+ ScrollView,
36
+ theme,
37
+ space,
38
+ typeWeight,
39
+ setTheme,
40
+ darkTheme,
41
+ lightTheme,
42
+ SafeArea,
43
+ } from "@solidrt/components"
44
+
45
+ import icon from "./icon.png" with { type: "binary" }
46
+
47
+ // A growing gallery of @solidrt/components. The whole thing recolors live when
48
+ // the theme switches: every component reads theme.* reactively, so pressing
49
+ // "Toggle theme" reflows the palette with no remount. The "Environment and
50
+ // policies" card shows the environment -> capabilities -> policies cascade and
51
+ // lets you force density/motion overrides to watch every control adapt.
52
+
53
+ // Lucide icons are SVG documents (strings). In a real app you import them as
54
+ // assets (`import House from "lucide-static/icons/house.svg"`) or from a string
55
+ // export; we inline a few here so the example stays self-contained. Each strokes
56
+ // with currentColor, so Icon recolors them from the theme.
57
+ const LUCIDE = (body: string) =>
58
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"` +
59
+ ` stroke="currentColor" stroke-width="2" stroke-linecap="round"` +
60
+ ` stroke-linejoin="round">${body}</svg>`
61
+ const HOUSE = LUCIDE(
62
+ `<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/>` +
63
+ `<path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>`,
64
+ )
65
+ const HEART = LUCIDE(
66
+ `<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/>`,
67
+ )
68
+ const STAR = LUCIDE(
69
+ `<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>`,
70
+ )
71
+ const SETTINGS = LUCIDE(
72
+ `<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/>` +
73
+ `<circle cx="12" cy="12" r="3"/>`,
74
+ )
75
+ const BELL = LUCIDE(
76
+ `<path d="M10.268 21a2 2 0 0 0 3.464 0"/>` +
77
+ `<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>`,
78
+ )
79
+
80
+ function Row(props: { label: string; children?: any }) {
81
+ return (
82
+ <View
83
+ layout={{
84
+ flexDirection: "row",
85
+ alignItems: "center",
86
+ justifyContent: "space-between",
87
+ gap: space("lg"),
88
+ }}
89
+ >
90
+ <Text muted>{props.label}</Text>
91
+ {props.children}
92
+ </View>
93
+ )
94
+ }
95
+
96
+ function Value(props: { children?: any }) {
97
+ return <Text>{props.children}</Text>
98
+ }
99
+
100
+ // Joins the names whose flag is true, e.g. list(["mouse", env.mouseSeen], ...).
101
+ function list(...entries: [string, boolean][]) {
102
+ let names = entries.filter(([, on]) => on).map(([name]) => name)
103
+ return names.length > 0 ? names.join(", ") : "none yet"
104
+ }
105
+
106
+ function App() {
107
+ let [dark, setDark] = createSignal(true)
108
+ let [name, setName] = createSignal("")
109
+ let [notify, setNotify] = createSignal(true)
110
+ let [agree, setAgree] = createSignal(false)
111
+ let [plan, setPlan] = createSignal<unknown>("pro")
112
+ let [volume, setVolume] = createSignal(40)
113
+ let [fruit, setFruit] = createSignal<unknown>(undefined)
114
+
115
+ // The QR code follows the name field debounced, not per keystroke: each data
116
+ // change regenerates the whole module grid, which is too heavy to run at
117
+ // typing frequency.
118
+ // untrack: seed with the current name once; the debounced effect below owns
119
+ // keeping it in sync, and a bare name() read here would warn (strict mode
120
+ // flags top-level reactive reads in component bodies as one-shot).
121
+ let [qrData, setQrData] = createSignal(untrack(name))
122
+ createEffect(
123
+ () => name(),
124
+ (v) => {
125
+ let timer = setTimeout(() => setQrData(v), 250)
126
+ return () => clearTimeout(timer)
127
+ },
128
+ )
129
+
130
+ // Follow the OS dark/light preference until the user toggles manually; the
131
+ // manual toggle then owns the choice for the rest of the session.
132
+ let userToggledTheme = false
133
+ let toggleTheme = () => {
134
+ userToggledTheme = true
135
+ let next = !dark()
136
+ setDark(next)
137
+ setTheme(next ? darkTheme : lightTheme)
138
+ }
139
+ createEffect(
140
+ () => env.systemTheme,
141
+ (t) => {
142
+ if (userToggledTheme || t === "unknown") return
143
+ let isDark = t === "dark"
144
+ setDark(isDark)
145
+ setTheme(isDark ? darkTheme : lightTheme)
146
+ },
147
+ )
148
+
149
+ // Policy override choices; "auto" hands the policy back to the resolver.
150
+ let [densityChoice, setDensityChoice] = createSignal<unknown>("auto")
151
+ let [motionChoice, setMotionChoice] = createSignal<unknown>("auto")
152
+ let chooseDensity = (v: unknown) => {
153
+ setDensityChoice(v)
154
+ setPolicy({ density: v === "auto" ? undefined : (v as DensityPolicy) })
155
+ }
156
+ let chooseMotion = (v: unknown) => {
157
+ setMotionChoice(v)
158
+ setPolicy({ motion: v === "auto" ? undefined : (v as MotionPolicy) })
159
+ }
160
+ let [textScaleChoice, setTextScaleChoice] = createSignal<unknown>("auto")
161
+ let chooseTextScale = (v: unknown) => {
162
+ setTextScaleChoice(v)
163
+ setPolicy({ textScale: v === "auto" ? undefined : (v as number) })
164
+ }
165
+ let [weightDeltaChoice, setWeightDeltaChoice] = createSignal<unknown>("auto")
166
+ let chooseWeightDelta = (v: unknown) => {
167
+ setWeightDeltaChoice(v)
168
+ setPolicy({ textWeightDelta: v === "auto" ? undefined : (v as number) })
169
+ }
170
+
171
+ return (
172
+ <Window
173
+ title="Components gallery"
174
+ layout={{ flexDirection: "column" }}
175
+ style={{ backgroundColor: theme.color.background }}
176
+ >
177
+ <SafeArea>
178
+ <ScrollView horizontal layout={{ flex: 1 }}>
179
+ <View
180
+ layout={{
181
+ height: "100%",
182
+ flexDirection: "column",
183
+ flexWrap: "wrap",
184
+ alignContent: "flex-start",
185
+ justifyContent: "flex-start",
186
+ gap: space("xl"),
187
+ padding: space("xl"),
188
+ }}
189
+ >
190
+ <Card title={dark() ? "Dark theme" : "Light theme"} layout={{ width: 360 }}>
191
+ <Text muted>Toggle to recolor every control below.</Text>
192
+ <Button onPress={toggleTheme}>Toggle theme</Button>
193
+ </Card>
194
+
195
+ <Card title="Environment" layout={{ width: 360 }}>
196
+ <Row label="Window">
197
+ <Value>
198
+ {`${env.windowSize.width} x ${env.windowSize.height} @ ${env.displayScale}x`}
199
+ </Value>
200
+ </Row>
201
+ <Row label="System">
202
+ <Value>{`${env.systemTheme} theme, ${env.orientation}`}</Value>
203
+ </Row>
204
+ <Row label="Devices">
205
+ <Value>
206
+ {env.inputDevices
207
+ ? list(
208
+ ["mouse", env.inputDevices.mouse],
209
+ ["touch", env.inputDevices.touch],
210
+ ["keyboard", env.inputDevices.keyboard],
211
+ )
212
+ : "not reported"}
213
+ </Value>
214
+ </Row>
215
+ <Row label="Inputs seen">
216
+ <Value>
217
+ {list(
218
+ ["mouse", env.mouseSeen],
219
+ ["touch", env.touchSeen],
220
+ ["keyboard", env.keyboardSeen],
221
+ )}
222
+ </Value>
223
+ </Row>
224
+ <Row label="Capabilities">
225
+ <Value>
226
+ {list(
227
+ ["hover", capabilities.hover],
228
+ ["touch", capabilities.touch],
229
+ ["keyboard nav", capabilities.keyboardNav],
230
+ )}
231
+ </Value>
232
+ </Row>
233
+ <Row label="Size class">
234
+ <Value>{capabilities.windowSizeClass}</Value>
235
+ </Row>
236
+ </Card>
237
+
238
+ <Card title="Policies" layout={{ width: 360 }}>
239
+ <Row label="Interaction policy">
240
+ <Value>{policy.interaction}</Value>
241
+ </Row>
242
+ <Row label="Focus ring">
243
+ <Value>{policy.focusRing ? "visible" : "hidden"}</Value>
244
+ </Row>
245
+ <Row label="App policies">
246
+ <Value>{`${policy.navigation}, ${policy.layout}`}</Value>
247
+ </Row>
248
+ <Divider />
249
+ <Row label="Density policy">
250
+ <Value>{policy.density}</Value>
251
+ </Row>
252
+ <RadioGroup
253
+ value={densityChoice()}
254
+ onChange={chooseDensity}
255
+ layout={{ flexDirection: "row", flexWrap: "wrap", gap: space("md") }}
256
+ >
257
+ <Radio value="auto">Auto</Radio>
258
+ <Radio value="comfortable">Comfortable</Radio>
259
+ <Radio value="compact">Compact</Radio>
260
+ <Radio value="dense">Dense</Radio>
261
+ </RadioGroup>
262
+ <Divider />
263
+ <Row label="Motion policy">
264
+ <Value>{policy.motion}</Value>
265
+ </Row>
266
+ <RadioGroup
267
+ value={motionChoice()}
268
+ onChange={chooseMotion}
269
+ layout={{ flexDirection: "row", flexWrap: "wrap", gap: space("md") }}
270
+ >
271
+ <Radio value="auto">Auto</Radio>
272
+ <Radio value="normal">Normal</Radio>
273
+ <Radio value="reduced">Reduced</Radio>
274
+ <Radio value="none">None</Radio>
275
+ </RadioGroup>
276
+ <Divider />
277
+ <Row label="Text scale">
278
+ <Value>{`${policy.textScale}x`}</Value>
279
+ </Row>
280
+ <RadioGroup
281
+ value={textScaleChoice()}
282
+ onChange={chooseTextScale}
283
+ layout={{ flexDirection: "row", flexWrap: "wrap", gap: space("md") }}
284
+ >
285
+ <Radio value="auto">Auto</Radio>
286
+ <Radio value={0.9}>0.9</Radio>
287
+ <Radio value={1.0}>1.0</Radio>
288
+ <Radio value={1.1}>1.1</Radio>
289
+ </RadioGroup>
290
+ <Divider />
291
+ <Row label="Text weight delta">
292
+ <Value>
293
+ {`base +${policy.textWeightDelta}, body ${typeWeight(
294
+ theme.text.body.weight,
295
+ theme.text.body.size * policy.textScale,
296
+ )}, title ${typeWeight(theme.text.title.weight, theme.text.title.size * policy.textScale)}`}
297
+ </Value>
298
+ </Row>
299
+ <RadioGroup
300
+ value={weightDeltaChoice()}
301
+ onChange={chooseWeightDelta}
302
+ layout={{ flexDirection: "row", flexWrap: "wrap", gap: space("md") }}
303
+ >
304
+ <Radio value="auto">Auto</Radio>
305
+ <Radio value={0}>0</Radio>
306
+ <Radio value={100}>+100</Radio>
307
+ <Radio value={200}>+200</Radio>
308
+ </RadioGroup>
309
+ </Card>
310
+
311
+ <Card title="Buttons" layout={{ width: 360 }}>
312
+ <View
313
+ layout={{
314
+ flexDirection: "row",
315
+ flexWrap: "wrap",
316
+ gap: space("md"),
317
+ alignItems: "center",
318
+ }}
319
+ >
320
+ <Button>Primary</Button>
321
+ <Button variant="secondary">Secondary</Button>
322
+ <Button variant="ghost">Ghost</Button>
323
+ <Button variant="danger">Danger</Button>
324
+ <Button disabled>Disabled</Button>
325
+ </View>
326
+ </Card>
327
+
328
+ <Card title="Text input" layout={{ width: 360 }}>
329
+ <TextInput
330
+ value={name()}
331
+ onInput={setName}
332
+ placeholder="Your name"
333
+ layout={{ width: 320 }}
334
+ />
335
+ </Card>
336
+
337
+ <Card title="Switch and checkbox" layout={{ width: 360 }}>
338
+ <Row label="Notifications">
339
+ <Switch value={notify()} onChange={setNotify} />
340
+ </Row>
341
+ <Row label="I agree to the terms">
342
+ <Checkbox checked={agree()} onChange={setAgree} />
343
+ </Row>
344
+ </Card>
345
+
346
+ <Card title="Radio group" layout={{ width: 360 }}>
347
+ <RadioGroup value={plan()} onChange={setPlan}>
348
+ <Radio value="free">Free</Radio>
349
+ <Radio value="pro">Pro</Radio>
350
+ <Radio value="team">Team</Radio>
351
+ </RadioGroup>
352
+ </Card>
353
+
354
+ <Card title="Context menu" layout={{ width: 360 }}>
355
+ <Text muted>
356
+ Right-click (mouse) or long-press (touch) the box below. Touch policy presents a
357
+ bottom sheet, desktop and hybrid a menu at the pointer.
358
+ </Text>
359
+ <ContextMenu
360
+ items={[
361
+ { label: "Copy", onSelect: () => console.log("copy") },
362
+ { label: "Paste", onSelect: () => console.log("paste") },
363
+ { label: "Rename", disabled: true },
364
+ { label: "Delete", onSelect: () => console.log("delete") },
365
+ ]}
366
+ >
367
+ <View
368
+ layout={{ padding: space("lg"), alignItems: "center" }}
369
+ style={{
370
+ backgroundColor: theme.color.surfaceAlt,
371
+ borderRadius: theme.radius.sm,
372
+ }}
373
+ >
374
+ <Text>Secondary actions live here</Text>
375
+ </View>
376
+ </ContextMenu>
377
+ </Card>
378
+
379
+ <Card title="Select" layout={{ width: 360 }}>
380
+ <Text muted>
381
+ Forks on the interaction policy: desktop and hybrid anchor a dropdown, touch opens a
382
+ bottom sheet.
383
+ </Text>
384
+ <Select
385
+ value={fruit()}
386
+ onChange={setFruit}
387
+ placeholder="Pick a fruit"
388
+ options={[
389
+ { value: "apple", label: "Apple" },
390
+ { value: "banana", label: "Banana" },
391
+ { value: "cherry", label: "Cherry" },
392
+ { value: "dragonfruit", label: "Dragonfruit" },
393
+ ]}
394
+ layout={{ width: 200 }}
395
+ />
396
+ </Card>
397
+
398
+ <Card title="Slider" layout={{ width: 360 }}>
399
+ <Row label={`Volume: ${Math.round(volume())}`}>
400
+ <Slider
401
+ value={volume()}
402
+ onChange={setVolume}
403
+ min={0}
404
+ max={100}
405
+ step={1}
406
+ layout={{ width: 180 }}
407
+ />
408
+ </Row>
409
+ </Card>
410
+
411
+ <Card title="Progress and spinner" layout={{ width: 360 }}>
412
+ <Row label="Loading">
413
+ <Spinner />
414
+ </Row>
415
+ <Text muted>Determinate, tracking the volume slider:</Text>
416
+ <ProgressBar value={volume() / 100} />
417
+ <Text muted>Indeterminate:</Text>
418
+ <ProgressBar />
419
+ </Card>
420
+
421
+ <Card title="Badges and divider" layout={{ width: 360 }}>
422
+ <Row label="Status">
423
+ <View layout={{ flexDirection: "row", gap: space("md"), alignItems: "center" }}>
424
+ <Badge>New</Badge>
425
+ <Badge variant="neutral">3</Badge>
426
+ <Badge variant="danger">Error</Badge>
427
+ </View>
428
+ </Row>
429
+ <Divider />
430
+ <Text muted>A divider separates content within a card.</Text>
431
+ </Card>
432
+
433
+ <Card title="Tooltip" layout={{ width: 360 }}>
434
+ <Text muted>
435
+ Rest the mouse on a button. Shows under desktop and hybrid interaction policies,
436
+ never under touch.
437
+ </Text>
438
+ <View layout={{ flexDirection: "row", gap: space("md") }}>
439
+ <Tooltip content="Saves your changes">
440
+ <Button>Hover me</Button>
441
+ </Tooltip>
442
+ <Tooltip content="Appears below the anchor" placement="bottom">
443
+ <Button>Below</Button>
444
+ </Tooltip>
445
+ </View>
446
+ </Card>
447
+
448
+ <Card title="Icons" layout={{ width: 360 }}>
449
+ <Text muted>
450
+ Lucide SVGs drawn through the core svg primitive. currentColor follows the theme;
451
+ the last two are recolored explicitly.
452
+ </Text>
453
+ <View layout={{ flexDirection: "row", gap: space("lg"), alignItems: "center" }}>
454
+ <Icon src={HOUSE} />
455
+ <Icon src={SETTINGS} />
456
+ <Icon src={BELL} />
457
+ <Icon src={HEART} color={theme.color.danger} />
458
+ <Icon src={STAR} color={theme.color.primary} />
459
+ </View>
460
+ </Card>
461
+
462
+ <Card title="QR code" layout={{ width: 360 }}>
463
+ <Text muted>Encodes your name, or a link if the field is empty.</Text>
464
+ <View layout={{ alignItems: "center" }}>
465
+ <QrCode data={qrData() || "https://solidjs.com"} />
466
+ </View>
467
+ </Card>
468
+
469
+ <Card title="Image" layout={{ width: 360 }}>
470
+ <Text muted>
471
+ Fetches, decodes, and uploads an image, then shows it as a GPU texture. Rounded via
472
+ a clipping border radius.
473
+ </Text>
474
+ <View layout={{ alignItems: "center" }}>
475
+ <Image src={icon} style={{ borderRadius: theme.radius.md }} />
476
+ </View>
477
+ </Card>
478
+ </View>
479
+ </ScrollView>
480
+ </SafeArea>
481
+ </Window>
482
+ )
483
+ }
484
+
485
+ render(() => <App />)
package/src/args.ts CHANGED
@@ -16,6 +16,7 @@ export let { values, positionals } = parseArgs({
16
16
  stats: { type: "boolean", default: false },
17
17
  android: { type: "boolean", default: false },
18
18
  device: { type: "string" },
19
+ template: { type: "string", short: "t" },
19
20
  },
20
21
  allowPositionals: true,
21
22
  })
@@ -35,9 +36,6 @@ function usage(line: string): never {
35
36
  // Per-command argument requirements. Called once before dispatch.
36
37
  export function validateArgs() {
37
38
  switch (command) {
38
- case "init":
39
- if (!source) usage("srt init <dir> (the target folder is required)")
40
- break
41
39
  case "bundle":
42
40
  if (values.flux) {
43
41
  if (!source || !isTs) usage("srt bundle --flux [options] <entry.[ts|js]>")
@@ -76,6 +74,9 @@ Commands:
76
74
  record <file.tsx|jsx> Capture frames for video generation
77
75
  pack <file> Bundle + compile to a standalone executable (experimental)
78
76
 
77
+ init options:
78
+ -t, --template <name> Start from a named template (skips the interactive picker)
79
+
79
80
  run/server options:
80
81
  --proxy-files Route file/dir access through the dev server
81
82
  --proxy-http Route fetch calls through the dev server (HTTP cache enabled)
@@ -1,18 +1,22 @@
1
- import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"
1
+ import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"
2
2
  import { basename, dirname, join, resolve } from "node:path"
3
- import { source } from "../args"
3
+ import { source, values } from "../args"
4
+ import { select, text } from "../prompt"
5
+
6
+ const DEFAULT_NAME = "solidrt-app"
4
7
 
5
8
  const SCAFFOLD_DIR = join(import.meta.dir, "../../scaffold")
9
+ const TEMPLATES_DIR = join(SCAFFOLD_DIR, "templates")
6
10
 
7
- // What gets written into a new project. Sources live in cli/scaffold/. The
8
- // .gitignore is stored there as `gitignore` because npm strips files literally
9
- // named `.gitignore` from published packages, so it is renamed on the way out.
11
+ // Shared project files written for every template. Sources live in
12
+ // cli/scaffold/. The .gitignore is stored there as `gitignore` because npm
13
+ // strips files literally named `.gitignore` from published packages, so it is
14
+ // renamed on the way out. The per-template src/ comes from scaffold/templates/.
10
15
  const TEMPLATE_FILES: Array<{ from: string; to: string }> = [
11
16
  { from: "package.json", to: "package.json" },
12
17
  { from: "tsconfig.json", to: "tsconfig.json" },
13
18
  { from: "gitignore", to: ".gitignore" },
14
19
  { from: "AGENTS.md", to: "AGENTS.md" },
15
- { from: "src/index.tsx", to: "src/index.tsx" },
16
20
  ]
17
21
 
18
22
  // A valid npm package name derived from the target directory.
@@ -23,17 +27,63 @@ function packageName(dir: string): string {
23
27
  return name || "solidrt-app"
24
28
  }
25
29
 
30
+ const DEFAULT_TEMPLATE = "default"
31
+
32
+ // Templates are the directories under scaffold/templates/; each holds the files
33
+ // that become the new project's src/. `default` sorts first as the starting
34
+ // point, the rest alphabetically.
35
+ async function listTemplates(): Promise<string[]> {
36
+ let entries = await readdir(TEMPLATES_DIR, { withFileTypes: true })
37
+ return entries
38
+ .filter((e) => e.isDirectory())
39
+ .map((e) => e.name)
40
+ .sort((a, b) =>
41
+ a === DEFAULT_TEMPLATE ? -1 : b === DEFAULT_TEMPLATE ? 1 : a.localeCompare(b),
42
+ )
43
+ }
44
+
45
+ // Resolve which template to scaffold from: an explicit --template if valid, an
46
+ // interactive picker on a TTY, else `default` (or the first available).
47
+ async function resolveTemplate(): Promise<string> {
48
+ let templates = await listTemplates()
49
+ if (templates.length === 0) {
50
+ console.error(`!! No templates found in ${TEMPLATES_DIR}`)
51
+ process.exit(1)
52
+ }
53
+ let chosen = values.template
54
+ if (chosen) {
55
+ if (!templates.includes(chosen)) {
56
+ console.error(`!! Unknown template "${chosen}"; choose from: ${templates.join(", ")}`)
57
+ process.exit(1)
58
+ }
59
+ return chosen
60
+ }
61
+ if (process.stdin.isTTY) return select("Select a template", templates)
62
+ return templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0]!
63
+ }
64
+
26
65
  export async function runInitCommand() {
27
- // The target folder is required (validateArgs enforces it) and must be empty
28
- // or absent, so init can never overwrite files in an existing project.
29
- let dir = source!
66
+ // The target folder comes from the positional arg, or an interactive prompt
67
+ // (defaulting to a suggested name) when omitted.
68
+ let dir = source
69
+ if (!dir) {
70
+ dir = await text("Project name", DEFAULT_NAME)
71
+ if (!dir) {
72
+ console.error("!! A project name is required")
73
+ process.exit(1)
74
+ }
75
+ }
76
+
77
+ // The folder must not exist yet, so init can never touch an existing project.
30
78
  let existing = await readdir(dir).catch(() => null)
31
- if (existing && existing.length > 0) {
32
- console.error(`!! ${resolve(dir)} already exists and is not empty; choose a new folder name`)
79
+ if (existing) {
80
+ console.error(`!! ${resolve(dir)} already exists; choose a new folder name`)
33
81
  process.exit(1)
34
82
  }
35
83
 
36
- console.log(`>> Scaffolding SolidRT project in ${resolve(dir)}`)
84
+ let template = await resolveTemplate()
85
+
86
+ console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template})`)
37
87
  for (let { from, to } of TEMPLATE_FILES) {
38
88
  let dest = join(dir, to)
39
89
  await mkdir(dirname(dest), { recursive: true })
@@ -41,6 +91,15 @@ export async function runInitCommand() {
41
91
  console.log(` Write ${to}`)
42
92
  }
43
93
 
94
+ // The chosen template's files become the project's src/. Entries may be
95
+ // nested directories (e.g. an asset folder), so copy recursively.
96
+ let templateDir = join(TEMPLATES_DIR, template)
97
+ await mkdir(join(dir, "src"), { recursive: true })
98
+ for (let file of await readdir(templateDir)) {
99
+ await cp(join(templateDir, file), join(dir, "src", file), { recursive: true })
100
+ console.log(` Write src/${file}`)
101
+ }
102
+
44
103
  // The scaffold package.json carries a placeholder name; set it from the
45
104
  // target folder.
46
105
  let pkgPath = join(dir, "package.json")
package/src/prompt.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { createInterface, emitKeypressEvents } from "node:readline"
2
+
3
+ // Single-line text prompt with an optional default (shown in parentheses, used
4
+ // when the answer is blank). Non-TTY stdin resolves the default rather than
5
+ // blocking on input that will never arrive.
6
+ export function text(message: string, def = ""): Promise<string> {
7
+ return new Promise<string>((resolve) => {
8
+ if (!process.stdin.isTTY) return resolve(def)
9
+ let rl = createInterface({ input: process.stdin, output: process.stdout })
10
+ let suffix = def ? ` (${def})` : ""
11
+ rl.question(`? ${message}${suffix}: `, (answer) => {
12
+ rl.close()
13
+ resolve(answer.trim() || def)
14
+ })
15
+ })
16
+ }
17
+
18
+ // Minimal arrow-key single-select prompt, built on node:readline (same
19
+ // dependency-free approach as repl.ts). Renders the option list, moves the
20
+ // highlight on up/down, resolves the chosen value on enter. Callers guard on
21
+ // process.stdin.isTTY; a non-TTY stdin here resolves the first option rather
22
+ // than hanging on input that will never arrive.
23
+ export function select(message: string, options: string[]): Promise<string> {
24
+ return new Promise((resolve) => {
25
+ let input = process.stdin
26
+ let output = process.stdout
27
+ if (!input.isTTY) return resolve(options[0]!)
28
+
29
+ let selected = 0
30
+ emitKeypressEvents(input)
31
+ let wasRaw = input.isRaw
32
+ input.setRawMode(true)
33
+
34
+ let render = (first = false) => {
35
+ // After the first paint the cursor sits below the block; move it back up
36
+ // to the message line so the list redraws in place.
37
+ if (!first) output.write(`\x1b[${options.length + 1}A`)
38
+ output.write(`\x1b[K? ${message}\n`)
39
+ for (let i = 0; i < options.length; i++) {
40
+ let active = i === selected
41
+ let pointer = active ? "\x1b[36m> " : " "
42
+ let reset = active ? "\x1b[0m" : ""
43
+ output.write(`\x1b[K${pointer}${options[i]}${reset}\n`)
44
+ }
45
+ }
46
+
47
+ let cleanup = () => {
48
+ input.off("keypress", onKey)
49
+ input.setRawMode(wasRaw)
50
+ input.pause()
51
+ }
52
+
53
+ let onKey = (_str: string, key: { name: string; ctrl: boolean } | undefined) => {
54
+ if (!key) return
55
+ if (key.name === "up") {
56
+ selected = (selected - 1 + options.length) % options.length
57
+ render()
58
+ } else if (key.name === "down") {
59
+ selected = (selected + 1) % options.length
60
+ render()
61
+ } else if (key.name === "return" || key.name === "enter") {
62
+ cleanup()
63
+ output.write("\n")
64
+ resolve(options[selected]!)
65
+ } else if (key.ctrl && (key.name === "c" || key.name === "d")) {
66
+ cleanup()
67
+ output.write("\n")
68
+ process.exit(130)
69
+ }
70
+ }
71
+
72
+ input.on("keypress", onKey)
73
+ input.resume()
74
+ render(true)
75
+ })
76
+ }
File without changes