@lincyaw/studio 0.1.1 → 0.2.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,7 +2,7 @@
2
2
 
3
3
  `presmium studio`: Presmium's workbench in one command. It starts [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (MIT) with Presmium's bundle, [`@lincyaw/dsh`](https://www.npmjs.com/package/@lincyaw/dsh), and the workspace service of a directory (`presmium serve`, from the `presmium` package it depends on), and prints the one address to open in the browser. Experimental (0.x): it installs one exact version of DeepSeek Harness, `0.2.1-alpha.4` (about 865 MB with its dependencies).
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
  ## Usage
8
8
 
@@ -17,11 +17,13 @@ In a project with `pnpm add -D @lincyaw/studio`, `pnpm exec presmium studio` is
17
17
 
18
18
  Sessions use the workspace root as their working directory and can read and write files throughout it. Opening another lecture changes document context, not the session or its directory.
19
19
 
20
- The model is chosen in the workbench's model settings, or given by the environment: `DEEPSEEK_API_KEY`, or `PRESMIUM_MODEL_URL`, `PRESMIUM_MODEL_KEY` and `PRESMIUM_MODEL_PROVIDER` for a Messages-compatible service. DeepSeek Harness's home for the workbench (its sessions, model settings and credentials, and the log `presmium-studio.log`) is `$XDG_STATE_HOME/presmium/studio`, never your own `~/.dsh`.
20
+ The model is chosen in the workbench's model settings, or given by the environment: `DEEPSEEK_API_KEY`, or `PRESMIUM_MODEL_URL`, `PRESMIUM_MODEL_KEY` and `PRESMIUM_MODEL_PROVIDER` for a Messages-compatible service. DeepSeek Harness's home for the workbench (its sessions, model settings and credentials, and the log) is `$XDG_STATE_HOME/presmium/studio`, never your own `~/.dsh`. Each installation (a version, or a checkout) has its own profile there, `profiles/presmium-studio-<hash>`, and its own log, `presmium-studio-<hash>.log`, so Studios of different installations can run at the same time without loading each other's code. Sessions and credentials are shared; a new installation's profile starts with the settings of the one used last.
21
+
22
+ Startup prints the log path to stderr and streams workspace-service logs there while running. The file keeps timestamped output from both processes. Preview logs identify the document, renderer, request, cache/fallback choice and elapsed time; export logs identify the job and phase. A backend preview marked ready means its address is prepared; browser loading and the preview handshake may still be pending. The host's output, including its login URL, stays in the log file.
21
23
 
22
24
  ## Documentation
23
25
 
24
- - [Design of the workbench on DeepSeek Harness](https://github.com/Lincyaw/presmium/blob/main/docs/design-dsh.md)
26
+ - [Design of the workbench on DeepSeek Harness](https://github.com/Lincyaw/presmium/blob/main/docs/studio.md)
25
27
 
26
28
  ## License
27
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/studio",
3
- "version": "0.1.1",
3
+ "version": "0.2.0-rc.1",
4
4
  "description": "presmium studio: Presmium's workbench on DeepSeek Harness in one command, with the workspace service of a directory (experimental)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -38,10 +38,10 @@
38
38
  "dependencies": {
39
39
  "@deepseek-ai/dsh": "0.2.1-alpha.4",
40
40
  "jiti": "^2.7.0",
41
- "@lincyaw/dsh": "0.1.0",
42
- "@lincyaw/presmium": "1.0.0-rc.0"
41
+ "@lincyaw/dsh": "0.2.0-rc.1",
42
+ "@lincyaw/presmium": "1.0.0-rc.2"
43
43
  },
44
44
  "devDependencies": {
45
- "@lincyaw/cli": "1.0.0-rc.0"
45
+ "@lincyaw/cli": "1.0.0-rc.2"
46
46
  }
47
47
  }
package/src/cli.ts CHANGED
@@ -1,52 +1,52 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import { parseArgs } from 'node:util'
4
- import { modelEnv } from './model.ts'
4
+ import { STUDIO_MESSAGES, systemLocale } from './messages.ts'
5
+ import { modelEnv } from './model-env.ts'
5
6
  import { startStudio } from './studio.ts'
6
7
 
7
- const USAGE = `Usage: presmium studio [dir] [options]
8
-
9
- The workbench of Presmium (experimental): DeepSeek Harness with Presmium's bundle, and the workspace
10
- service of the directory (the workspace root, or a deck's directory; default: .). It prints the one
11
- address to open in the browser. The model is chosen in the workbench's model settings, or given by the
12
- environment: DEEPSEEK_API_KEY, or PRESMIUM_MODEL_URL, PRESMIUM_MODEL_KEY and PRESMIUM_MODEL_PROVIDER for
13
- a Messages-compatible service. DeepSeek Harness's home (sessions, model settings and credentials, the
14
- log presmium-studio.log) is $XDG_STATE_HOME/presmium/studio.
15
-
16
- Options:
17
- --port <n> the workbench's port (default: 4320, or a free one when that is in use)
18
- --host <addr> listen on this address of the machine on the local network instead of 127.0.0.1, with
19
- no login: whoever reaches the port works as you
20
- -h, --help show this help`
8
+ const t = STUDIO_MESSAGES[systemLocale()]
21
9
 
22
10
  export interface MainOptions {
23
11
  presmium?: string
24
12
  }
25
13
 
14
+ function urlOf(option: string, value: string | undefined): string | undefined {
15
+ if (value === undefined) return undefined
16
+ try {
17
+ const url = new URL(value)
18
+ if (/^https?:$/.test(url.protocol) && !url.username && !url.password && !url.search && !url.hash) return url.href
19
+ } catch {}
20
+ throw new Error(t.badUrl(option, value))
21
+ }
22
+
26
23
  function portOf(value: string | undefined): number | undefined {
27
24
  if (value === undefined) return undefined
28
25
  const n = Number(value)
29
- if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(`--port takes a port number, not "${value}"`)
26
+ if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(t.badPort(value))
30
27
  return n
31
28
  }
32
29
 
33
30
  export async function main(argv: string[], options: MainOptions = {}): Promise<number> {
34
- let values: { port?: string, host?: string, help?: boolean }
31
+ let values: { port?: string, host?: string, 'public-url'?: string, 'preview-url'?: string, 'preview-port'?: string, help?: boolean }
35
32
  let dir: string
36
33
  try {
37
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { port: { type: 'string' }, host: { type: 'string' }, help: { type: 'boolean', short: 'h' } } })
34
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { port: { type: 'string' }, host: { type: 'string' }, 'public-url': { type: 'string' }, 'preview-url': { type: 'string' }, 'preview-port': { type: 'string' }, help: { type: 'boolean', short: 'h' } } })
38
35
  values = parsed.values
