@solidrt/cli 0.0.26 → 0.0.28
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 +10 -7
- package/scaffold/AGENTS.md +138 -9
- package/scaffold/package.json +4 -4
- package/scaffold/templates/components/index.tsx +15 -0
- package/scaffold/templates/components/template.json +4 -0
- package/scaffold/templates/default/index.tsx +7 -9
- package/scaffold/templates/default/template.json +4 -0
- package/scaffold/templates/gallery/template.json +4 -0
- package/scaffold/templates/minimal/index.tsx +5 -2
- package/scaffold/templates/minimal/template.json +4 -0
- package/scaffold/tsconfig.json +1 -0
- package/server/control.ts +132 -16
- package/server/main.ts +13 -4
- package/server/rebuild.ts +54 -0
- package/server/remap.ts +47 -0
- package/server/state.ts +24 -1
- package/src/args.ts +4 -0
- package/src/bundle-cli.ts +12 -0
- package/src/bundler.ts +69 -23
- package/src/commands/bundle.ts +4 -7
- package/src/commands/check.ts +94 -0
- package/src/commands/init.ts +53 -14
- package/src/commands/mcp.ts +205 -66
- package/src/commands/server.ts +4 -3
- package/src/dev-server.ts +15 -2
- package/src/main.ts +3 -0
- package/src/prompt.ts +19 -11
- package/src/repl.ts +17 -7
- package/src/util.ts +3 -0
- package/src/watcher.ts +4 -3
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { command } from "flux:subprocess"
|
|
2
|
+
import { state } from "./state"
|
|
3
|
+
|
|
4
|
+
// Server-owned "rebuild and push": the single place the running app is rebuilt
|
|
5
|
+
// from source on demand (an MCP reload). The srt repl still bundles in-process
|
|
6
|
+
// for its own keystroke reloads, but both routes call the same bundle-cli, so
|
|
7
|
+
// the bundling logic cannot drift. Making the server the rebuild authority is
|
|
8
|
+
// the interim step toward folding the whole CLI into flux (see
|
|
9
|
+
// okf/backlog/cli-flux-migration.md).
|
|
10
|
+
|
|
11
|
+
// Build the reload message the same way srt's buildReload does, so a
|
|
12
|
+
// server-triggered reload is indistinguishable from a repl-triggered one to
|
|
13
|
+
// clients. proxyFiles/proxyHttp are message flags, not build inputs.
|
|
14
|
+
function buildReload(code: string) {
|
|
15
|
+
let config = state.config
|
|
16
|
+
return { type: "reload", proxyFiles: config.proxyFiles, proxyHttp: config.proxyHttp, code }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Rebuild from state.config.entry via the external bundle-cli subprocess, then
|
|
20
|
+
// latch (for late-joining clients) and broadcast the reload to every connected
|
|
21
|
+
// client. Resolves with an error message on failure (no entry configured, or a
|
|
22
|
+
// build error), or null on success.
|
|
23
|
+
export async function rebuildAndBroadcast(): Promise<string | null> {
|
|
24
|
+
let config = state.config
|
|
25
|
+
if (!config.entry) {
|
|
26
|
+
return "No app entry to rebuild. Start srt with a source file (srt run src/index.tsx)."
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let params = JSON.stringify({
|
|
30
|
+
entry: config.entry,
|
|
31
|
+
devBase: state.serverUrl,
|
|
32
|
+
dev: true,
|
|
33
|
+
minify: config.minify,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
let result = await command(config.bundlerCmd[0]!, [...config.bundlerCmd.slice(1), params]).output()
|
|
37
|
+
if (!result.success) {
|
|
38
|
+
let stderr = typeof result.stderr === "string" ? result.stderr : ""
|
|
39
|
+
return `Rebuild failed:\n${stderr.trim()}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// bundle-cli writes one JSON object { code, map } to stdout.
|
|
43
|
+
let bundle: { code?: string; map?: string | null }
|
|
44
|
+
try {
|
|
45
|
+
bundle = JSON.parse(typeof result.stdout === "string" ? result.stdout : "")
|
|
46
|
+
} catch {
|
|
47
|
+
return "Rebuild failed: unreadable bundler output"
|
|
48
|
+
}
|
|
49
|
+
state.currentMap = bundle.map ?? null
|
|
50
|
+
let text = JSON.stringify(buildReload(bundle.code ?? ""))
|
|
51
|
+
state.currentReload = text
|
|
52
|
+
for (let ws of state.clients.keys()) ws.send(text)
|
|
53
|
+
return null
|
|
54
|
+
}
|
package/server/remap.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"
|
|
2
|
+
|
|
3
|
+
// Stack-trace remapping for forwarded client logs. The runtime evaluates the
|
|
4
|
+
// bundle as module "main", so QuickJS frames cite bundle positions like
|
|
5
|
+
// "at boom (main:212:9)". With the current reload's sourcemap latched on the
|
|
6
|
+
// server (state.currentMap), those positions are rewritten to the original
|
|
7
|
+
// .tsx sources before a log entry is buffered.
|
|
8
|
+
|
|
9
|
+
// The parsed map is cached per map text; a reload swaps the text and the next
|
|
10
|
+
// lookup rebuilds the tracer.
|
|
11
|
+
let cachedText: string | null = null
|
|
12
|
+
let tracer: TraceMap | null = null
|
|
13
|
+
|
|
14
|
+
function tracerFor(map: string | null): TraceMap | null {
|
|
15
|
+
if (map !== cachedText) {
|
|
16
|
+
cachedText = map
|
|
17
|
+
tracer = null
|
|
18
|
+
if (map) {
|
|
19
|
+
try {
|
|
20
|
+
tracer = new TraceMap(JSON.parse(map))
|
|
21
|
+
} catch {
|
|
22
|
+
// A malformed map disables remapping until the next reload.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return tracer
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rewrite every "main:LINE:COL" (or "main:LINE") position in `text` to its
|
|
31
|
+
* original source position, e.g. "src/app.tsx:42:7". Positions the map has no
|
|
32
|
+
* entry for, and all text when `map` is null, pass through unchanged.
|
|
33
|
+
* QuickJS lines and columns are 1-based; sourcemap columns are 0-based.
|
|
34
|
+
*/
|
|
35
|
+
export function remapPositions(text: string, map: string | null): string {
|
|
36
|
+
if (!map || !text.includes("main:")) return text
|
|
37
|
+
let t = tracerFor(map)
|
|
38
|
+
if (!t) return text
|
|
39
|
+
return text.replace(/\bmain:(\d+)(?::(\d+))?\b/g, (frame, line, column) => {
|
|
40
|
+
let pos = originalPositionFor(t, {
|
|
41
|
+
line: parseInt(line, 10),
|
|
42
|
+
column: column ? Math.max(parseInt(column, 10) - 1, 0) : 0,
|
|
43
|
+
})
|
|
44
|
+
if (pos.source == null || pos.line == null) return frame
|
|
45
|
+
return `${pos.source}:${pos.line}:${(pos.column ?? 0) + 1}`
|
|
46
|
+
})
|
|
47
|
+
}
|
package/server/state.ts
CHANGED
|
@@ -11,6 +11,15 @@ export type Config = {
|
|
|
11
11
|
address: string
|
|
12
12
|
proxyFiles: boolean
|
|
13
13
|
proxyHttp: boolean
|
|
14
|
+
/** The app entry (absolute .tsx/.jsx path) the server rebuilds on an
|
|
15
|
+
* MCP-triggered reload, or undefined when srt was started without a source.
|
|
16
|
+
* Moved by the repl `load` command via /__internal__/reload. */
|
|
17
|
+
entry?: string
|
|
18
|
+
/** Minify the rebuild output, mirroring the srt --minify flag. */
|
|
19
|
+
minify: boolean
|
|
20
|
+
/** How the server invokes the external bundler: [bunPath, bundleCliPath],
|
|
21
|
+
* spawned with a JSON params argument appended (see rebuild.ts). */
|
|
22
|
+
bundlerCmd: string[]
|
|
14
23
|
/** Enable the sqlite-backed proxy cache. */
|
|
15
24
|
cache: boolean
|
|
16
25
|
/** Directory holding .srt-cache.db. */
|
|
@@ -22,17 +31,31 @@ export type Config = {
|
|
|
22
31
|
tunnel: boolean
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
export type ClientInfo = { platform: string; version: string; id: number; capabilities: string[] }
|
|
34
|
+
export type ClientInfo = { platform: string; version: string; profile: string; id: number; capabilities: string[] }
|
|
26
35
|
|
|
27
36
|
export let state = {
|
|
28
37
|
config: undefined as unknown as Config,
|
|
29
38
|
clients: new Map<ServerWebSocket, ClientInfo>(),
|
|
30
39
|
nextClientId: 0,
|
|
40
|
+
/**
|
|
41
|
+
* Identity of this server run, included in control responses that carry
|
|
42
|
+
* cross-call state (client ids, log seq cursors). Both reset on restart, so
|
|
43
|
+
* a consumer that sees the generation change knows its ids and cursors are
|
|
44
|
+
* from a dead server and must be re-fetched.
|
|
45
|
+
*/
|
|
46
|
+
generation: Date.now(),
|
|
31
47
|
/**
|
|
32
48
|
* The latched reload message (JSON text), replayed to late-joining clients.
|
|
33
49
|
* Set by /__internal__/reload posts with `latch`, cleared by a broadcast stop.
|
|
34
50
|
*/
|
|
35
51
|
currentReload: null as string | null,
|
|
52
|
+
/**
|
|
53
|
+
* The running bundle's sourcemap (JSON text, bundle -> .tsx sources), used
|
|
54
|
+
* to remap stack traces in forwarded client logs (see control.ts). Replaced
|
|
55
|
+
* on every reload; a reload without a map clears it so frames are never
|
|
56
|
+
* remapped against a stale map.
|
|
57
|
+
*/
|
|
58
|
+
currentMap: null as string | null,
|
|
36
59
|
sourceDir: "",
|
|
37
60
|
serverUrl: "",
|
|
38
61
|
stats: false,
|
package/src/args.ts
CHANGED
|
@@ -46,6 +46,9 @@ export function validateArgs() {
|
|
|
46
46
|
usage("srt bundle [options] <entry.[tsx|jsx|ts|js|srt.js|srt.bin]>")
|
|
47
47
|
}
|
|
48
48
|
break
|
|
49
|
+
case "check":
|
|
50
|
+
if (!source || !isSource) usage("srt check <entry.[tsx|jsx|ts|js]>")
|
|
51
|
+
break
|
|
49
52
|
case "render":
|
|
50
53
|
if (!source || !isTsx) usage("srt render <entry.[tsx|jsx]>")
|
|
51
54
|
break
|
|
@@ -74,6 +77,7 @@ Commands:
|
|
|
74
77
|
server [file] Start dev server only
|
|
75
78
|
client Start solidrt-go client only
|
|
76
79
|
bundle <file> Transpile TS/JS/TSX/JSX to JS or bytecode
|
|
80
|
+
check <file> Verify the app builds and typechecks, without writing anything
|
|
77
81
|
render <file.tsx|jsx> Replay a script (optional) and render frames for video generation
|
|
78
82
|
pack <file> Bundle + compile to a standalone executable (experimental)
|
|
79
83
|
mcp MCP server (stdio) exposing the running dev server to coding agents
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Standalone bundler entry, spawned by the dev server (a flux process) as a
|
|
2
|
+
// Bun subprocess to rebuild the app on an MCP-triggered reload. flux cannot call
|
|
3
|
+
// Bun.build, so the server shells out to this. Params arrive as one JSON
|
|
4
|
+
// argument; one JSON object { code, map } goes to stdout and diagnostics to
|
|
5
|
+
// stderr. On a build failure it exits non-zero with an empty stdout.
|
|
6
|
+
|
|
7
|
+
import { bundleWith, type BundleOptions } from "./bundler"
|
|
8
|
+
|
|
9
|
+
let params = JSON.parse(process.argv[2] ?? "{}") as BundleOptions
|
|
10
|
+
let result = await bundleWith(params)
|
|
11
|
+
if (!result) process.exit(1)
|
|
12
|
+
process.stdout.write(JSON.stringify(result))
|
package/src/bundler.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { transformAsync } from "@babel/core"
|
|
2
2
|
import jsx from "@babel/plugin-syntax-jsx"
|
|
3
3
|
import ts from "@babel/preset-typescript"
|
|
4
|
+
import remapping from "@jridgewell/remapping"
|
|
4
5
|
import solid from "babel-preset-solid"
|
|
5
6
|
import { type BunPlugin, type BuildArtifact } from "bun"
|
|
6
7
|
import { readFileSync } from "node:fs"
|
|
@@ -53,7 +54,7 @@ function binaryImport({ types: t }: { types: any }) {
|
|
|
53
54
|
// build, skipping emitted asset outputs. Bun's file loader emits binary assets
|
|
54
55
|
// as extra outputs; the callers that flatten outputs into a single code string
|
|
55
56
|
// must not glue those raw bytes onto the program.
|
|
56
|
-
|
|
57
|
+
async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string> {
|
|
57
58
|
let code = ""
|
|
58
59
|
for (let o of outputs) {
|
|
59
60
|
if (o.kind === "entry-point" || o.kind === "chunk") code += await o.text()
|
|
@@ -62,61 +63,106 @@ export async function codeFromOutputs(outputs: BuildArtifact[]): Promise<string>
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// Bun build plugin that runs JSX/TSX through babel-preset-solid (universal
|
|
65
|
-
// generate, targeting @solidrt/core) plus the TS preset.
|
|
66
|
-
|
|
66
|
+
// generate, targeting @solidrt/core) plus the TS preset. Plain .js/.ts app
|
|
67
|
+
// modules take the same path (solid is a no-op without JSX) so binaryImport
|
|
68
|
+
// can rewrite their `with { type: "binary" }` imports too; dependency code
|
|
69
|
+
// (node_modules) skips the babel detour and keeps Bun's native loaders.
|
|
70
|
+
// With `babelMaps`, each file's transform map (original -> babel output) is
|
|
71
|
+
// collected there, keyed by absolute path, for sourcemap composition later.
|
|
72
|
+
function solidPlugin(babelMaps?: Map<string, object>): BunPlugin {
|
|
67
73
|
return {
|
|
68
74
|
name: "bun-plugin-solid",
|
|
69
75
|
setup: (build) => {
|
|
70
|
-
build.onLoad({ filter: /\.(js|ts)x
|
|
76
|
+
build.onLoad({ filter: /\.(js|ts)x?$/ }, async (args) => {
|
|
77
|
+
if (!/\.(js|ts)x$/.test(args.path) && args.path.includes("node_modules")) return
|
|
71
78
|
let file = Bun.file(args.path)
|
|
72
79
|
let code = await file.text()
|
|
73
80
|
let transforms = await transformAsync(code, {
|
|
74
81
|
filename: args.path,
|
|
82
|
+
sourceMaps: !!babelMaps,
|
|
75
83
|
presets: [[solid, { moduleName: "@solidrt/core", generate: "universal" }], [ts]],
|
|
76
84
|
plugins: [jsx, binaryImport],
|
|
77
85
|
})
|
|
86
|
+
if (babelMaps && transforms?.map) babelMaps.set(args.path, transforms.map)
|
|
78
87
|
return { contents: transforms?.code ?? "", loader: "js" }
|
|
79
88
|
})
|
|
80
89
|
},
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
export
|
|
85
|
-
let result = null
|
|
93
|
+
export type BundleOptions = { entry: string; devBase?: string; dev: boolean; minify: boolean }
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
export type BundleResult = {
|
|
96
|
+
code: string
|
|
97
|
+
/** Composed sourcemap JSON (bundle -> original .tsx sources), dev builds only. */
|
|
98
|
+
map: string | null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The pure bundle: every input is explicit, so it runs identically in the srt
|
|
102
|
+
// (Bun) process and in the standalone bundle-cli subprocess the dev server
|
|
103
|
+
// spawns. It never touches the ambient args/state singletons and never prints
|
|
104
|
+
// progress (callers own that), so its stdout stays clean for subprocess use.
|
|
105
|
+
export async function bundleWith(opts: BundleOptions): Promise<BundleResult | null> {
|
|
91
106
|
let define: Record<string, string> = {
|
|
92
|
-
"process.env.NODE_ENV": dev ? "development" : "production",
|
|
107
|
+
"process.env.NODE_ENV": opts.dev ? "development" : "production",
|
|
93
108
|
}
|
|
94
|
-
if (devBase) define.__SRT_DEV_BASE__ = devBase
|
|
109
|
+
if (opts.devBase) define.__SRT_DEV_BASE__ = opts.devBase
|
|
95
110
|
|
|
111
|
+
let babelMaps = opts.dev ? new Map<string, object>() : undefined
|
|
112
|
+
let result = null
|
|
96
113
|
try {
|
|
97
114
|
result = await Bun.build({
|
|
98
|
-
entrypoints: [entry
|
|
115
|
+
entrypoints: [opts.entry],
|
|
99
116
|
target: "browser",
|
|
100
117
|
format: "esm",
|
|
101
|
-
minify:
|
|
118
|
+
minify: opts.minify,
|
|
102
119
|
external: ["flux:*", "srt:*"],
|
|
103
120
|
define,
|
|
104
121
|
loader: { ".svg": "text" },
|
|
105
|
-
|
|
122
|
+
sourcemap: opts.dev ? "external" : "none",
|
|
123
|
+
plugins: [solidPlugin(babelMaps)],
|
|
106
124
|
})
|
|
107
125
|
} catch (e) {
|
|
108
126
|
console.error("[cli] compile error:\n", e)
|
|
109
127
|
return null
|
|
110
128
|
}
|
|
111
129
|
|
|
112
|
-
if (result
|
|
113
|
-
|
|
130
|
+
if (!result.success) {
|
|
131
|
+
for (let msg of result.logs) console.error(msg)
|
|
132
|
+
return null
|
|
114
133
|
}
|
|
115
134
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
return { code: await codeFromOutputs(result.outputs), map: await composeMap(result.outputs, babelMaps) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Compose Bun's bundle map (babel output -> bundle) with the per-file Babel
|
|
139
|
+
// maps (original source -> babel output) so positions point at the .tsx
|
|
140
|
+
// sources. Bun ignores sourcemaps in plugin onLoad contents, so this second
|
|
141
|
+
// hop has to happen here. Only a single-artifact build gets a map: code
|
|
142
|
+
// splitting is off, and concatenated artifacts would invalidate offsets.
|
|
143
|
+
async function composeMap(outputs: BuildArtifact[], babelMaps?: Map<string, object>): Promise<string | null> {
|
|
144
|
+
if (!babelMaps) return null
|
|
145
|
+
let js = outputs.filter((o) => o.kind === "entry-point" || o.kind === "chunk")
|
|
146
|
+
if (js.length !== 1 || !js[0]!.sourcemap) return null
|
|
147
|
+
let bunMap = JSON.parse(await js[0]!.sourcemap.text())
|
|
148
|
+
let composed = remapping(bunMap, (file: string) => {
|
|
149
|
+
// Bun writes cwd-relative source paths; the babel maps are keyed by the
|
|
150
|
+
// absolute path. Serve each map exactly once: remapping asks again for a
|
|
151
|
+
// served map's own original source, and that lookup must return null.
|
|
152
|
+
let abs = resolvePath(file)
|
|
153
|
+
let map = babelMaps.get(abs)
|
|
154
|
+
babelMaps.delete(abs)
|
|
155
|
+
return (map as any) ?? null
|
|
156
|
+
})
|
|
157
|
+
return composed.toString()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function bundle(entry = source) {
|
|
161
|
+
let devBase = state.serverUrl ?? undefined
|
|
162
|
+
let dev = !!devBase || values.dev
|
|
163
|
+
// Keep stdout clean when the bundle itself is written to stdout.
|
|
164
|
+
if (!values.stdout) print(`[cli] Bundling (${dev ? "development" : "production"})`)
|
|
165
|
+
return bundleWith({ entry: entry!, devBase, dev, minify: values.minify })
|
|
120
166
|
}
|
|
121
167
|
|
|
122
168
|
export async function bundleTo(outfile: string) {
|
|
@@ -125,7 +171,7 @@ export async function bundleTo(outfile: string) {
|
|
|
125
171
|
console.error("Build failed")
|
|
126
172
|
process.exit(1)
|
|
127
173
|
}
|
|
128
|
-
await Bun.write(outfile,
|
|
174
|
+
await Bun.write(outfile, result.code)
|
|
129
175
|
return result
|
|
130
176
|
}
|
|
131
177
|
|
|
@@ -153,7 +199,7 @@ export async function bundleSolid(): Promise<string> {
|
|
|
153
199
|
console.error("Build failed")
|
|
154
200
|
process.exit(1)
|
|
155
201
|
}
|
|
156
|
-
return
|
|
202
|
+
return result.code
|
|
157
203
|
}
|
|
158
204
|
|
|
159
205
|
// Compile JS source to QuickJS bytecode via the fluxc binary.
|
package/src/commands/bundle.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { values, source, isPrebuilt } from "../args"
|
|
2
|
-
import { bundle, bundleTo, bundleFlux, compileToBytecode
|
|
2
|
+
import { bundle, bundleTo, bundleFlux, compileToBytecode } from "../bundler"
|
|
3
3
|
import { resolve } from "path"
|
|
4
4
|
|
|
5
5
|
// Write to stdout and resolve only once the whole payload is flushed.
|
|
@@ -55,7 +55,7 @@ export async function runBundleCommand() {
|
|
|
55
55
|
console.error("Build failed")
|
|
56
56
|
process.exit(1)
|
|
57
57
|
}
|
|
58
|
-
await writeStdout(
|
|
58
|
+
await writeStdout(result.code)
|
|
59
59
|
process.exit()
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -65,15 +65,12 @@ export async function runBundleCommand() {
|
|
|
65
65
|
console.error("Build failed")
|
|
66
66
|
process.exit(1)
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
await writeBytecode(jsCode, baseName + ".srt.bin")
|
|
68
|
+
await writeBytecode(result.code, baseName + ".srt.bin")
|
|
70
69
|
process.exit()
|
|
71
70
|
}
|
|
72
71
|
|
|
73
72
|
let jsOutfile = baseName + ".srt.js"
|
|
74
73
|
let result = await bundleTo(jsOutfile)
|
|
75
|
-
|
|
76
|
-
console.log(`>> wrote ${output.size} bytes to ${jsOutfile}`)
|
|
77
|
-
}
|
|
74
|
+
console.log(`>> wrote ${result.code.length} bytes to ${jsOutfile}`)
|
|
78
75
|
process.exit()
|
|
79
76
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
import { source } from "../args"
|
|
4
|
+
import { bundleWith } from "../bundler"
|
|
5
|
+
|
|
6
|
+
// srt check: verify the app without side effects. Bundles in memory (nothing
|
|
7
|
+
// written, so no dev-server reload fires and no build outputs land in the
|
|
8
|
+
// project) and typechecks with the project's own tsc, reporting only
|
|
9
|
+
// diagnostics in app code. @solidrt packages ship raw .ts sources, so a strict
|
|
10
|
+
// consumer config surfaces their internal errors too; those are counted and
|
|
11
|
+
// hidden, not the caller's problem to wade through.
|
|
12
|
+
|
|
13
|
+
// Walk up from the entry to the enclosing project (tsconfig.json or, failing
|
|
14
|
+
// that, package.json).
|
|
15
|
+
function findProjectRoot(entry: string): string | null {
|
|
16
|
+
let dir = dirname(resolve(entry))
|
|
17
|
+
let byConfig: string | null = null
|
|
18
|
+
let byPackage: string | null = null
|
|
19
|
+
while (true) {
|
|
20
|
+
if (!byConfig && existsSync(join(dir, "tsconfig.json"))) byConfig = dir
|
|
21
|
+
if (!byPackage && existsSync(join(dir, "package.json"))) byPackage = dir
|
|
22
|
+
let parent = dirname(dir)
|
|
23
|
+
if (parent === dir) return byConfig ?? byPackage
|
|
24
|
+
dir = parent
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One tsc --pretty false diagnostic: the "path(line,col): error TS...: ..."
|
|
29
|
+
// head line plus any indented continuation lines.
|
|
30
|
+
type Diagnostic = { head: string; lines: string[]; inDependencies: boolean }
|
|
31
|
+
|
|
32
|
+
function parseDiagnostics(output: string): Diagnostic[] {
|
|
33
|
+
let diagnostics: Diagnostic[] = []
|
|
34
|
+
let current: Diagnostic | null = null
|
|
35
|
+
for (let line of output.split("\n")) {
|
|
36
|
+
let head = /^(.*?)\(\d+,\d+\): (error|warning) TS\d+: /.exec(line) ?? /^(error|warning) TS\d+: /.exec(line)
|
|
37
|
+
if (head) {
|
|
38
|
+
let file = line.includes("): ") ? head[1]! : ""
|
|
39
|
+
current = { head: line, lines: [line], inDependencies: file.includes("node_modules") }
|
|
40
|
+
diagnostics.push(current)
|
|
41
|
+
} else if (current && line.trim() !== "") {
|
|
42
|
+
current.lines.push(line)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return diagnostics
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function typecheck(root: string): Promise<{ app: Diagnostic[]; hidden: number } | null> {
|
|
49
|
+
let tsc = join(root, "node_modules", ".bin", process.platform === "win32" ? "tsc.exe" : "tsc")
|
|
50
|
+
if (!existsSync(tsc)) {
|
|
51
|
+
console.warn("Typecheck skipped: no tsc in the project (add the typescript devDependency)")
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
let proc = Bun.spawn([tsc, "--noEmit", "--pretty", "false"], { cwd: root, stdout: "pipe", stderr: "pipe" })
|
|
55
|
+
let [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
|
|
56
|
+
await proc.exited
|
|
57
|
+
let diagnostics = parseDiagnostics(out + err)
|
|
58
|
+
let app = diagnostics.filter((d) => !d.inDependencies)
|
|
59
|
+
return { app, hidden: diagnostics.length - app.length }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runCheckCommand() {
|
|
63
|
+
let entry = source!
|
|
64
|
+
let failed = false
|
|
65
|
+
|
|
66
|
+
let result = await bundleWith({ entry, dev: true, minify: false })
|
|
67
|
+
if (!result) {
|
|
68
|
+
// bundleWith already printed the compile errors.
|
|
69
|
+
failed = true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let root = findProjectRoot(entry)
|
|
73
|
+
if (!root) {
|
|
74
|
+
console.warn("Typecheck skipped: no tsconfig.json or package.json above the entry")
|
|
75
|
+
} else {
|
|
76
|
+
let types = await typecheck(root)
|
|
77
|
+
if (types) {
|
|
78
|
+
for (let d of types.app) console.error(d.lines.join("\n"))
|
|
79
|
+
if (types.app.length > 0) {
|
|
80
|
+
failed = true
|
|
81
|
+
let hidden = types.hidden > 0 ? ` (${types.hidden} in dependencies hidden)` : ""
|
|
82
|
+
console.error(`${types.app.length} type error${types.app.length === 1 ? "" : "s"} in app code${hidden}`)
|
|
83
|
+
} else if (types.hidden > 0) {
|
|
84
|
+
console.log(`Types OK (${types.hidden} dependency-internal errors hidden)`)
|
|
85
|
+
} else {
|
|
86
|
+
console.log("Types OK")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (failed) process.exit(1)
|
|
92
|
+
console.log("Check passed")
|
|
93
|
+
process.exit(0)
|
|
94
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -29,23 +29,48 @@ function packageName(dir: string): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const DEFAULT_TEMPLATE = "default"
|
|
32
|
+
const TEMPLATE_MANIFEST = "template.json"
|
|
33
|
+
|
|
34
|
+
// Each template's template.json declares which level the scaffolded app is
|
|
35
|
+
// written at: "core" (only @solidrt/core, no component framework) or
|
|
36
|
+
// "components" (built with @solidrt/components). The level decides the
|
|
37
|
+
// generated dependencies; the description labels the template in the picker.
|
|
38
|
+
interface TemplateInfo {
|
|
39
|
+
name: string
|
|
40
|
+
level: "core" | "components"
|
|
41
|
+
description: string
|
|
42
|
+
}
|
|
32
43
|
|
|
33
44
|
// Templates are the directories under scaffold/templates/; each holds the files
|
|
34
|
-
// that become the new project's src
|
|
35
|
-
// point, the rest alphabetically.
|
|
36
|
-
async function listTemplates(): Promise<
|
|
45
|
+
// that become the new project's src/, plus a template.json manifest. `default`
|
|
46
|
+
// sorts first as the starting point, the rest alphabetically.
|
|
47
|
+
async function listTemplates(): Promise<TemplateInfo[]> {
|
|
37
48
|
let entries = await readdir(TEMPLATES_DIR, { withFileTypes: true })
|
|
38
|
-
|
|
49
|
+
let names = entries
|
|
39
50
|
.filter((e) => e.isDirectory())
|
|
40
51
|
.map((e) => e.name)
|
|
41
52
|
.sort((a, b) =>
|
|
42
53
|
a === DEFAULT_TEMPLATE ? -1 : b === DEFAULT_TEMPLATE ? 1 : a.localeCompare(b),
|
|
43
54
|
)
|
|
55
|
+
let templates: TemplateInfo[] = []
|
|
56
|
+
for (let name of names) {
|
|
57
|
+
// A missing manifest falls back to the components level: it keeps every
|
|
58
|
+
// dependency, so the scaffolded app works at either level.
|
|
59
|
+
let manifest = await readFile(join(TEMPLATES_DIR, name, TEMPLATE_MANIFEST), "utf8")
|
|
60
|
+
.then((raw) => JSON.parse(raw))
|
|
61
|
+
.catch(() => ({}))
|
|
62
|
+
templates.push({
|
|
63
|
+
name,
|
|
64
|
+
level: manifest.level === "core" ? "core" : "components",
|
|
65
|
+
description: typeof manifest.description === "string" ? manifest.description : "",
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return templates
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
// Resolve which template to scaffold from: an explicit --template if valid, an
|
|
47
72
|
// interactive picker on a TTY, else `default` (or the first available).
|
|
48
|
-
async function resolveTemplate(): Promise<
|
|
73
|
+
async function resolveTemplate(): Promise<TemplateInfo> {
|
|
49
74
|
let templates = await listTemplates()
|
|
50
75
|
if (templates.length === 0) {
|
|
51
76
|
console.error(`!! No templates found in ${TEMPLATES_DIR}`)
|
|
@@ -53,14 +78,25 @@ async function resolveTemplate(): Promise<string> {
|
|
|
53
78
|
}
|
|
54
79
|
let chosen = values.template
|
|
55
80
|
if (chosen) {
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
let found = templates.find((t) => t.name === chosen)
|
|
82
|
+
if (!found) {
|
|
83
|
+
let names = templates.map((t) => t.name).join(", ")
|
|
84
|
+
console.error(`!! Unknown template "${chosen}"; choose from: ${names}`)
|
|
58
85
|
process.exit(1)
|
|
59
86
|
}
|
|
60
|
-
return
|
|
87
|
+
return found
|
|
88
|
+
}
|
|
89
|
+
if (process.stdin.isTTY) {
|
|
90
|
+
let picked = await select(
|
|
91
|
+
"Select a template",
|
|
92
|
+
templates.map((t) => ({
|
|
93
|
+
label: t.description ? `${t.name} - ${t.description}` : t.name,
|
|
94
|
+
value: t.name,
|
|
95
|
+
})),
|
|
96
|
+
)
|
|
97
|
+
return templates.find((t) => t.name === picked)!
|
|
61
98
|
}
|
|
62
|
-
|
|
63
|
-
return templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0]!
|
|
99
|
+
return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
|
|
64
100
|
}
|
|
65
101
|
|
|
66
102
|
export async function runInitCommand() {
|
|
@@ -84,7 +120,7 @@ export async function runInitCommand() {
|
|
|
84
120
|
|
|
85
121
|
let template = await resolveTemplate()
|
|
86
122
|
|
|
87
|
-
console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template})`)
|
|
123
|
+
console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template.name})`)
|
|
88
124
|
for (let { from, to } of TEMPLATE_FILES) {
|
|
89
125
|
let dest = join(dir, to)
|
|
90
126
|
await mkdir(dirname(dest), { recursive: true })
|
|
@@ -93,19 +129,22 @@ export async function runInitCommand() {
|
|
|
93
129
|
}
|
|
94
130
|
|
|
95
131
|
// The chosen template's files become the project's src/. Entries may be
|
|
96
|
-
// nested directories (e.g. an asset folder), so copy recursively.
|
|
97
|
-
|
|
132
|
+
// nested directories (e.g. an asset folder), so copy recursively. The
|
|
133
|
+
// manifest describes the template rather than belonging to the app.
|
|
134
|
+
let templateDir = join(TEMPLATES_DIR, template.name)
|
|
98
135
|
await mkdir(join(dir, "src"), { recursive: true })
|
|
99
136
|
for (let file of await readdir(templateDir)) {
|
|
137
|
+
if (file === TEMPLATE_MANIFEST) continue
|
|
100
138
|
await cp(join(templateDir, file), join(dir, "src", file), { recursive: true })
|
|
101
139
|
console.log(` Write src/${file}`)
|
|
102
140
|
}
|
|
103
141
|
|
|
104
142
|
// The scaffold package.json carries a placeholder name; set it from the
|
|
105
|
-
// target folder.
|
|
143
|
+
// target folder. A core-level app gets no component framework dependency.
|
|
106
144
|
let pkgPath = join(dir, "package.json")
|
|
107
145
|
let pkg = JSON.parse(await readFile(pkgPath, "utf8"))
|
|
108
146
|
pkg.name = packageName(dir)
|
|
147
|
+
if (template.level === "core") delete pkg.dependencies["@solidrt/components"]
|
|
109
148
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
|
|
110
149
|
|
|
111
150
|
// Deps are declared in scaffold/package.json (Solid peers resolve via
|