@lincyaw/cli 1.0.0-rc.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,20 +2,20 @@
2
2
 
3
3
  The `presmium` command: `new`, `check`, `ir`, `build`, `dev` and `serve` for Presmium documents and workspaces.
4
4
 
5
- Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
5
+ Part of [Presmium](https://github.com/Lincyaw/presmium), which turns structured teaching documents into web pages, slides, Jupyter notebooks and narrated lectures.
6
6
 
7
7
  ## Install
8
8
 
9
9
  Most people install the `presmium` package, which brings this command with the renderers and the official plugins:
10
10
 
11
11
  ```sh
12
- pnpm add -g presmium
12
+ pnpm add -g @lincyaw/presmium
13
13
  ```
14
14
 
15
15
  In a project that picks its own renderers and plugins, install the CLI with them (Node.js 22.19 or later):
16
16
 
17
17
  ```sh
18
- pnpm add -D @lincyaw/cli @lincyaw/web @lincyaw/render-slides @lincyaw/render-notebook @lincyaw/slidev-addon-core
18
+ pnpm add -D @lincyaw/cli @lincyaw/web @lincyaw/render-slides @lincyaw/render-notebook
19
19
  ```
20
20
 
21
21
  ## Commands
@@ -30,7 +30,7 @@ pnpm add -D @lincyaw/cli @lincyaw/web @lincyaw/render-slides @lincyaw/render-not
30
30
  | `presmium serve [dir]` | The workspace service, on this machine: the documents and their sites, compiled on demand (`--port`, `--host`, `--operator-dir`, `--preview-origin`) |
31
31
  | `presmium studio [dir]` | The workbench (experimental), from `@lincyaw/studio` installed in the project (`--port`, `--host`); installed globally, it is `presmium-studio` |
32
32
 
33
- `[deck]` is a directory with `lecture.md` or an entry file, by default the current directory. Exit codes: 0 no errors, 1 error findings, 2 usage or internal error. `presmium --help` lists every option.
33
+ `[deck]` is a directory with `lecture.json` or an entry file, by default the current directory. Exit codes: 0 no errors, 1 error findings, 2 usage or internal error. `presmium --help` lists every option.
34
34
 
35
35
  ## Testing plugins
36
36
 
@@ -44,8 +44,8 @@ const { code, stdout } = await presmium(dir, ['build', '--to', 'web'])
44
44
 
45
45
  ## Documentation
46
46
 
47
- - [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/lecture.en.md)
48
- - [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/lecture.en.md)
47
+ - [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/locales/en/lecture.json)
48
+ - [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/locales/en/lecture.json)
49
49
 
50
50
  ## License
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/cli",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.1",
4
4
  "description": "The presmium command: build, dev, ir, check and serve for Presmium documents",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -36,9 +36,10 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "jiti": "^2.7.0",
39
- "@lincyaw/kernel": "1.0.0-rc.0",
40
- "@lincyaw/workspace": "1.0.0-rc.0",
41
- "@lincyaw/server": "1.0.0-rc.0",
42
- "@lincyaw/compiler": "1.0.0-rc.0"
39
+ "@lincyaw/kernel": "1.0.0-rc.1",
40
+ "@lincyaw/compiler": "1.0.0-rc.1",
41
+ "@lincyaw/server": "1.0.0-rc.1",
42
+ "@lincyaw/workspace": "1.0.0-rc.1",
43
+ "@lincyaw/skills": "1.0.0-rc.1"
43
44
  }
44
45
  }
package/src/check.ts CHANGED
@@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { ANCHOR_LOCK_PATH, errorMessage } from '@lincyaw/kernel'
4
4
  import type { AnchorString, Finding } from '@lincyaw/kernel'
5
- import { checkContext, Compiler, frontmatterLine, sortFindings } from '@lincyaw/compiler'
5
+ import { checkContext, Compiler, sortFindings } from '@lincyaw/compiler'
6
6
  import type { Compilation, CompilerOptions } from '@lincyaw/compiler'
7
7
 