39
36
  const [given = '.', ...extra] = parsed.positionals
40
- if (extra.length > 0) throw new Error(`unexpected argument "${extra[0]}"`)
37
+ if (extra.length > 0) throw new Error(t.unexpectedArgument(extra[0]!))
41
38
  dir = resolve(given)
42
- if (!values.help && (!existsSync(dir) || !statSync(dir).isDirectory())) throw new Error(`${dir} is not a directory`)
39
+ if (!values.help && (!existsSync(dir) || !statSync(dir).isDirectory())) throw new Error(t.notDirectory(dir))
43
40
  portOf(values.port)
41
+ urlOf('--public-url', values['public-url'])
42
+ urlOf('--preview-url', values['preview-url'])
43
+ portOf(values['preview-port'])
44
44
  } catch (error) {
45
- process.stderr.write(`presmium studio: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}\n`)
45
+ process.stderr.write(`presmium studio: ${error instanceof Error ? error.message : String(error)}\n\n${t.usage}\n`)
46
46
  return 2
47
47
  }
48
48
  if (values.help) {
49
- process.stdout.write(`${USAGE}\n`)
49
+ process.stdout.write(`${t.usage}\n`)
50
50
  return 0
51
51
  }
52
52
 
@@ -55,14 +55,15 @@ export async function main(argv: string[], options: MainOptions = {}): Promise<n
55
55
 
56
56
  let studio
57
57
  try {
58
- studio = await startStudio({ dir, port: portOf(values.port), ...values.host ? { host: values.host } : {}, ...options.presmium ? { presmium: options.presmium } : {} })
58
+ studio = await startStudio({ dir, port: portOf(values.port), onServiceOutput: text => process.stderr.write(text), ...values.host ? { listenAddress: values.host } : {}, ...values['public-url'] ? { publicUrl: urlOf('--public-url', values['public-url']) } : {}, ...values['preview-url'] ? { previewUrl: urlOf('--preview-url', values['preview-url']) } : {}, ...values['preview-port'] ? { previewPort: portOf(values['preview-port']) } : {}, ...options.presmium ? { presmium: options.presmium } : {} })
59
59
  } catch (error) {
60
60
  process.stderr.write(`presmium studio: ${error instanceof Error ? error.message : String(error)}\n`)
61
61
  return 2
62
62
  }
63
63
  process.stdout.write(`studio ${studio.url}\n`)
64
- if (values.host && values.host !== '127.0.0.1' && values.host !== '::1') {
65
- process.stderr.write(`presmium studio: listening on ${values.host}, on the local network, with no login: anyone who reaches port ${new URL(studio.url).port} (and the previews' port ${new URL(studio.service).port}) with the address above works in this workspace as you (edits, runs code, uses your model). Use it on a network you trust; without --host it listens on this machine only.\n`)
64
+ process.stderr.write(`${t.log(studio.log)}\n`)
65
+ if (values.host && values.host !== '127.0.0.1' && values.host !== '::1' && !values['public-url']) {
66
+ process.stderr.write(`${t.openOnNetwork(values.host, new URL(studio.url).port, new URL(studio.workspaceServerUrl).port)}\n`)
66
67
  }
67
68
 
68
69
  const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
@@ -73,6 +74,6 @@ export async function main(argv: string[], options: MainOptions = {}): Promise<n
73
74
  for (const signal of signals) process.off(signal, stop)
74
75
  await studio.close()
75
76
  if (ended === undefined) return 0
76
- process.stderr.write(`presmium studio: ${ended}; the log is ${studio.log}\n`)
77
+ process.stderr.write(`${t.ended(ended, studio.log)}\n`)
77
78
  return 1
78
79
  }
