@lincyaw/studio 0.1.1 → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +4 -4
- package/src/cli.ts +27 -26
- package/src/index.ts +0 -4
- package/src/messages.ts +96 -0
- package/src/{model.ts → model-env.ts} +5 -3
- package/src/studio.ts +107 -131
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
|
|
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
|
|
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/
|
|
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.
|
|
3
|
+
"version": "0.2.0-rc.0",
|
|
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/
|
|
42
|
-
"@lincyaw/
|
|
41
|
+
"@lincyaw/presmium": "1.0.0-rc.1",
|
|
42
|
+
"@lincyaw/dsh": "0.2.0-rc.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@lincyaw/cli": "1.0.0-rc.
|
|
45
|
+
"@lincyaw/cli": "1.0.0-rc.1"
|
|
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 {
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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${
|
|
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(`${
|
|
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 ? {
|
|
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
|
-
|
|
65
|
-
|
|
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(
|
|
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'
|
package/src/messages.ts
ADDED
|
@@ -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
|
|
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: { ...
|
|
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:
|
|
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 {
|
|
2
|
-
import
|
|
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,
|
|
12
|
-
import {
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
102
|
+
async function choosePort(listenAddress: string, port: number | undefined): Promise<number> {
|
|
64
103
|
if (port) {
|
|
65
|
-
if (await freePort(
|
|
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(
|
|
69
|
-
if (chosen === undefined) throw new Error(
|
|
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 =>
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
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(
|
|
187
|
-
const origins = [`http://${
|
|
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
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
const
|
|
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
|
|
165
|
+
for (const launched of started.reverse()) await launched.stop()
|
|
197
166
|
started.length = 0
|
|
198
167
|
}
|
|
199
168
|
|
|
200
169
|
try {
|
|
201
|
-
const
|
|
202
|
-
options.presmium ?? presmiumBin(),
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
211
|
-
const url =
|
|
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 =
|
|
216
|
-
dshBin(PACKAGE_DIR), ...launchArgs(
|
|
217
|
-
...
|
|
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
|
-
...
|
|
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:
|
|
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.
|
|
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
|
-
|
|
237
|
-
dsh.exited.then(() =>
|
|
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
|
-
|
|
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(
|
|
231
|
+
throw new Error(t.wholeLog(message, log.file))
|
|
256
232
|
}
|
|
257
233
|
}
|