8
8
  export interface CheckOptions {
@@ -35,7 +35,7 @@ export async function runCheck(options: CheckOptions): Promise<CheckResult> {
35
35
 
36
36
  let lock: LockUpdate | undefined
37
37
  if (options.updateLock) {
38
- lock = await updateLock(compilation, (name, error) => fail(`plugin "${name}" failed to list anchors: ${errorMessage(error)}`, frontmatterLine(entryText, name)))
38
+ lock = await updateLock(compilation, (name, error) => fail(`plugin "${name}" failed to list anchors: ${errorMessage(error)}`, jsonLine(entryText, name)))
39
39
  compilation = await compiler.compile()
40
40
  }
41
41
 
@@ -54,7 +54,7 @@ export async function probeEnvironments(compilation: Compilation, text: string):
54
54
  } catch (error) {
55
55
  reason = errorMessage(error)
56
56
  }
57
- if (reason) findings.push({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}" (${spec.kind}) is not reachable: ${reason}`, source: { file: compilation.entry, line: frontmatterLine(text, `${name}:`) } })
57
+ if (reason) findings.push({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}" (${spec.kind}) is not reachable: ${reason}`, source: { file: compilation.entry, line: jsonLine(text, name) } })
58
58
  }
59
59
  return findings
60
60
  }
@@ -83,3 +83,9 @@ async function updateLock(compilation: Compilation, onError: (plugin: string, er
83
83
  }
84
84
  return { file, anchors: lock.size, changed: text !== before }
85
85
  }
86
+
87
+ /** JSON property locations are diagnostic hints; entity paths are supplied by document validation. */
88
+ function jsonLine(text: string, property: string): number {
89
+ const index = text.indexOf(JSON.stringify(property))
90
+ return index < 0 ? 1 : text.slice(0, index).split("\n").length
91
+ }
package/src/cli.ts CHANGED
@@ -5,92 +5,42 @@ import { dirname, join, relative, resolve } from 'node:path'
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url'
6
6
  import { parseArgs } from 'node:util'
7
7
  import { createJiti } from 'jiti'
8
- import { errorMessage } from '@lincyaw/kernel'
8
+ import { APPROVAL_KINDS, errorMessage } from '@lincyaw/kernel'
9
+ import { bracketIPv6, ffmpegInstallCommand, findFfmpeg, irRevision, lanAddresses } from '@lincyaw/kernel/node'
9
10
  import type { ApprovalKind, Finding, Severity } from '@lincyaw/kernel'
10
11
  import { build, Compiler, dev, resolveEntry } from '@lincyaw/compiler'
11
12
  import type { BuildReport, Compilation, RunStats } from '@lincyaw/compiler'
12
- import { anyAddress, isLoopback, networkAddresses, servedDocument, startServer } from '@lincyaw/server'
13
+ import { isAnyAddress, isLoopback, serverConfigKey, servedDocument, startServer } from '@lincyaw/server'
13
14
  import type { ApprovalPolicy } from '@lincyaw/server'
14
15
  import { buildWorkspace, isWorkspaceRoot, serveWorkspace } from '@lincyaw/workspace'
15
16
  import type { WorkspaceBuild } from '@lincyaw/workspace'
16
17
  import { runCheck } from './check.ts'
17
18
  import type { CheckResult } from './check.ts'
18
- import { devHost } from './host.ts'
19
+ import { devHost } from './dev-host.ts'
20
+ import { CLI_MESSAGES, systemLocale } from './messages.ts'
19
21
 
20
- export const BUNDLED = fileURLToPath(new URL('..', import.meta.url))
22
+ const BUNDLED = fileURLToPath(new URL('..', import.meta.url))
23
+ const PRESMIUM_BIN = fileURLToPath(new URL('../bin/presmium.mjs', import.meta.url))
21
24
 
22
25
  export interface MainOptions {
26
+ /** The package whose dependencies hold the renderers and plugins a document does not install itself. */
23
27
  bundled?: string
28
+ /** The presmium command that presmium studio starts. */
24
29
  bin?: string
25
30
  }
26
31
 
27
- let bundled = BUNDLED
28
- let presmiumBin = fileURLToPath(new URL('../bin/presmium.mjs', import.meta.url))
29
-
30
- const USAGE = `Usage: presmium <command> [deck] [options]
31
-
32
- The deck is a directory holding lecture.md, or an entry file (default: .).
33
-
34
- Commands:
35
- new a new project in the directory (default: asks), from create-presmium
36
- --type <kind> lecture, workspace or plugin (default: asks)
37
- --title <text> the lecture's or the workspace's title
38
- --name <name> the plugin's package name
39
- --no-git do not run git init
40
- build compile and render; writes <out>/ir.json and <out>/<renderer>/; at a workspace root
41
- (presmium.workspace.yaml) builds the workspace site into <out>/web/
42
- --to <ids> renderers to run, comma-separated (default: the deck's renderers)
43
- --out <dir> output directory (default: <deck>/dist)
44
- --audience <name> only the content for this audience (e.g. student); then every file
45
- written is scanned for the sentences of the content left out
46
- dev compile, start the Presmium server and the renderers' dev servers, recompile on save
47
- --to <ids> renderers to run, comma-separated
48
- --out <dir> working directory (default: <deck>/.presmium/dev)
49
- --server-port <n> port of the Presmium server (default: a free port)
50
- --allow <kinds> approvals given without asking: operate, spend, write, agent
51
- ir print the IR as JSON
52
- --out <file> write it to a file instead
53
- check compile and run the plugins' checks
54
- --json print the result as JSON
55
- --update-lock re-resolve anchors and rewrite .presmium/anchors.lock.json
56
- --env also connect to the environments of execute.environments
57
- --audience <name> check the deck as compiled for this audience
58
- --external-links also check the external links, with lychee when it is installed
59
- serve the workspace service for you on this machine (the argument is the workspace root, or
60
- a deck): the workspace site, the documents and their history, the reviews, the plugins'
61
- routes, the classroom sync; without --host it listens on this machine only
62
- --port <n> port (default: 4310)
63
- --host <addr> address to listen on (default: 127.0.0.1); 0.0.0.0 or an address on the
64
- local network opens it to your other devices there, with no login:
65
- whoever reaches the port works as you
66
- --operator-dir <dir> the operator directory (default: the user's state directory)
67
- --preview-origin <origin> (repeatable) the origin of a workbench whose pages embed the
68
- previews, e.g. http://127.0.0.1:4320; with PRESMIUM_SERVICE_TOKEN set, the
69
- workspace's routes ask for it (Authorization: Bearer)
70
- studio the workbench (experimental): DeepSeek Harness with Presmium's bundle and the workspace
71
- service of the directory (the workspace root, or a deck); prints the one address to open.
72
- It is the package @lincyaw/studio (with DeepSeek Harness, about 865 MB), installed beside
73
- this one
74
- --port <n> the workbench's port (default: 4320, or a free one when that is in use)
75
- --host <addr> listen on this address of the machine on the local network instead of
76
- 127.0.0.1, with no login: whoever reaches the port works as you
77
-
78
- -h, --help show this help
79
-
80
- Exit codes: 0 no errors, 1 error findings, 2 usage or internal error.`
32
+ const t = CLI_MESSAGES[systemLocale()]
81
33
 
82
34
  const OPTIONS = {
83
35
  'build': ['to', 'out', 'audience'],
84
36
  'dev': ['to', 'out', 'server-port', 'allow'],
85
37
  'ir': ['out'],
86
38
  'check': ['json', 'update-lock', 'env', 'audience', 'external-links'],
87
- 'serve': ['port', 'host', 'operator-dir', 'preview-origin'],
39
+ 'serve': ['port', 'host', 'operator-dir', 'preview-origin', 'public-url'],
88
40
  } as const
89
41
 
90
42
  type Name = keyof typeof OPTIONS
91
43
 
92
- const KINDS: readonly ApprovalKind[] = ['operate', 'spend', 'write', 'agent']
93
-
94
44
  interface Command {
95
45
  name: Name
96
46
  entry: string
@@ -106,33 +56,34 @@ interface Command {
106
56
  allow: ApprovalKind[]
107
57
  operatorDir?: string
108
58
  port?: number
109
- host?: string
59
+ listenAddress?: string
110
60
  previewOrigins: string[]
61
+ publicUrl?: string
111
62
  }
112
63
 
113
64
  export async function main(argv: string[], options: MainOptions = {}): Promise<number> {
114
- bundled = options.bundled ?? BUNDLED
115
- if (options.bin) presmiumBin = options.bin
116
- if (argv[0] === 'new') return create(argv.slice(1))
117
- if (argv[0] === 'studio') return studio(argv.slice(1))
65
+ const bundled = options.bundled ?? BUNDLED
66
+ if (argv[0] === 'new') return runNewCommand(argv.slice(1), bundled)
67
+ if (argv[0] === 'skills') return (await import('@lincyaw/skills')).main(argv.slice(1))
68
+ if (argv[0] === 'studio') return runStudioCommand(argv.slice(1), bundled, options.bin ?? PRESMIUM_BIN)
118
69
  let command: Command | 'help'
119
70
  try {
120
71
  command = parseCommand(argv)
121
72
  } catch (error) {
122
- process.stderr.write(`presmium: ${errorMessage(error)}\n\n${USAGE}\n`)
73
+ process.stderr.write(`presmium: ${errorMessage(error)}\n\n${t.usage}\n`)
123
74
  return 2
124
75
  }
125
76
  if (command === 'help') {
126
- process.stdout.write(`${USAGE}\n`)
77
+ process.stdout.write(`${t.usage}\n`)
127
78
  return 0
128
79
  }
129
80
  try {
130
81
  switch (command.name) {
131
- case 'check': return await check(command)
132
- case 'ir': return await ir(command)
133
- case 'build': return await buildDeck(command)
134
- case 'dev': return await devDeck(command)
135
- case 'serve': return await serve(command)
82
+ case 'check': return await runCheckCommand(command, bundled)
83
+ case 'ir': return await runIrCommand(command, bundled)
84
+ case 'build': return await runBuildCommand(command, bundled)
85
+ case 'dev': return await runDevCommand(command, bundled)
86
+ case 'serve': return await runServeCommand(command, bundled)
136
87
  }
137
88
  } catch (error) {
138
89
  process.stderr.write(`presmium: ${errorMessage(error)}\n`)
@@ -158,34 +109,37 @@ function parseCommand(argv: string[]): Command | 'help' {
158
109
  'port': { type: 'string' },
159
110
  'host': { type: 'string' },
160
111
  'preview-origin': { type: 'string', multiple: true },
112
+ 'public-url': { type: 'string' },
161
113
  'help': { type: 'boolean', short: 'h', default: false },
162
114
  },
163
115
  })
164
116
  if (values.help) return 'help'
165
117
  const [name, deck = '.', ...extra] = positionals
166
- if (!name) throw new Error('missing command')
167
- if (!(name in OPTIONS)) throw new Error(`unknown command "${name}"`)
118
+ if (!name) throw new Error(t.missingCommand)
119
+ if (!(name in OPTIONS)) throw new Error(t.unknownCommand(name))
168
120
  const allowed: readonly string[] = OPTIONS[name as Name]
169
121
  const given = Object.entries(values).filter(([key, value]) => key !== 'help' && value !== undefined && value !== false)
170
122
  const wrong = given.find(([key]) => !allowed.includes(key))
171
- if (wrong) throw new Error(`option --${wrong[0]} does not apply to ${name}`)
172
- if (extra.length > 0) throw new Error(`unexpected argument "${extra[0]}"`)
123
+ if (wrong) throw new Error(t.optionNotFor(wrong[0], name))
124
+ if (extra.length > 0) throw new Error(t.unexpectedArgument(extra[0]!))
173
125
  const workspace = name === 'build' && existsSync(deck) && statSync(deck).isDirectory() && isWorkspaceRoot(resolve(deck)) ? resolve(deck) : undefined
174
- if (workspace && values.to) throw new Error('--to does not apply to a workspace: its site is the web form')
175
- if (workspace && values.audience !== undefined) throw new Error('--audience does not apply to a workspace yet: build each document for its audience')
126
+ if (workspace && values.to) throw new Error(t.toNotForWorkspace)
127
+ if (workspace && values.audience !== undefined) throw new Error(t.audienceNotForWorkspace)
176
128
  const audience = values.audience?.trim()
177
- if (values.audience !== undefined && !/^[\w.-]+$/.test(audience ?? '')) throw new Error(`--audience takes the name of an audience, e.g. student, not "${values.audience}"`)
129
+ if (values.audience !== undefined && !/^[\w.-]+$/.test(audience ?? '')) throw new Error(t.badAudience(values.audience))
178
130
  const directory = name === 'serve'
179
131
  const entry = directory || workspace ? resolve(deck) : resolveEntry(deck)
180
- if (!existsSync(entry) || (directory && !statSync(entry).isDirectory())) throw new Error(`${entry} not found`)
132
+ if (!existsSync(entry) || (directory && !statSync(entry).isDirectory())) throw new Error(t.notFound(entry))
181
133
  const to = values.to?.split(',').map(id => id.trim()).filter(Boolean)
182
- if (to?.length === 0) throw new Error('--to needs at least one renderer id')
134
+ if (to?.length === 0) throw new Error(t.emptyTo)
183
135
  const allow = values.allow?.split(',').map(kind => kind.trim()).filter(Boolean) ?? []
184
- const unknown = allow.find(kind => !(KINDS as string[]).includes(kind))
185
- if (unknown) throw new Error(`--allow takes ${KINDS.join(', ')}, not "${unknown}"`)
136
+ const unknown = allow.find(kind => !(APPROVAL_KINDS as readonly string[]).includes(kind))
137
+ if (unknown) throw new Error(t.badAllow(APPROVAL_KINDS, unknown))
186
138
  const previewOrigins = values['preview-origin'] ?? []
187
139
  const notOrigin = previewOrigins.find(origin => !isOrigin(origin))
188
- if (notOrigin !== undefined) throw new Error(`--preview-origin takes an origin such as http://127.0.0.1:4320, not "${notOrigin}"`)
140
+ if (notOrigin !== undefined) throw new Error(t.badPreviewOrigin(notOrigin))
141
+ const publicUrl = values['public-url']
142
+ if (publicUrl !== undefined && !isPublicUrl(publicUrl)) throw new Error(t.badPublicUrl(publicUrl))
189
143
  return {
190
144
  name: name as Name,
191
145
  entry,
@@ -197,12 +151,23 @@ function parseCommand(argv: string[]): Command | 'help' {
197
151
  env: values.env,
198
152
  ...audience ? { audience } : {},
199
153
  externalLinks: values['external-links'],
200
- serverPort: port(values['server-port'], '--server-port'),
154
+ serverPort: parsePort(values['server-port'], '--server-port'),
201
155
  allow: allow as ApprovalKind[],
202
156
  operatorDir: values['operator-dir'],
203
- port: port(values.port, '--port'),
204
- host: values.host,
157
+ port: parsePort(values.port, '--port'),
158
+ listenAddress: values.host,
205
159
  previewOrigins,
160
+ ...publicUrl ? { publicUrl } : {},
161
+ }
162
+ }
163
+
164
+ /** An http(s) address, optionally with the path a proxy forwards; no credentials, query or fragment. */
165
+ function isPublicUrl(value: string): boolean {
166
+ try {
167
+ const url = new URL(value)
168
+ return /^https?:$/.test(url.protocol) && !url.username && !url.password && !url.search && !url.hash
169
+ } catch {
170
+ return false
206
171
  }
207
172
  }
208
173
 
@@ -215,14 +180,14 @@ function isOrigin(value: string): boolean {
215
180
  }
216
181
  }
217
182
 
218
- function port(value: string | undefined, option: string): number | undefined {
183
+ function parsePort(value: string | undefined, option: string): number | undefined {
219
184
  if (value === undefined) return undefined
220
185
  const n = Number(value)
221
- if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(`${option} takes a port number, not "${value}"`)
186
+ if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(t.badPort(option, value))
222
187
  return n
223
188
  }
224
189
 
225
- async function check(command: Command): Promise<number> {
190
+ async function runCheckCommand(command: Command, bundled: string): Promise<number> {
226
191
  const result = await runCheck({
227
192
  deck: command.entry,
228
193
  updateLock: command.updateLock,
@@ -234,7 +199,7 @@ async function check(command: Command): Promise<number> {
234
199
  return exitCode(result.findings)
235
200
  }
236
201
 
237
- async function ir(command: Command): Promise<number> {
202
+ async function runIrCommand(command: Command, bundled: string): Promise<number> {
238
203
  const { doc } = await new Compiler(command.entry, { bundled }).compile()
239
204
  const json = `${JSON.stringify(doc, null, 2)}\n`
240
205
  if (command.out) {
@@ -248,7 +213,7 @@ async function ir(command: Command): Promise<number> {
248
213
  return exitCode(doc.diagnostics)
249
214
  }
250
215
 
251
- async function buildDeck(command: Command): Promise<number> {
216
+ async function runBuildCommand(command: Command, bundled: string): Promise<number> {
252
217
  if (command.workspace) {
253
218
  const report = await buildWorkspace(command.workspace, { outDir: command.out, bundled })
254
219
  process.stdout.write(workspaceText(report))
@@ -257,11 +222,11 @@ async function buildDeck(command: Command): Promise<number> {
257
222
  const compilation = await new Compiler(command.entry, { bundled, ...command.audience ? { audience: command.audience } : {} }).compile({ pruneCache: true })
258
223
  const outDir = resolve(command.out ?? join(compilation.deckDir, 'dist'))
259
224
  const report = await build(compilation, { outDir, to: command.to })
260
- process.stdout.write(buildText(report, compilation.runs, compilation.audience?.name))
225
+ process.stdout.write(buildText(report, compilation.runStats, compilation.audience?.name))
261
226
  return exitCode(report.findings)
262
227
  }
263
228
 
264
- async function devDeck(command: Command): Promise<number> {
229
+ async function runDevCommand(command: Command, bundled: string): Promise<number> {
265
230
  let latest: Compilation | undefined
266
231
  const deckDir = dirname(command.entry)
267
232
  const approvals: Partial<Record<ApprovalKind, ApprovalPolicy>> = Object.fromEntries(command.allow.map(kind => [kind, 'allow']))
@@ -274,20 +239,27 @@ async function devDeck(command: Command): Promise<number> {
274
239
  document: async dir => (dir === '' && latest ? servedDocument(latest) : undefined),
275
240
  stateFile: join(deckDir, '.presmium', 'server.json'),
276
241
  })
277
- const compiler = new Compiler(command.entry, { bundled, services: server.services })
242
+ const compiler = new Compiler(command.entry, { bundled, priority: 'interactive', services: server.services })
278
243
  const outDir = resolve(command.out ?? join(compiler.deckDir, '.presmium', 'dev'))
279
244
  await mkdir(outDir, { recursive: true })
280
245
  await writeFile(join(outDir, '.gitignore'), '*\n')
281
- let session
246
+ let devServers
282
247
  try {
283
- session = await dev(compiler, {
248
+ devServers = await dev(compiler, {
284
249
  outDir,
285
250
  to: command.to,
286
251
  server: { url: server.url },
287
252
  onCompile(compilation, findings) {
253
+ const changed = latest && serverConfigKey(latest) !== serverConfigKey(compilation)
288
254
  latest = compilation
255
+ const revision = irRevision(compilation.doc)
256
+ server.diagnostics.compiled('', compilation.doc, compilation.workspaceRoot, revision)
257
+ server.diagnostics.replace({ document: '', origin: 'preview' }, findings.filter(finding => finding.checkId === 'presmium.render'), { root: compilation.workspaceRoot, irRevision: revision })
258
+ if (changed) void server.unmount('', { preserveFocus: true }).catch((error: unknown) => {
259
+ process.stderr.write(`${t.unmountFailed(errorMessage(error))}\n`)
260
+ })
289
261
  const lines = findingLines(findings)
290
- lines.push(`compiled ${compilation.doc.sections.length} sections; ${summary(findings)}`)
262
+ lines.push(t.compiled(compilation.doc.sections.length, summary(findings)))
291
263
  process.stdout.write(`${lines.join('\n')}\n`)
292
264
  },
293
265
  })
@@ -295,32 +267,34 @@ async function devDeck(command: Command): Promise<number> {
295
267
  await server.close()
296
268
  throw error
297
269
  }
298
- for (const { renderer, url } of session.servers) process.stdout.write(`${renderer} ${url}\n`)
299
- process.stdout.write(`server ${server.url}\nmcp ${server.url}/mcp (its token is in ${relative(process.cwd(), join(deckDir, '.presmium', 'server.json'))})\n`)
300
- process.stdout.write(`watching; IR in ${relative(process.cwd(), session.ir)}\n`)
270
+ for (const { renderer, url } of devServers.servers) process.stdout.write(`${renderer} ${url}\n`)
271
+ process.stdout.write(`server ${server.url}\nmcp ${server.url}/mcp${t.token(relative(process.cwd(), join(deckDir, '.presmium', 'server.json')))}\n`)
272
+ process.stdout.write(`${t.watching(relative(process.cwd(), devServers.ir))}\n`)
301
273
  await new Promise<void>((done) => {
302
274
  process.once('SIGINT', done)
303
275
  process.once('SIGTERM', done)
304
276
  })
305
- await session.close()
277
+ await devServers.close()
306
278
  await server.close()
307
279
  return 0
308
280
  }
309
281
 
310
- async function serve(command: Command): Promise<number> {
282
+ async function runServeCommand(command: Command, bundled: string): Promise<number> {
311
283
  const started = await serveWorkspace({
312
284
  dir: command.entry,
313
- host: command.host,
285
+ listenAddress: command.listenAddress,
314
286
  port: command.port,
315
287
  ...command.operatorDir ? { operatorDir: resolve(command.operatorDir) } : {},
316
288
  bundled,
317
289
  check: compilation => compilation.check(),
318
290
  previewOrigins: command.previewOrigins,
291
+ ...command.publicUrl ? { publicUrl: command.publicUrl } : {},
319
292
  })
320
- process.stdout.write(`serve ${started.url}\n${networkLines(command.host, started.url)}workspace ${started.root}\nmcp ${started.mcp.url} (its token is in ${started.mcp.stateFile})\n`)
321
- if (command.host && !isLoopback(command.host)) {
322
- process.stderr.write(`presmium serve: listening on ${command.host}, on the local network, with no login: anyone who reaches port ${new URL(started.url).port} uses this workspace as you (edits, runs code, approves). Use it on a network you trust; without --host it listens on this machine only.\n`)
293
+ process.stdout.write(`serve ${started.url}\n${networkLines(command.listenAddress, started.url)}workspace ${started.root}\nmcp ${started.mcp.url}${t.token(started.mcp.stateFile)}\n`)
294
+ if (command.listenAddress && !isLoopback(command.listenAddress)) {
295
+ process.stderr.write(`${t.serveOpen(command.listenAddress, new URL(started.url).port)}\n`)
323
296
  }
297
+ if (!findFfmpeg()) process.stderr.write(`${t.noFfmpeg(ffmpegInstallCommand())}\n`)
324
298
  await new Promise<void>((done) => {
325
299
  process.once('SIGINT', done)
326
300
  process.once('SIGTERM', done)
@@ -329,7 +303,7 @@ async function serve(command: Command): Promise<number> {
329
303
  return 0
330
304
  }
331
305
 
332
- function installed(name: string): string | undefined {
306
+ function resolveInstalled(name: string, bundled: string): string | undefined {
333
307
  for (const from of [pathToFileURL(join(bundled, 'package.json')).href, import.meta.url, pathToFileURL(join(process.cwd(), 'package.json')).href]) {
334
308
  try {
335
309
  return createRequire(from).resolve(name)
@@ -339,30 +313,30 @@ function installed(name: string): string | undefined {
339
313
  return undefined
340
314
  }
341
315
 
342
- async function create(argv: string[]): Promise<number> {
343
- const entry = installed('create-presmium')
316
+ async function runNewCommand(argv: string[], bundled: string): Promise<number> {
317
+ const entry = resolveInstalled('create-presmium', bundled)
344
318
  if (!entry) {
345
- process.stderr.write('presmium new: create-presmium is not installed; it comes with the presmium package: pnpm add -g presmium\n')
319
+ process.stderr.write(`${t.newMissing}\n`)
346
320
  return 2
347
321
  }
348
322
  const { main: createMain } = await createJiti(import.meta.url).import<{ main(argv: string[]): Promise<number> }>(entry)
349
323
  return createMain(argv)
350
324
  }
351
325
 
352
- async function studio(argv: string[]): Promise<number> {
353
- const entry = installed('@lincyaw/studio')
326
+ async function runStudioCommand(argv: string[], bundled: string, presmiumBin: string): Promise<number> {
327
+ const entry = resolveInstalled('@lincyaw/studio', bundled)
354
328
  if (!entry) {
355
- process.stderr.write('presmium studio: the workbench is not installed here. It is the package @lincyaw/studio (experimental; with DeepSeek Harness, about 865 MB): pnpm add -g @lincyaw/studio, then run presmium-studio; or pnpm add -D @lincyaw/studio in this project.\n')
329
+ process.stderr.write(`${t.studioMissing}\n`)
356
330
  return 2
357
331
  }
358
332
  const { main: studioMain } = await createJiti(import.meta.url).import<{ main(argv: string[], options: { presmium: string }): Promise<number> }>(entry)
359
333
  return studioMain(argv, { presmium: presmiumBin })
360
334
  }
361
335
 
362
- function networkLines(host: string | undefined, url: string): string {
363
- if (!host || isLoopback(host)) return ''
336
+ function networkLines(listenAddress: string | undefined, url: string): string {
337
+ if (!listenAddress || isLoopback(listenAddress)) return ''
364
338
  const { port } = new URL(url)
365
- const addresses = anyAddress(host) ? networkAddresses() : [host.includes(':') && !host.startsWith('[') ? `[${host}]` : host]
339
+ const addresses = isAnyAddress(listenAddress) ? lanAddresses() : [bracketIPv6(listenAddress)]
366
340
  return addresses.map(address => `network http://${address}:${port}/\n`).join('')
367
341
  }
368
342
 
@@ -378,13 +352,13 @@ function counts(findings: Finding[]): Record<Severity, number> {
378
352
 
379
353
  function summary(findings: Finding[]): string {
380
354
  const n = counts(findings)
381
- return findings.length === 0 ? 'no problems' : `${n.error} errors, ${n.warning} warnings, ${n.info} infos`
355
+ return findings.length === 0 ? t.noProblems : t.counts(n.error, n.warning, n.info)
382
356
  }
383
357
 
384
358
  function findingLines(findings: Finding[], cwd = process.cwd()): string[] {
385
359
  return findings.flatMap((f) => {
386
360
  const where = f.source ? `${relative(cwd, f.source.file)}${f.source.line ? `:${f.source.line}` : ''}` : ''
387
- return [`${where} ${f.severity} ${f.message} (${f.checkId})`, ...f.hint ? [` hint: ${f.hint}`] : []]
361
+ return [`${where} ${t.severity[f.severity]} ${f.message} (${f.checkId})`, ...f.hint ? [t.hint(f.hint)] : []]
388
362
  })
389
363
  }
390
364
 
@@ -395,8 +369,7 @@ function checkJson(result: CheckResult) {
395
369
  function checkText(result: CheckResult, cwd = process.cwd()): string {
396
370
  const out = findingLines(result.findings, cwd)
397
371
  if (result.lock) {
398
- const state = result.lock.changed ? 'updated' : 'unchanged'
399
- out.push(`${relative(cwd, result.lock.file)} ${state} (${result.lock.anchors} anchors)`)
372
+ out.push(t.lock(relative(cwd, result.lock.file), result.lock.changed, result.lock.anchors))
400
373
  }
401
374
  out.push(summary(result.findings))
402
375
  return `${out.join('\n')}\n`
@@ -405,7 +378,7 @@ function checkText(result: CheckResult, cwd = process.cwd()): string {
405
378
  function workspaceText(report: WorkspaceBuild, cwd = process.cwd()): string {
406
379
  const out = findingLines(report.findings, cwd)
407
380
  for (const { dir, title, runs } of report.documents) {
408
- out.push(`document ${dir || '.'} ${title}${runs.executed + runs.cached > 0 ? ` (code runs: ${runs.executed} executed, ${runs.cached} from cache)` : ''}`)
381
+ out.push(`document ${dir || '.'} ${title}${runs.executed + runs.cached > 0 ? t.documentRuns(runs.executed, runs.cached) : ''}`)
409
382
  }
410
383
  out.push(`web ${relative(cwd, report.outDir)}`)
411
384
  out.push(summary(report.findings))
@@ -417,10 +390,10 @@ function buildText(report: BuildReport, runs: RunStats, audience?: string, cwd =
417
390
  if (audience) out.push(`audience ${audience}`)
418
391
  out.push(`ir ${relative(cwd, report.ir)}`)
419
392
  for (const { renderer, outDir, skipped } of report.outputs) {
420
- out.push(`${renderer} ${relative(cwd, outDir)}${skipped.length ? ` (${skipped.length} blocks not rendered)` : ''}`)
393
+ out.push(`${renderer} ${relative(cwd, outDir)}${skipped.length ? t.notRendered(skipped.length) : ''}`)
421
394
  for (const s of skipped) out.push(` ${s.section}#${s.block}: ${s.reason}`)
422
395
  }
423
- if (runs.executed + runs.cached > 0) out.push(`code runs: ${runs.executed} executed, ${runs.cached} from cache${runs.sandbox && runs.executed > 0 ? `, isolated by ${runs.sandbox}` : ''}`)
396
+ if (runs.executed + runs.cached > 0) out.push(t.codeRuns(runs.executed, runs.cached, runs.sandbox && runs.executed > 0 ? runs.sandbox : undefined))
424
397
  out.push(summary(report.findings))
425
398
  return `${out.join('\n')}\n`
426
399
  }
@@ -1,4 +1,4 @@
1
- import { buildView, runBlock } from '@lincyaw/compiler'
1
+ import { buildSummary, buildToDist, runBlock } from '@lincyaw/compiler'
2
2
  import type { Compilation } from '@lincyaw/compiler'
3
3
  import type { ServerHost } from '@lincyaw/server'
4
4
 
@@ -9,6 +9,7 @@ export function devHost(latest: () => Compilation | undefined): ServerHost {
9
9
  return compilation
10
10
  }
11
11
  return {
12
+ get workspaceRoot() { return latest()?.workspaceRoot },
12
13
  async documents() {
13
14
  const compilation = latest()
14
15
  return compilation ? [{ dir: '', title: compilation.doc.meta.title, lang: compilation.doc.meta.lang }] : []
@@ -22,8 +23,7 @@ export function devHost(latest: () => Compilation | undefined): ServerHost {
22
23
  return [...compilation.doc.diagnostics, ...await compilation.check()]
23
24
  },
24
25
  async build(document, options = {}) {
25
- const report = await buildView(current(document), options)
26
- return { outputs: report.outputs.map(({ renderer, outDir }) => ({ renderer, outDir })), findings: report.findings }
26
+ return buildSummary(await buildToDist(current(document), options))
27
27
  },
28
28
  run: async (document, target, signal) => runBlock(current(document), target, signal),
29
29
  }
package/src/index.ts CHANGED
@@ -1,3 +1,2 @@
1
- export { runCheck } from './check.ts'
2
- export type { CheckOptions, CheckResult, LockUpdate } from './check.ts'
3
1
  export { main } from './cli.ts'
2
+ export type { MainOptions } from './cli.ts'
@@ -0,0 +1,219 @@
1
+ export { systemLocale } from '@lincyaw/kernel/node'
2
+ import { DOCUMENT_ENTRY } from '@lincyaw/kernel'
3
+ import type { Locale } from '@lincyaw/kernel'
4
+
5
+ // The lines that start with a key and two spaces (`serve <url>`, `workspace <root>`, `mcp <url>`, `<renderer> <url>`)
6
+ // are read by programs (`@lincyaw/dsh/launch`, the tests): their keys, URLs and paths stay the same in both languages.
7
+ export interface CliMessages {
8
+ usage: string
9
+ missingCommand: string
10
+ unknownCommand(name: string): string
11
+ optionNotFor(option: string, command: string): string
12
+ unexpectedArgument(argument: string): string
13
+ toNotForWorkspace: string
14
+ audienceNotForWorkspace: string
15
+ badAudience(value: string): string
16
+ notFound(entry: string): string
17
+ emptyTo: string
18
+ badAllow(kinds: readonly string[], value: string): string
19
+ badPreviewOrigin(value: string): string
20
+ badPublicUrl(value: string): string
21
+ badPort(option: string, value: string): string
22
+ unmountFailed(error: string): string
23
+ compiled(sections: number, summary: string): string
24
+ token(file: string): string
25
+ watching(ir: string): string
26
+ serveOpen(address: string, port: string): string
27
+ noFfmpeg(command: string): string
28
+ newMissing: string
29
+ studioMissing: string
30
+ noProblems: string
31
+ counts(errors: number, warnings: number, infos: number): string
32
+ severity: Record<'error' | 'warning' | 'info', string>
33
+ hint(text: string): string
34
+ lock(file: string, changed: boolean, anchors: number): string
35
+ documentRuns(executed: number, cached: number): string
36
+ notRendered(blocks: number): string
37
+ codeRuns(executed: number, cached: number, sandbox?: string): string
38
+ }
39
+
40
+ export const CLI_MESSAGES: Record<Locale, CliMessages> = {
41
+ 'zh-CN': {
42
+ usage: `用法:presmium <命令> [<文稿>] [<选项>]
43
+
44
+ <文稿> 是含有 ${DOCUMENT_ENTRY} 的目录,或这个文件本身(默认为 .)。
45
+
46
+ 命令:
47
+ new 在目录中新建项目(没有给出时逐项询问),由 create-presmium 完成
48
+ --type <种类> lecture、workspace、site 或 plugin(默认询问)
49
+ --theme <主题> site 的主题:course、docs 或 showcase(默认 course)
50
+ --title <标题> 讲义、工作区或网站的标题
51
+ --name <包名> 插件的包名
52
+ --no-git 不运行 git init
53
+ skills 安装内置技能(与 MCP 和可视化编辑器共用)
54
+ install <目录> [--json] 安装缺少的 .agents/skills,保留已有技能
55
+ --name <技能> 只安装指定技能,可重复
56
+ --json 以 JSON 打印安装结果
57
+ build 导出文稿:写出 <输出>/ir.json 与 <输出>/<形态>/;在工作区的根目录
58
+ (有 presmium.workspace.yaml)把整个工作区导出为网站,写到 <输出>/web/
59
+ --to <形态> 要导出的形态,以逗号分隔(默认为文稿声明的形态)
60
+ --out <目录> 输出目录(默认为 <文稿>/dist)
61
+ --audience <名称> 只导出面向这类读者的内容(例如 student),并在写出的每个
62
+ 文件中查找被略去内容的句子
63
+ dev 实时预览:启动 Presmium 服务与各形态的预览服务器,保存后自动更新
64
+ --to <形态> 要预览的形态,以逗号分隔
65
+ --out <目录> 工作目录(默认为 <文稿>/.presmium/dev)
66
+ --server-port <端口> Presmium 服务的端口(默认为空闲端口)
67
+ --allow <种类> 无需询问即允许的操作:operate、spend、write、agent
68
+ ir 以 JSON 打印文稿的运行快照
69
+ --out <文件> 写入文件,而不是打印
70
+ check 检查文稿并运行插件的检查
71
+ --json 以 JSON 打印结果
72
+ --update-lock 重新定位代码锚点,并重写 .presmium/anchors.lock.json
73
+ --env 同时连接 execute.environments 中的运行环境
74
+ --audience <名称> 按面向这类读者的内容检查
75
+ --external-links 同时检查外部链接(装有 lychee 时用它)
76
+ serve 在本机提供工作区服务(参数是工作区的根目录或一份文稿):工作区网站、
77
+ 各文稿与其版本历史、审阅、插件的服务端路由与课堂同步;不加 --host 时
78
+ 只在本机监听
79
+ --port <端口> 端口(默认为 4310)
80
+ --host <地址> 监听的地址(默认为 127.0.0.1);0.0.0.0 或局域网中的地址
81
+ 让同一网络中的其他设备也能使用,且没有登录:任何能访问
82
+ 这个端口的人都以你的身份操作
83
+ --operator-dir <目录> 运营者目录(默认为用户的状态目录)
84
+ --preview-origin <来源> (可重复)嵌入预览的 Presmium Studio 页面的来源,
85
+ 例如 http://127.0.0.1:4320;设置 PRESMIUM_SERVICE_TOKEN 时,
86
+ 工作区的路由要求它(Authorization: Bearer)
87
+ --public-url <网址> 反向代理在前面时浏览器使用的地址:信任它的主机,预览与产物
88
+ 的地址用它给出;带路径时代理原样转发该路径,服务的全部内容都在它下面
89
+ studio 启动 Presmium Studio(实验性):浏览器中的编辑应用与这个目录(工作区的
90
+ 根目录或一份文稿)的工作区服务,只打印一个要打开的网址。需要另外安装
91
+ @lincyaw/studio(约 865 MB)
92
+ --port <端口> Presmium Studio 的端口(默认为 4320,被占用时取空闲端口)
93
+ --host <地址> 改为监听本机在局域网中的这个地址,而不是 127.0.0.1,且没有
94
+ 登录:任何能访问这个端口的人都以你的身份操作
95
+
96
+ -h, --help 显示本说明
97
+
98
+ 退出码:0 没有错误,1 有错误级别的问题,2 用法错误或内部错误。`,
99
+ missingCommand: '缺少命令。请在 presmium 之后给出命令,例如 presmium build。',
100
+ unknownCommand: name => `没有命令“${name}”。可用的命令见 presmium --help。`,
101
+ optionNotFor: (option, command) => `选项 --${option} 不能用于 presmium ${command}。可用的选项见 presmium --help。`,
102
+ unexpectedArgument: argument => `多余的参数“${argument}”。每个命令只接受一个文稿或目录。`,
103
+ toNotForWorkspace: '--to 不能用于工作区:工作区总是导出为网站。',
104
+ audienceNotForWorkspace: '--audience 暂不能用于工作区。请分别为每份文稿加 --audience 导出。',
105
+ badAudience: value => `“${value}”不是读者类别的名称。请给出字母、数字、.、_ 或 - 组成的名称,例如 student。`,
106
+ notFound: entry => `找不到 ${entry}。请检查路径,或用 presmium new 新建文稿。`,
107
+ emptyTo: '--to 至少需要一个形态,例如 --to web。',
108
+ badAllow: (kinds, value) => `--allow 不接受“${value}”。可用的种类:${kinds.join('、')}。`,
109
+ badPreviewOrigin: value => `“${value}”不是来源。请给出协议、主机与端口,例如 http://127.0.0.1:4320。`,
110
+ badPublicUrl: value => `“${value}”不是可用的网址。请给出浏览器访问的地址,例如 https://preview.example.com 或 https://example.com/user/a/preview/。`,
111
+ badPort: (option, value) => `${option} 需要 0 到 65535 之间的端口号,而不是“${value}”。`,
112
+ unmountFailed: error => `presmium: 无法关闭上一次加载的服务端扩展:${error}`,
113
+ compiled: (sections, summary) => `已更新 ${sections} 个页面;${summary}`,
114
+ token: file => ` (访问令牌在 ${file} 中)`,
115
+ watching: ir => `正在监视修改;运行快照在 ${ir}`,
116
+ noFfmpeg: command => `presmium serve: 没有找到 FFmpeg,讲授视频只能由浏览器导出为 WebM,画质较低。安装后可导出清晰的 MP4:${command}`,
117
+ serveOpen: (address, port) => `presmium serve: 正在 ${address} 上监听,局域网中的设备可以访问,且没有登录:任何能访问端口 ${port} 的人都以你的身份使用这个工作区(编辑、运行代码、允许操作)。请只在可信的网络中使用;不加 --host 时只在本机监听。`,
118
+ newMissing: 'presmium new: 没有安装 create-presmium。它随 presmium 一起安装,请运行 pnpm add -g @lincyaw/presmium。',
119
+ studioMissing: 'presmium studio: 这里没有安装 Presmium Studio(实验性,约 865 MB)。请运行 pnpm add -g @lincyaw/studio 后使用 presmium-studio,或在本项目中运行 pnpm add -D @lincyaw/studio。',
120
+ noProblems: '没有问题',
121
+ counts: (errors, warnings, infos) => `${errors} 个错误,${warnings} 个警告,${infos} 条信息`,
122
+ severity: { error: '错误', warning: '警告', info: '信息' },
123
+ hint: text => ` 建议:${text}`,
124
+ lock: (file, changed, anchors) => `${file} ${changed ? '已更新' : '没有变化'}(${anchors} 个锚点)`,
125
+ documentRuns: (executed, cached) => ` (代码运行:执行 ${executed} 个,取自缓存 ${cached} 个)`,
126
+ notRendered: blocks => ` (${blocks} 个内容块未显示)`,
127
+ codeRuns: (executed, cached, sandbox) => `代码运行:执行 ${executed} 个,取自缓存 ${cached} 个${sandbox ? `,隔离方式为 ${sandbox}` : ''}`,
128
+ },
129
+ 'en': {
130
+ usage: `Usage: presmium <command> [deck] [options]
131
+
132
+ The deck is a directory holding ${DOCUMENT_ENTRY}, or that entry file (default: .).
133
+
134
+ Commands:
135
+ new a new project in the directory (default: asks), from create-presmium
136
+ --type <kind> lecture, workspace, site, or plugin (default: asks)
137
+ --theme <name> site theme: course, docs, or showcase (default: course)
138
+ --title <text> the lecture, workspace, or site title
139
+ --name <name> the plugin’s package name
140
+ --no-git do not run git init
141
+ skills install bundled skills, shared with MCP and the visual editor
142
+ install <dir> [--json] install missing .agents/skills; keep existing skills
143
+ --name <skill> install only the named skill; repeat for more
144
+ --json print the installation result as JSON
145
+ build export the deck: writes <out>/ir.json and <out>/<renderer>/; at a workspace root
146
+ (presmium.workspace.yaml) exports the workspace as a website into <out>/web/
147
+ --to <ids> forms to export, comma-separated (default: the deck’s renderers)
148
+ --out <dir> output directory (default: <deck>/dist)
149
+ --audience <name> only the content for this audience (e.g. student); then every file
150
+ written is scanned for the sentences of the content left out
151
+ dev live preview: starts the Presmium server and the renderers’ dev servers, updates on save
152
+ --to <ids> forms to preview, comma-separated
153
+ --out <dir> working directory (default: <deck>/.presmium/dev)
154
+ --server-port <n> port of the Presmium server (default: a free port)
155
+ --allow <kinds> approvals given without asking: operate, spend, write, agent
156
+ ir print the runtime snapshot as JSON
157
+ --out <file> write it to a file instead
158
+ check check the deck and run the plugins’ checks
159
+ --json print the result as JSON
160
+ --update-lock re-resolve anchors and rewrite .presmium/anchors.lock.json
161
+ --env also connect to the environments of execute.environments
162
+ --audience <name> check the content for this audience
163
+ --external-links also check the external links, with lychee when it is installed
164
+ serve the workspace service for you on this machine (the argument is the workspace root, or
165
+ a deck): the workspace site, the documents and their history, the reviews, the plugins’
166
+ routes, and the classroom sync; without --host it listens on this machine only
167
+ --port <n> port (default: 4310)
168
+ --host <addr> address to listen on (default: 127.0.0.1); 0.0.0.0 or an address on the
169
+ local network opens it to your other devices there, with no login:
170
+ whoever reaches the port works as you
171
+ --operator-dir <dir> the operator directory (default: the user’s state directory)
172
+ --preview-origin <origin> (repeatable) the origin of a Presmium Studio whose pages embed
173
+ the previews, e.g. http://127.0.0.1:4320; with PRESMIUM_SERVICE_TOKEN set,
174
+ the workspace’s routes ask for it (Authorization: Bearer)
175
+ --public-url <url> the address browsers use when a reverse proxy fronts the service: its
176
+ host is trusted, and previews and outputs are linked under it; with a path, the
177
+ proxy forwards that path unchanged and everything the service serves is below it
178
+ studio Presmium Studio (experimental): the editing app in the browser and the workspace service
179
+ of the directory (the workspace root, or a deck); prints the one address to open. It is
180
+ the package @lincyaw/studio (about 865 MB), installed beside this one
181
+ --port <n> Presmium Studio’s port (default: 4320, or a free one when that is in use)
182
+ --host <addr> listen on this address of the machine on the local network instead of
183
+ 127.0.0.1, with no login: whoever reaches the port works as you
184
+
185
+ -h, --help show this help
186
+
187
+ Exit Codes: 0 no errors, 1 error findings, 2 usage or internal error.`,
188
+ missingCommand: 'No command given. Put a command after presmium, for example presmium build.',
189
+ unknownCommand: name => `There is no command “${name}”. See presmium --help for the commands.`,
190
+ optionNotFor: (option, command) => `The option --${option} does not apply to presmium ${command}. See presmium --help for its options.`,
191
+ unexpectedArgument: argument => `Unexpected argument “${argument}”. Each command takes one deck or directory.`,
192
+ toNotForWorkspace: '--to does not apply to a workspace: its site is the web form.',
193
+ audienceNotForWorkspace: '--audience does not apply to a workspace yet. Export each document with --audience instead.',
194
+ badAudience: value => `“${value}” is not the name of an audience. Give a name of letters, digits, ., _, or -, such as student.`,
195
+ notFound: entry => `Couldn’t find ${entry}. Check the path, or make a document with presmium new.`,
196
+ emptyTo: '--to needs at least one renderer, such as --to web.',
197
+ badAllow: (kinds, value) => `--allow does not take “${value}”. The kinds are ${kinds.join(', ')}.`,
198
+ badPreviewOrigin: value => `“${value}” is not an origin. Give a scheme, host, and port, such as http://127.0.0.1:4320.`,
199
+ badPublicUrl: value => `“${value}” is not a usable address. Give the address browsers use, such as https://preview.example.com or https://example.com/user/a/preview/.`,
200
+ badPort: (option, value) => `${option} takes a port number from 0 to 65535, not “${value}”.`,
201
+ unmountFailed: error => `presmium: couldn’t close the previous server contributions: ${error}`,
202
+ compiled: (sections, summary) => `compiled ${sections} sections; ${summary}`,
203
+ token: file => ` (its token is in ${file})`,
204
+ watching: ir => `watching; runtime snapshot in ${ir}`,
205
+ noFfmpeg: command => `presmium serve: FFmpeg was not found, so lecture videos export from the browser as WebM, at a lower quality. Install it for sharp MP4 videos: ${command}`,
206
+ serveOpen: (address, port) => `presmium serve: listening on ${address}, on the local network, with no login: anyone who reaches port ${port} uses this workspace as you (edits, runs code, approves). Use it on a network you trust; without --host it listens on this machine only.`,
207
+ newMissing: 'presmium new: create-presmium is not installed. It comes with the presmium package: run pnpm add -g @lincyaw/presmium.',
208
+ studioMissing: 'presmium studio: Presmium Studio is not installed here (experimental, about 865 MB). Run pnpm add -g @lincyaw/studio, then presmium-studio; or run pnpm add -D @lincyaw/studio in this project.',
209
+ noProblems: 'no problems',
210
+ counts: (errors, warnings, infos) => `${errors} errors, ${warnings} warnings, ${infos} infos`,
211
+ severity: { error: 'error', warning: 'warning', info: 'info' },
212
+ hint: text => ` hint: ${text}`,
213
+ lock: (file, changed, anchors) => `${file} ${changed ? 'updated' : 'unchanged'} (${anchors} anchors)`,
214
+ documentRuns: (executed, cached) => ` (code runs: ${executed} executed, ${cached} from cache)`,
215
+ notRendered: blocks => ` (${blocks} blocks not rendered)`,
216
+ codeRuns: (executed, cached, sandbox) => `code runs: ${executed} executed, ${cached} from cache${sandbox ? `, isolated by ${sandbox}` : ''}`,
217
+ },
218
+ }
219
+
package/src/testing.ts CHANGED
@@ -22,5 +22,3 @@ export function presmium(cwd: string, args: string[], env: Record<string, string
22
22
  return run(process.execPath, [PRESMIUM_BIN, ...args], { cwd, env: { ...inherited, ...env } })
23
23
  }
24
24
 
25
- export { fakeModel, recordingProxy } from './fake-model.ts'
26
- export type { FakeModel, Received, Step } from './fake-model.ts'
package/src/fake-model.ts DELETED
@@ -1,101 +0,0 @@
1
- import { createServer } from 'node:http'
2
- import type { IncomingHttpHeaders } from 'node:http'
3
- import type { AddressInfo } from 'node:net'
4
-
5
- export type Step = { text: string } | { tool: string, input: Record<string, unknown> }
6
-
7
- export interface Received {
8
- path: string
9
- headers: IncomingHttpHeaders
10
- body: Record<string, any>
11
- }
12
-
13
- export interface FakeModel {
14
- url: string
15
- received: Received[]
16
- hold(): void
17
- release(): void
18
- close(): Promise<void>
19
- }
20
-
21
- const event = (name: string, data: unknown) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`
22
-
23
- function stream(step: Step, n: number): string {
24
- const start = { type: 'message_start', message: { id: `msg_${n}`, type: 'message', role: 'assistant', model: 'fake-model', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 0 } } }
25
- const block = 'text' in step
26
- ? [event('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }), event('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: step.text } })]
27
- : [event('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: `toolu_${n}`, name: step.tool, input: {} } }), event('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: JSON.stringify(step.input) } })]
28
- return [
29
- event('message_start', start),
30
- ...block,
31
- event('content_block_stop', { type: 'content_block_stop', index: 0 }),
32
- event('message_delta', { type: 'message_delta', delta: { stop_reason: 'text' in step ? 'end_turn' : 'tool_use', stop_sequence: null }, usage: { output_tokens: 5 } }),
33
- event('message_stop', { type: 'message_stop' }),
34
- ].join('')
35
- }
36
-
37
- export async function fakeModel(script: Step[]): Promise<FakeModel> {
38
- const received: Received[] = []
39
- let held: Promise<void> | undefined
40
- let release = () => {}
41
- const server = createServer((req, res) => {
42
- let body = ''
43
- req.setEncoding('utf8').on('data', (chunk: string) => body += chunk)
44
- req.on('end', async () => {
45
- let parsed: Record<string, any> = {}
46
- try {
47
- parsed = JSON.parse(body) as Record<string, any>
48
- } catch {
49
- }
50
- const record: Received = { path: req.url ?? '', headers: req.headers, body: parsed }
51
- received.push(record)
52
- if (req.method !== 'POST' || !(req.url ?? '').endsWith('/v1/messages')) {
53
- res.writeHead(404).end()
54
- return
55
- }
56
- await held
57
- const turn = (r: Received) => r.path.endsWith('/v1/messages') && Array.isArray(r.body.tools) && r.body.tools.length > 0
58
- const n = received.filter(r => r.path.endsWith('/v1/messages')).length
59
- const step = turn(record) ? script[received.filter(turn).indexOf(record)] ?? { text: '完毕。' } : { text: 'Presmium' }
60
- res.writeHead(200, { 'Content-Type': 'text/event-stream' }).end(stream(step, n))
61
- })
62
- })
63
- const port = await new Promise<number>(done => server.listen(0, '127.0.0.1', () => done((server.address() as AddressInfo).port)))
64
- return {
65
- url: `http://127.0.0.1:${port}`,
66
- received,
67
- hold() {
68
- held = new Promise(done => release = done)
69
- },
70
- release() {
71
- release()
72
- held = undefined
73
- },
74
- close: () => new Promise<void>((done) => {
75
- release()
76
- server.close(() => done())
77
- server.closeAllConnections()
78
- }),
79
- }
80
- }
81
-
82
- export async function recordingProxy(): Promise<{ url: string, attempts: string[], close(): Promise<void> }> {
83
- const attempts: string[] = []
84
- const server = createServer((req, res) => {
85
- attempts.push(`${req.method} ${req.url}`)
86
- res.writeHead(502).end()
87
- })
88
- server.on('connect', (req, socket) => {
89
- attempts.push(`CONNECT ${req.url}`)
90
- socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
91
- })
92
- const port = await new Promise<number>(done => server.listen(0, '127.0.0.1', () => done((server.address() as AddressInfo).port)))
93
- return {
94
- url: `http://127.0.0.1:${port}`,
95
- attempts,
96
- close: () => new Promise<void>((done) => {
97
- server.close(() => done())
98
- server.closeAllConnections()
99
- }),
100
- }
101
- }