package/src/index.ts CHANGED
@@ -1,6 +1,2 @@
1
1
  export { main } from './cli.ts'
2
2
  export type { MainOptions } from './cli.ts'
3
- export { DEFAULT_PORT, PROFILE, presmiumBin, startStudio, studioHome } from './studio.ts'
4
- export type { Studio, StudioOptions } from './studio.ts'
5
- export { modelEnv } from './model.ts'
6
- export type { ModelEnv } from './model.ts'
@@ -0,0 +1,96 @@
1
+ export { systemLocale } from '@lincyaw/dsh/launch'
2
+ // Only the `studio <url>` line on standard output is read by programs; it is the same in both languages.
3
+ export type Locale = 'zh-CN' | 'en'
4
+
5
+ export interface StudioMessages {
6
+ usage: string
7
+ badPort(value: string): string
8
+ badUrl(option: string, value: string): string
9
+ unexpectedArgument(argument: string): string
10
+ notDirectory(dir: string): string
11
+ log(file: string): string
12
+ openOnNetwork(address: string, port: string, previewPort: string): string
13
+ ended(how: string, log: string): string
14
+ portInUse(port: number, address: string): string
15
+ noFreePort(address: string): string
16
+ badHost(address: string): string
17
+ wholeLog(message: string, file: string): string
18
+ serviceEnded: string
19
+ appEnded: string
20
+ modelVariables(variables: readonly string[], unset: string[]): string
21
+ }
22
+
23
+ export const STUDIO_MESSAGES: Record<Locale, StudioMessages> = {
24
+ 'zh-CN': {
25
+ usage: `用法:presmium studio [<目录>] [<选项>]
26
+
27
+ 启动 Presmium Studio(实验性):在浏览器中编辑这个目录(工作区的根目录或一份文稿所在的目录;
28
+ 默认为 .)中的文稿,并为它提供工作区服务。只打印一个要在浏览器中打开的网址。AI 助手所用的模型在
29
+ “设置 → 模型”中设置,也可以由环境变量给出:DEEPSEEK_API_KEY,或者兼容 Messages 接口的服务的
30
+ PRESMIUM_MODEL_URL、PRESMIUM_MODEL_KEY 与 PRESMIUM_MODEL_PROVIDER。对话、模型设置、密钥与日志
31
+ presmium-studio-<散列>.log(每个安装一个)保存在 $XDG_STATE_HOME/presmium/studio 中。
32
+
33
+ 选项:
34
+ --port <端口> Presmium Studio 的端口(默认为 4320,被占用时取空闲端口)
35
+ --host <地址> 改为监听本机在局域网中的这个地址,而不是 127.0.0.1,且没有登录:
36
+ 任何能访问这个端口的人都以你的身份操作
37
+ --public-url <网址> 有反向代理在前面时浏览器打开的根地址(例如 Presmium 云端);
38
+ 它的主机受信任,打印的网址用它。代理需保留 Host 并转发 WebSocket
39
+ --preview-url <网址> 同样情况下预览所在的地址,由代理转发到工作区服务;可以带路径,
40
+ 此时它必须与 Studio 不同源,否则预览中文稿的代码能读到 Studio 的会话
41
+ --preview-port <端口> 工作区服务的端口(默认为空闲端口),供需要事先知道端口的代理使用
42
+ -h, --help 显示本说明`,
43
+ badPort: value => `--port 需要 0 到 65535 之间的端口号,而不是“${value}”。`,
44
+ badUrl: (option, value) => `${option} 需要一个完整的网址,例如 https://studio.example.com/,而不是“${value}”。`,
45
+ unexpectedArgument: argument => `多余的参数“${argument}”。只能给出一个目录。`,
46
+ notDirectory: dir => `${dir} 不是目录。请给出工作区的根目录或一份文稿所在的目录。`,
47
+ log: file => `presmium studio: 日志在 ${file}`,
48
+ openOnNetwork: (address, port, previewPort) => `presmium studio: 正在 ${address} 上监听,局域网中的设备可以访问,且没有登录:任何能用上面的网址访问端口 ${port}(以及预览的端口 ${previewPort})的人都以你的身份使用这个工作区(编辑、运行代码、使用你的模型)。请只在可信的网络中使用;不加 --host 时只在本机监听。`,
49
+ ended: (how, log) => `presmium studio: ${how}。日志在 ${log}`,
50
+ portInUse: (port, address) => `${address} 的端口 ${port} 已被占用。请用 --port 指定另一个端口。`,
51
+ noFreePort: address => `${address} 上没有空闲的端口。请停止占用端口的服务后重试。`,
52
+ badHost: address => `--host 需要本机的一个地址,例如 192.168.0.21,而不是“${address}”(Presmium Studio 只在一个确定的地址上监听,不能同时监听全部地址)。`,
53
+ wholeLog: (message, file) => `${message}${message.endsWith('\n') ? '' : '。'}完整的日志在 ${file}`,
54
+ serviceEnded: '工作区服务(presmium serve)已停止',
55
+ appEnded: 'Presmium Studio 的应用进程已停止',
56
+ modelVariables: (variables, unset) => `${variables.join('、')} 需要同时设置;没有设置 ${unset.join('、')}。`,
57
+ },
58
+ 'en': {
59
+ usage: `Usage: presmium studio [dir] [options]
60
+
61
+ Presmium Studio (experimental): edit the documents of the directory (the workspace root, or a deck’s
62
+ directory; default: .) in the browser, with the workspace service of that directory. It prints the one
63
+ address to open in the browser. The AI Assistant’s model is chosen in Settings > Models, or given by the
64
+ environment: DEEPSEEK_API_KEY, or PRESMIUM_MODEL_URL, PRESMIUM_MODEL_KEY, and PRESMIUM_MODEL_PROVIDER
65
+ for a Messages-compatible service. Chats, model settings, API keys, and the log
66
+ presmium-studio-<hash>.log (one per installation) are kept in $XDG_STATE_HOME/presmium/studio.
67
+
68
+ Options:
69
+ --port <n> Presmium Studio’s port (default: 4320, or a free one when that is in use)
70
+ --host <addr> listen on this address of the machine on the local network instead of 127.0.0.1, with
71
+ no login: whoever reaches the port works as you
72
+ --public-url <url> the root browsers open when a reverse proxy fronts Studio (as in Presmium
73
+ Cloud): its host is trusted, and the printed address uses it. The proxy keeps Host and
74
+ forwards WebSockets
75
+ --preview-url <url> in that case, the address of the previews, forwarded to the workspace service; it
76
+ may have a path, and should be another origin than Studio’s, or the documents’ code in
77
+ previews can read Studio’s session
78
+ --preview-port <n> the workspace service’s port (default: a free one), for a proxy that needs it in advance
79
+ -h, --help show this help`,
80
+ badPort: value => `--port takes a port number from 0 to 65535, not “${value}”.`,
81
+ badUrl: (option, value) => `${option} takes a full address, such as https://studio.example.com/, not “${value}”.`,
82
+ unexpectedArgument: argument => `Unexpected argument “${argument}”. Give one directory.`,
83
+ notDirectory: dir => `${dir} is not a directory. Give the workspace root, or the directory of a document.`,
84
+ log: file => `presmium studio: log ${file}`,
85
+ openOnNetwork: (address, port, previewPort) => `presmium studio: listening on ${address}, on the local network, with no login: anyone who reaches port ${port} (and the previews’ port ${previewPort}) with the address above works in this workspace as you (edits, runs code, uses your model). Use it on a network you trust; without --host it listens on this machine only.`,
86
+ ended: (how, log) => `presmium studio: ${how}; the log is ${log}`,
87
+ portInUse: (port, address) => `Port ${port} of ${address} is in use. Give another with --port.`,
88
+ noFreePort: address => `There is no free port on ${address}. Stop a service that uses one, then retry.`,
89
+ badHost: address => `--host takes one address of this machine, such as 192.168.0.21, not “${address}” (Presmium Studio listens on one concrete address, never on all of them).`,
90
+ wholeLog: (message, file) => `${message.endsWith('\n') ? message : `${message}; `}the whole log is ${file}`,
91
+ serviceEnded: 'the workspace service (presmium serve) ended',
92
+ appEnded: 'the Presmium Studio app ended',
93
+ modelVariables: (variables, unset) => `${variables.join(', ')} go together; ${unset.join(' and ')} ${unset.length > 1 ? 'are' : 'is'} not set.`,
94
+ },
95
+ }
96
+
@@ -1,3 +1,5 @@
1
+ import { STUDIO_MESSAGES, systemLocale } from './messages.ts'
2
+
1
3
  export const MODEL_VARIABLES = ['PRESMIUM_MODEL_URL', 'PRESMIUM_MODEL_KEY', 'PRESMIUM_MODEL_PROVIDER'] as const
2
4
 
3
5
  export interface ModelEnv {
@@ -8,13 +10,13 @@ export interface ModelEnv {
8
10
 
9
11
  const searchUrl = (url: string) => `${url.replace(/\/+$/, '').replace(/\/v1$/, '')}/v1`
10
12
 
11
- const service = (url: string) => ({ DEEPSEEK_BASE_URL: url, DEEPSEEK_SEARCH_BASE_URL: searchUrl(url) })
13
+ const deepseekEndpoints = (url: string) => ({ DEEPSEEK_BASE_URL: url, DEEPSEEK_SEARCH_BASE_URL: searchUrl(url) })
12
14
 
13
15
  export function modelEnv(env: NodeJS.ProcessEnv): ModelEnv {
14
16
  const [url, key, provider] = MODEL_VARIABLES.map(name => env[name]?.trim() || undefined)
15
- if (url && key && provider) return { env: { ...service(url), DEEPSEEK_API_KEY: key }, from: [...MODEL_VARIABLES] }
17
+ if (url && key && provider) return { env: { ...deepseekEndpoints(url), DEEPSEEK_API_KEY: key }, from: [...MODEL_VARIABLES] }
16
18
  const unset = MODEL_VARIABLES.filter((_, i) => ![url, key, provider][i])
17
- const warning = unset.length < MODEL_VARIABLES.length ? { warning: `${MODEL_VARIABLES.join(', ')} go together; ${unset.join(' and ')} ${unset.length > 1 ? 'are' : 'is'} not set` } : {}
19
+ const warning = unset.length < MODEL_VARIABLES.length ? { warning: STUDIO_MESSAGES[systemLocale(env)].modelVariables(MODEL_VARIABLES, unset) } : {}
18
20
  const deepseek = env.DEEPSEEK_API_KEY?.trim()
19
21
  if (deepseek) return { env: { DEEPSEEK_API_KEY: deepseek }, from: ['DEEPSEEK_API_KEY'], ...warning }
20
22
  return { env: {}, from: [], ...warning }
package/src/studio.ts CHANGED
@@ -1,20 +1,18 @@
1
- import { spawn } from 'node:child_process'
2
- import type { ChildProcess } from 'node:child_process'
3
- import { randomBytes } from 'node:crypto'
4
- import { closeSync, mkdirSync, openSync, renameSync, writeSync } from 'node:fs'
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'
5
3
  import { createRequire } from 'node:module'
6
4
  import { createServer, isIP } from 'node:net'
7
5
  import type { AddressInfo } from 'node:net'
8
6
  import { homedir } from 'node:os'
9
7
  import { dirname, join, resolve } from 'node:path'
10
8
  import { fileURLToPath } from 'node:url'
11
- import { dshBin, launchArgs, launchEnv, WATCHDOG, WEB_READY, writeProfile } from '@lincyaw/dsh/launch'
12
- import { modelEnv } from './model.ts'
9
+ import { bracketIPv6, dshBin, inheritedEnv, isLauncherVariable, launchArgs, launchEnv, launchNode, launchWorkspaceServer, WEB_READY, writeProfile } from '@lincyaw/dsh/launch'
10
+ import type { LaunchedProcess } from '@lincyaw/dsh/launch'
11
+ import { STUDIO_MESSAGES, systemLocale } from './messages.ts'
12
+ import { modelEnv } from './model-env.ts'
13
13
 
14
- export const PROFILE = 'presmium-studio'
15
- export const DEFAULT_PORT = 4320
16
- const START_MS = 180_000
17
- const STOP_MS = 15_000
14
+ const PROFILE = 'presmium-studio'
15
+ const DEFAULT_PORT = 4320
18
16
  const LOG_BYTES = 16 << 20
19
17
 
20
18
  const PACKAGE_DIR = fileURLToPath(new URL('..', import.meta.url))
@@ -22,60 +20,95 @@ const PACKAGE_DIR = fileURLToPath(new URL('..', import.meta.url))
22
20
  export interface StudioOptions {
23
21
  dir: string
24
22
  port?: number
25
- host?: string
23
+ /** `--host`: one address of this machine to listen on instead of 127.0.0.1. */
24
+ listenAddress?: string
25
+ /**
26
+ * When a reverse proxy fronts Studio (Presmium Cloud): the root browsers open, and the origin of the workspace
27
+ * service's previews. Their hosts are trusted and advertised; the proxy keeps `Host` and forwards upgrades.
28
+ */
29
+ publicUrl?: string
30
+ previewUrl?: string
31
+ /** The workspace service's port (default: a free one), for a proxy that needs it in advance. */
32
+ previewPort?: number
26
33
  env?: NodeJS.ProcessEnv
27
34
  presmium?: string
35
+ /** Live workspace-service output; the complete host output is also kept in `Studio.log`. */
36
+ onServiceOutput?(text: string): void
28
37
  }
29
38
 
30
39
  export interface Studio {
31
40
  url: string
32
- service: string
41
+ workspaceServerUrl: string
33
42
  root: string
34
43
  home: string
35
44
  log: string
45
+ /** `<home>/<profile>.json`: `url` (with its launch token), `workspace`, `workspaceServerUrl`, `pid`; 0600, removed on close. */
46
+ state: string
36
47
  ended: Promise<string>
37
48
  close(): Promise<void>
38
49
  }
39
50
 
40
- export function studioHome(env: NodeJS.ProcessEnv = process.env): string {
51
+ function studioHome(env: NodeJS.ProcessEnv = process.env): string {
41
52
  return join(env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state'), 'presmium', 'studio')
42
53
  }
43
54
 
44
- export function presmiumBin(): string {
45
- return join(dirname(createRequire(join(PACKAGE_DIR, 'package.json')).resolve('@lincyaw/presmium/package.json')), 'bin', 'presmium.mjs')
55
+ /** The bundle `@lincyaw/dsh` this package depends on: the directory the profile links to. */
56
+ function bundleDir(): string {
57
+ return realpathSync(dirname(createRequire(join(PACKAGE_DIR, 'package.json')).resolve('@lincyaw/dsh/package.json')))
46
58
  }
47
59
 
48
- const urlHost = (host: string) => (host.includes(':') ? `[${host}]` : host)
60
+ /**
61
+ * The profile of this installation, `presmium-studio-<hash of the bundle's directory>`: its `node_modules` links to
62
+ * this installation's bundle, so Studios of other checkouts or versions sharing the home never repoint it. A new
63
+ * one starts with the author's settings, the profile patch of the Studio profile used last (the earlier single
64
+ * profile `presmium-studio` included); sessions and credentials are in the shared home, and its log is `<profile>.log` there.
65
+ */
66
+ function installProfile(home: string, bundle: string): string {
67
+ const name = `${PROFILE}-${createHash('sha256').update(bundle).digest('hex').slice(0, 10)}`
68
+ const profiles = join(home, 'profiles')
69
+ const patch = join(profiles, name, 'cordis.patch.yml')
70
+ if (!existsSync(patch)) {
71
+ const last = (existsSync(profiles) ? readdirSync(profiles) : [])
72
+ .filter(other => other === PROFILE || other.startsWith(`${PROFILE}-`))
73
+ .map(other => join(profiles, other, 'cordis.patch.yml'))
74
+ .filter(file => existsSync(file))
75
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]
76
+ if (last) {
77
+ mkdirSync(dirname(patch), { recursive: true })
78
+ copyFileSync(last, patch)
79
+ }
80
+ }
81
+ return name
82
+ }
83
+
84
+ /** The `presmium` command of the `@lincyaw/presmium` package this one depends on. */
85
+ function presmiumBin(): string {
86
+ return join(dirname(createRequire(join(PACKAGE_DIR, 'package.json')).resolve('@lincyaw/presmium/package.json')), 'bin', 'presmium.mjs')
87
+ }
49
88
 
50
89
  const WILDCARD = /^(?:0\.0\.0\.0|::|(?:0+:){7}0+|::ffff:0\.0\.0\.0)$/i
51
90
 
52
- function freePort(host: string, port: number): Promise<number | undefined> {
91
+ function freePort(listenAddress: string, port: number): Promise<number | undefined> {
53
92
  return new Promise((done) => {
54
93
  const server = createServer()
55
94
  server.once('error', () => done(undefined))
56
- server.listen(port, host, () => {
95
+ server.listen(port, listenAddress, () => {
57
96
  const bound = (server.address() as AddressInfo).port
58
97
  server.close(() => done(bound))
59
98
  })
60
99
  })
61
100
  }
62
101
 
63
- async function choosePort(host: string, port: number | undefined): Promise<number> {
102
+ async function choosePort(listenAddress: string, port: number | undefined): Promise<number> {
64
103
  if (port) {
65
- if (await freePort(host, port) === undefined) throw new Error(`port ${port} of ${host} is in use: give another with --port`)
104
+ if (await freePort(listenAddress, port) === undefined) throw new Error(STUDIO_MESSAGES[systemLocale()].portInUse(port, listenAddress))
66
105
  return port
67
106
  }
68
- const chosen = (port === undefined ? await freePort(host, DEFAULT_PORT) : undefined) ?? await freePort(host, 0)
69
- if (chosen === undefined) throw new Error(`no free port on ${host}`)
107
+ const chosen = (port === undefined ? await freePort(listenAddress, DEFAULT_PORT) : undefined) ?? await freePort(listenAddress, 0)
108
+ if (chosen === undefined) throw new Error(STUDIO_MESSAGES[systemLocale()].noFreePort(listenAddress))
70
109
  return chosen
71
110
  }
72
111
 
73
- function inherited(env: NodeJS.ProcessEnv, drop: (name: string) => boolean): Record<string, string> {
74
- return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined && !drop(entry[0])))
75
- }
76
-
77
- const OWN = (name: string) => /^(?:DSH_|PRESMIUM_MODEL_|PRESMIUM_SERVICE_)/.test(name) || ['PRESMIUM_WORKSPACE', 'NODE_ENV', 'PORT'].includes(name)
78
-
79
112
  class Log {
80
113
  readonly file: string
81
114
  #fd: number
@@ -92,7 +125,7 @@ class Log {
92
125
  const lines = ((this.#rest.get(name) ?? '') + text).split('\n')
93
126
  this.#rest.set(name, lines.pop()!)
94
127
  if (lines.length === 0) return
95
- const out = lines.map(line => `[${name}] ${line}\n`).join('')
128
+ const out = lines.map(line => `${new Date().toISOString()} [${name}] ${line}\n`).join('')
96
129
  if (this.#bytes + out.length > LOG_BYTES) {
97
130
  closeSync(this.#fd)
98
131
  renameSync(this.file, `${this.file}.1`)
@@ -110,141 +143,84 @@ class Log {
110
143
  }
111
144
  }
112
145
 
113
- interface Supervised {
114
- child: ChildProcess
115
- ready(pattern: RegExp): Promise<RegExpExecArray>
116
- started(): void
117
- exited: Promise<void>
118
- stop(): Promise<void>
119
- }
120
-
121
- function supervise(name: string, args: string[], options: { cwd: string, env: Record<string, string>, log: Log }): Supervised {
122
- const child = spawn(process.execPath, [WATCHDOG, process.execPath, ...args], { cwd: options.cwd, env: options.env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] })
123
- child.stdin!.on('error', () => {})
124
- let output: string | undefined = ''
125
- const waiting = new Set<() => void>()
126
- const read = (chunk: Buffer) => {
127
- const text = chunk.toString()
128
- options.log.write(name, text)
129
- if (output === undefined) return
130
- output += text
131
- for (const check of waiting) check()
132
- }
133
- child.stdout!.on('data', read)
134
- child.stderr!.on('data', read)
135
- let ended = false
136
- const exited = new Promise<void>(done => child.once('exit', () => {
137
- ended = true
138
- done()
139
- }))
140
- return {
141
- child,
142
- exited,
143
- ready(pattern) {
144
- return new Promise((resolveReady, reject) => {
145
- const tail = () => (output ?? '').trimEnd().split('\n').slice(-12).map(line => ` ${line}`).join('\n')
146
- const timer = setTimeout(() => finish(new Error(`${name} did not start within ${START_MS / 1000} s:\n${tail()}\n`)), START_MS)
147
- const check = () => {
148
- const match = pattern.exec(output ?? '')
149
- if (match) finish(match)
150
- }
151
- const finish = (result: RegExpExecArray | Error) => {
152
- clearTimeout(timer)
153
- waiting.delete(check)
154
- if (result instanceof Error) reject(result)
155
- else resolveReady(result)
156
- }
157
- waiting.add(check)
158
- void exited.then(() => finish(new Error(`${name} ended before it was ready:\n${tail()}\n`)))
159
- check()
160
- })
161
- },
162
- started() {
163
- output = undefined
164
- },
165
- async stop() {
166
- if (ended) return
167
- child.kill('SIGTERM')
168
- const timer = setTimeout(() => {
169
- try {
170
- process.kill(-child.pid!, 'SIGKILL')
171
- } catch {
172
- }
173
- }, STOP_MS)
174
- await exited
175
- clearTimeout(timer)
176
- },
177
- }
178
- }
179
-
180
146
  export async function startStudio(options: StudioOptions): Promise<Studio> {
181
147
  const env = options.env ?? process.env
182
- const host = options.host
183
- if (host !== undefined && (isIP(host) === 0 || WILDCARD.test(host))) {
184
- throw new Error(`--host takes one address of this machine, such as 192.168.0.21, not "${host}" (DeepSeek Harness listens on one concrete address, never on all of them)`)
148
+ const t = STUDIO_MESSAGES[systemLocale(env)]
149
+ const { listenAddress } = options
150
+ if (listenAddress !== undefined && (isIP(listenAddress) === 0 || WILDCARD.test(listenAddress))) {
151
+ throw new Error(t.badHost(listenAddress))
185
152
  }
186
- const port = await choosePort(host ?? '127.0.0.1', options.port)
187
- const origins = [`http://${urlHost(host ?? '127.0.0.1')}:${port}`, ...!host || host === '127.0.0.1' ? [`http://localhost:${port}`] : []]
153
+ const port = await choosePort(listenAddress ?? '127.0.0.1', options.port)
154
+ const origins = options.publicUrl ? [new URL(options.publicUrl).origin] : [`http://${bracketIPv6(listenAddress ?? '127.0.0.1')}:${port}`, ...!listenAddress || listenAddress === '127.0.0.1' ? [`http://localhost:${port}`] : []]
188
155
 
189
156
  const home = studioHome(env)
190
157
  mkdirSync(home, { recursive: true, mode: 0o700 })
191
- writeProfile(home, PROFILE, 'web')
192
- const log = new Log(join(home, 'presmium-studio.log'))
193
- const token = randomBytes(24).toString('base64url')
194
- const started: Supervised[] = []
158
+ const bundle = bundleDir()
159
+ const profile = installProfile(home, bundle)
160
+ writeProfile(home, profile, 'web', bundle)
161
+ const log = new Log(join(home, `${profile}.log`))
162
+ const serviceToken = randomBytes(24).toString('base64url')
163
+ const started: LaunchedProcess[] = []
195
164
  const stopAll = async () => {
196
- for (const process_ of started.reverse()) await process_.stop()
165
+ for (const launched of started.reverse()) await launched.stop()
197
166
  started.length = 0
198
167
  }
199
168
 
200
169
  try {
201
- const service = supervise('serve', [
202
- options.presmium ?? presmiumBin(), 'serve', resolve(options.dir), '--port', '0',
203
- ...host ? ['--host', host] : [],
204
- ...origins.flatMap(origin => ['--preview-origin', origin]),
205
- ], {
206
- cwd: resolve(options.dir),
207
- env: { ...inherited(env, name => OWN(name) || name.startsWith('DEEPSEEK_')), PRESMIUM_SERVICE_TOKEN: token },
208
- log,
170
+ const server = launchWorkspaceServer({
171
+ presmium: options.presmium ?? presmiumBin(),
172
+ dir: resolve(options.dir),
173
+ serviceToken,
174
+ ...options.previewPort ? { port: options.previewPort } : {},
175
+ args: [
176
+ ...listenAddress ? ['--host', listenAddress] : [],
177
+ ...origins.flatMap(origin => ['--preview-origin', origin]),
178
+ ...options.previewUrl ? ['--public-url', options.previewUrl] : [],
179
+ ],
180
+ env,
181
+ onOutput: text => { log.write('serve', text); options.onServiceOutput?.(text) },
209
182
  })
210
- started.push(service)
211
- const url = (await service.ready(/^serve {2}(http\S+)$/m))[1]!
212
- const root = (await service.ready(/^workspace {2}(.+)$/m))[1]!.trim()
213
- service.started()
183
+ started.push(server.process)
184
+ const { url, root } = await server.ready
214
185
 
215
- const dsh = supervise('dsh', [
216
- dshBin(PACKAGE_DIR), ...launchArgs(PROFILE, 'web'), '--port', String(port), '--no-open',
217
- ...host ? ['--host', host, '--trusted-host', `${urlHost(host)}:${port}`] : [],
186
+ const dsh = launchNode('DeepSeek Harness', [
187
+ dshBin(PACKAGE_DIR), ...launchArgs(profile, 'web'), '--port', String(port), '--no-open',
188
+ ...listenAddress ? ['--host', listenAddress, '--trusted-host', `${bracketIPv6(listenAddress)}:${port}`] : [],
189
+ ...options.publicUrl ? ['--public-url', options.publicUrl, '--trusted-host', new URL(options.publicUrl).host] : [],
218
190
  ], {
219
191
  cwd: root,
220
192
  env: {
221
- ...inherited(env, name => OWN(name) || ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL'].includes(name)),
193
+ ...inheritedEnv(env, name => isLauncherVariable(name) || ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL'].includes(name)),
222
194
  ...modelEnv(env).env,
223
- ...launchEnv(home),
195
+ ...launchEnv(home, env),
224
196
  PRESMIUM_SERVICE_URL: url,
225
- PRESMIUM_SERVICE_TOKEN: token,
197
+ PRESMIUM_SERVICE_TOKEN: serviceToken,
226
198
  PRESMIUM_WORKSPACE: root,
227
199
  },
228
- log,
200
+ onOutput: text => log.write('dsh', text),
229
201
  })
230
202
  started.push(dsh)
231
203
  const address = (await dsh.ready(WEB_READY))[1]!
232
- dsh.started()
204
+ dsh.stopBuffering()
205
+ // The address with its launch token, for a host that opens Studio for the user (0600, removed on close).
206
+ const state = join(home, `${profile}.json`)
207
+ writeFileSync(state, `${JSON.stringify({ url: address, workspace: root, workspaceServerUrl: url, pid: process.pid }, null, 2)}\n`, { mode: 0o600 })
233
208
 
234
209
  let closing: Promise<void> | undefined
235
210
  const ended = Promise.race([
236
- service.exited.then(() => 'the workspace service (presmium serve) ended'),
237
- dsh.exited.then(() => 'DeepSeek Harness ended'),
211
+ server.process.exited.then(() => t.serviceEnded),
212
+ dsh.exited.then(() => t.appEnded),
238
213
  ]).then(how => (closing ? new Promise<string>(() => {}) : how))
239
214
  return {
240
215
  url: address,
241
- service: url,
216
+ workspaceServerUrl: url,
242
217
  root,
243
218
  home,
244
219
  log: log.file,
245
220
  ended,
221
+ state,
246
222
  close() {
247
- closing ??= stopAll().finally(() => log.close())
223
+ closing ??= stopAll().finally(() => { rmSync(state, { force: true }); log.close() })
248
224
  return closing
249
225
  },
250
226
  }
@@ -252,6 +228,6 @@ export async function startStudio(options: StudioOptions): Promise<Studio> {
252
228
  await stopAll()
253
229
  log.close()
254
230
  const message = error instanceof Error ? error.message : String(error)
255
- throw new Error(`${message.endsWith('\n') ? message : `${message}; `}the whole log is ${log.file}`)
231
+ throw new Error(t.wholeLog(message, log.file))
256
232
  }
257
233
  }