@lincyaw/addon-exec-local 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +30 -2
- package/components/PmLocalEnv.css +48 -0
- package/components/PmLocalEnv.tsx +190 -0
- package/package.json +47 -3
- package/src/client/index.ts +5 -0
- package/src/client/local-env.ts +68 -0
- package/src/client/messages.ts +50 -0
- package/src/client/plugin.ts +39 -0
- package/src/domain/config.ts +25 -0
- package/src/domain/index.ts +6 -0
- package/src/domain/service.ts +36 -0
- package/src/domain/workspace.ts +32 -0
- package/src/node/container.ts +163 -0
- package/src/node/devcontainer.ts +165 -0
- package/src/node/docker.ts +36 -0
- package/src/node/local-env.ts +112 -0
- package/src/node/messages.ts +80 -0
- package/src/node/presmium.ts +53 -0
- package/src/node/process.ts +68 -0
- package/src/node/replicate.ts +30 -0
- package/src/node/server.ts +116 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { deckLocale } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
const zhCN = {
|
|
5
|
+
codeChild: 'PmLocalEnv 必须有且只有一个 canonical 代码子块。',
|
|
6
|
+
noDefinition: '工作区没有 devcontainer 定义',
|
|
7
|
+
unreadableDefinition: '无法读取 devcontainer 定义,请确认 Docker 可用',
|
|
8
|
+
unsupportedLanguage: (lang: string) => `容器执行器不支持语言 ${lang}`,
|
|
9
|
+
buildFailed: (detail: string) => `devcontainer build 失败:${detail}`,
|
|
10
|
+
outsideWorkspace: (file: string) => `路径必须位于工作区内:${file}`,
|
|
11
|
+
notJson: '请求体必须是 JSON',
|
|
12
|
+
invalidJson: '请求体不是有效的 JSON',
|
|
13
|
+
notObject: '请求体必须是 JSON 对象',
|
|
14
|
+
missingField: (key: string) => `缺少字段 ${key}`,
|
|
15
|
+
notString: (key: string) => `字段 ${key} 必须是字符串`,
|
|
16
|
+
notStringTable: (key: string) => `字段 ${key} 必须是字符串表`,
|
|
17
|
+
runTitle: (lang: string) => `在本地环境的容器中运行一段 ${lang} 代码`,
|
|
18
|
+
notAllowed: '没有得到运行的许可',
|
|
19
|
+
readme: (file: string, run?: string) => [
|
|
20
|
+
'# Presmium 导出的运行环境',
|
|
21
|
+
'',
|
|
22
|
+
`本压缩包包含演示中的代码片段 \`${file}\` 与仓库的 devcontainer 定义。`,
|
|
23
|
+
'',
|
|
24
|
+
'用 Dev Containers CLI 打开并运行:',
|
|
25
|
+
'',
|
|
26
|
+
'```sh',
|
|
27
|
+
'devcontainer up --workspace-folder .',
|
|
28
|
+
`devcontainer exec --workspace-folder . ${run ?? `<运行 ${file} 的命令>`}`,
|
|
29
|
+
'```',
|
|
30
|
+
'',
|
|
31
|
+
'也可以在 VS Code 中打开本目录,选择“Reopen in Container”。',
|
|
32
|
+
'',
|
|
33
|
+
].join('\n'),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ServerMessages = typeof zhCN
|
|
37
|
+
|
|
38
|
+
const en: ServerMessages = {
|
|
39
|
+
codeChild: 'PmLocalEnv requires exactly one canonical code child.',
|
|
40
|
+
noDefinition: 'The workspace has no devcontainer definition',
|
|
41
|
+
unreadableDefinition: 'Cannot read the devcontainer definition; check that Docker is available',
|
|
42
|
+
unsupportedLanguage: lang => `The container executor does not run ${lang}`,
|
|
43
|
+
buildFailed: detail => `devcontainer build failed: ${detail}`,
|
|
44
|
+
outsideWorkspace: file => `The path must be inside the workspace: ${file}`,
|
|
45
|
+
notJson: 'The request body must be JSON',
|
|
46
|
+
invalidJson: 'The request body is not valid JSON',
|
|
47
|
+
notObject: 'The request body must be a JSON object',
|
|
48
|
+
missingField: key => `Missing field ${key}`,
|
|
49
|
+
notString: key => `Field ${key} must be a string`,
|
|
50
|
+
notStringTable: key => `Field ${key} must map names to strings`,
|
|
51
|
+
runTitle: lang => `Run ${lang} code in the local environment's container`,
|
|
52
|
+
notAllowed: 'The run was not allowed',
|
|
53
|
+
readme: (file, run) => [
|
|
54
|
+
'# Environment exported by Presmium',
|
|
55
|
+
'',
|
|
56
|
+
`This archive holds the snippet \`${file}\` from the presentation and the repository's devcontainer definition.`,
|
|
57
|
+
'',
|
|
58
|
+
'Open and run it with the Dev Containers CLI:',
|
|
59
|
+
'',
|
|
60
|
+
'```sh',
|
|
61
|
+
'devcontainer up --workspace-folder .',
|
|
62
|
+
`devcontainer exec --workspace-folder . ${run ?? `<command that runs ${file}>`}`,
|
|
63
|
+
'```',
|
|
64
|
+
'',
|
|
65
|
+
'You can also open this folder in VS Code and choose "Reopen in Container".',
|
|
66
|
+
'',
|
|
67
|
+
].join('\n'),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MESSAGES = { 'zh-CN': zhCN, en }
|
|
71
|
+
|
|
72
|
+
const requested = new AsyncLocalStorage<ServerMessages>()
|
|
73
|
+
|
|
74
|
+
export function inLanguage<T>(language: string | undefined, task: () => T): T {
|
|
75
|
+
return requested.run(MESSAGES[deckLocale(language?.trim())], task)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function text(): ServerMessages {
|
|
79
|
+
return requested.getStore() ?? en
|
|
80
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { componentCode, environmentExecutor, jupyterKernelLanguage, languageId } from '@lincyaw/kernel'
|
|
4
|
+
import type { EnvironmentProvider, EnvironmentSpec, PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
5
|
+
import { DEVCONTAINER_KIND, EXEC_LOCAL_ID } from '../domain/index.ts'
|
|
6
|
+
import { createContainerExecutor } from './container.ts'
|
|
7
|
+
import { findDefinition } from './devcontainer.ts'
|
|
8
|
+
import { dockerAvailable } from './docker.ts'
|
|
9
|
+
import { localEnvDirective, localEnvPass } from './local-env.ts'
|
|
10
|
+
import { localEnvRoutes } from './server.ts'
|
|
11
|
+
|
|
12
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
13
|
+
|
|
14
|
+
/** The plugin's executor, which each compile replaces with its deck's own (`localEnvPass`). */
|
|
15
|
+
const container = createContainerExecutor()
|
|
16
|
+
|
|
17
|
+
const definitionDir = (spec: EnvironmentSpec, deckDir: string, workspaceRoot: string) => typeof spec.path === 'string' && spec.path ? resolve(deckDir, spec.path) : workspaceRoot
|
|
18
|
+
|
|
19
|
+
const devcontainer: EnvironmentProvider = {
|
|
20
|
+
create(name, spec, ctx) {
|
|
21
|
+
const executor = createContainerExecutor(environmentExecutor(name))
|
|
22
|
+
executor.bind(definitionDir(spec, ctx.deckDir, ctx.workspaceRoot))
|
|
23
|
+
return executor
|
|
24
|
+
},
|
|
25
|
+
async probe(_name, spec, ctx) {
|
|
26
|
+
const dir = definitionDir(spec, ctx.deckDir, ctx.workspaceRoot)
|
|
27
|
+
if (!findDefinition(dir)) return `no devcontainer definition (.devcontainer/devcontainer.json) in ${dir}`
|
|
28
|
+
return await dockerAvailable() ? undefined : 'Docker is not available'
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const plugin: PresmiumNodePlugin = {
|
|
33
|
+
id: EXEC_LOCAL_ID,
|
|
34
|
+
version,
|
|
35
|
+
kernel: '^1.0.0',
|
|
36
|
+
directives: [localEnvDirective],
|
|
37
|
+
passes: [localEnvPass(container)],
|
|
38
|
+
executors: [container],
|
|
39
|
+
environments: { [DEVCONTAINER_KIND]: devcontainer },
|
|
40
|
+
server: localEnvRoutes,
|
|
41
|
+
rendererExtensions: {
|
|
42
|
+
notebook: {
|
|
43
|
+
components: {
|
|
44
|
+
PmLocalEnv(node, _block, ctx) {
|
|
45
|
+
const code = componentCode(node)
|
|
46
|
+
return code && languageId(code.lang) === jupyterKernelLanguage(ctx.config.kernel) ? { cell_type: 'code', source: code.value, outputs: [], execution_count: null } : undefined
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default plugin
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { backgroundPriority, resourceEnvironment } from '@lincyaw/kernel/node'
|
|
3
|
+
|
|
4
|
+
export interface ProcessResult {
|
|
5
|
+
code: number | null
|
|
6
|
+
stdout: string
|
|
7
|
+
stderr: string
|
|
8
|
+
timedOut: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProcessOptions {
|
|
12
|
+
cwd?: string
|
|
13
|
+
env?: Record<string, string>
|
|
14
|
+
input?: string
|
|
15
|
+
timeoutMs?: number
|
|
16
|
+
signal?: AbortSignal
|
|
17
|
+
onStop?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const GRACE_MS = 5_000
|
|
21
|
+
|
|
22
|
+
export function runProcess(command: string, args: string[], options: ProcessOptions = {}): Promise<ProcessResult> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const group = process.platform !== 'win32'
|
|
25
|
+
const child = spawn(command, args, { cwd: options.cwd, env: resourceEnvironment(options.env), stdio: 'pipe', detached: group })
|
|
26
|
+
backgroundPriority(child.pid)
|
|
27
|
+
const stdout: Buffer[] = []
|
|
28
|
+
const stderr: Buffer[] = []
|
|
29
|
+
let timedOut = false
|
|
30
|
+
let stopped = false
|
|
31
|
+
let kill: ReturnType<typeof setTimeout> | undefined
|
|
32
|
+
const send = (signal: NodeJS.Signals) => {
|
|
33
|
+
try {
|
|
34
|
+
if (group && child.pid) process.kill(-child.pid, signal)
|
|
35
|
+
else child.kill(signal)
|
|
36
|
+
}
|
|
37
|
+
catch {}
|
|
38
|
+
}
|
|
39
|
+
const stop = () => {
|
|
40
|
+
if (stopped) return
|
|
41
|
+
stopped = true
|
|
42
|
+
options.onStop?.()
|
|
43
|
+
send('SIGTERM')
|
|
44
|
+
kill = setTimeout(() => send('SIGKILL'), GRACE_MS)
|
|
45
|
+
}
|
|
46
|
+
const timer = options.timeoutMs === undefined
|
|
47
|
+
? undefined
|
|
48
|
+
: setTimeout(() => {
|
|
49
|
+
timedOut = true
|
|
50
|
+
stop()
|
|
51
|
+
}, options.timeoutMs)
|
|
52
|
+
if (options.signal?.aborted) stop()
|
|
53
|
+
options.signal?.addEventListener('abort', stop, { once: true })
|
|
54
|
+
const finish = (code: number | null, error?: Error) => {
|
|
55
|
+
clearTimeout(timer)
|
|
56
|
+
clearTimeout(kill)
|
|
57
|
+
options.signal?.removeEventListener('abort', stop)
|
|
58
|
+
const err = Buffer.concat(stderr).toString('utf8')
|
|
59
|
+
resolve({ code, stdout: Buffer.concat(stdout).toString('utf8'), stderr: error ? `${err}${error.message}` : err, timedOut })
|
|
60
|
+
}
|
|
61
|
+
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk))
|
|
62
|
+
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
63
|
+
child.on('error', error => finish(null, error))
|
|
64
|
+
child.on('close', code => finish(code))
|
|
65
|
+
child.stdin.on('error', () => {})
|
|
66
|
+
child.stdin.end(options.input ?? '')
|
|
67
|
+
})
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { relative, sep } from 'node:path'
|
|
3
|
+
import { languageId } from '@lincyaw/kernel'
|
|
4
|
+
import { strToU8, zipSync } from 'fflate'
|
|
5
|
+
import { archiveName, workspacePath } from '../domain/index.ts'
|
|
6
|
+
import type { ReplicateRequest } from '../domain/index.ts'
|
|
7
|
+
import { definitionCommands, definitionFiles } from './devcontainer.ts'
|
|
8
|
+
import { text } from './messages.ts'
|
|
9
|
+
|
|
10
|
+
export class PathError extends Error {}
|
|
11
|
+
|
|
12
|
+
export function requirePath(file: string): string {
|
|
13
|
+
try {
|
|
14
|
+
return workspacePath(file)
|
|
15
|
+
} catch {
|
|
16
|
+
throw new PathError(text().outsideWorkspace(file))
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function replicateArchive(workspace: string, configFile: string, request: ReplicateRequest): { name: string, zip: Uint8Array } {
|
|
21
|
+
const file = requirePath(request.file)
|
|
22
|
+
const entries: Record<string, Uint8Array> = {}
|
|
23
|
+
for (const path of definitionFiles(configFile)) {
|
|
24
|
+
entries[requirePath(relative(workspace, path).split(sep).join('/'))] = readFileSync(path)
|
|
25
|
+
}
|
|
26
|
+
entries[file] = strToU8(request.content)
|
|
27
|
+
const command = request.lang ? definitionCommands(configFile)[languageId(request.lang)] : undefined
|
|
28
|
+
entries[file === 'README.md' ? 'PRESMIUM.md' : 'README.md'] = strToU8(text().readme(file, command && `${command[0]} ${file}`))
|
|
29
|
+
return { name: archiveName(file), zip: zipSync(entries, { mtime: '2000-01-01T00:00:00Z' }) }
|
|
30
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { relative, resolve, sep } from 'node:path'
|
|
2
|
+
import { APPROVALS, errorMessage, isRecord, jsonResponse, problemResponse } from '@lincyaw/kernel'
|
|
3
|
+
import type { Route, RunRequest, ServerContext, ServerContribution } from '@lincyaw/kernel'
|
|
4
|
+
import { EXEC_LOCAL_ID, execLocalConfig, failedContainerRun } from '../domain/index.ts'
|
|
5
|
+
import type { LocalEnvStatus } from '../domain/index.ts'
|
|
6
|
+
import { runInContainer } from './container.ts'
|
|
7
|
+
import { definitionCommands, findDefinition, readDevcontainer } from './devcontainer.ts'
|
|
8
|
+
import { dockerAvailable } from './docker.ts'
|
|
9
|
+
import { inLanguage, text } from './messages.ts'
|
|
10
|
+
import { PathError, replicateArchive, requirePath } from './replicate.ts'
|
|
11
|
+
|
|
12
|
+
class Refused extends Error {
|
|
13
|
+
readonly status: number
|
|
14
|
+
|
|
15
|
+
constructor(status: number, message: string) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.status = status
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
22
|
+
if (!request.headers.get('content-type')?.startsWith('application/json')) throw new Refused(415, text().notJson)
|
|
23
|
+
let value: unknown
|
|
24
|
+
try {
|
|
25
|
+
value = JSON.parse(await request.text() || '{}')
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Refused(400, text().invalidJson)
|
|
28
|
+
}
|
|
29
|
+
if (!isRecord(value)) throw new Refused(400, text().notObject)
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function string(body: Record<string, unknown>, key: string, optional = false): string | undefined {
|
|
34
|
+
const value = body[key]
|
|
35
|
+
if (value === undefined && optional) return undefined
|
|
36
|
+
if (typeof value !== 'string') throw new Refused(400, optional ? text().notString(key) : text().missingField(key))
|
|
37
|
+
return value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringTable(body: Record<string, unknown>, key: string): Record<string, string> | undefined {
|
|
41
|
+
const value = body[key]
|
|
42
|
+
if (value === undefined) return undefined
|
|
43
|
+
if (!isRecord(value) || Object.values(value).some(v => typeof v !== 'string')) throw new Refused(400, text().notStringTable(key))
|
|
44
|
+
return value as Record<string, string>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runRequest(body: Record<string, unknown>): RunRequest {
|
|
48
|
+
const files = stringTable(body, 'files')
|
|
49
|
+
for (const file of Object.keys(files ?? {})) requirePath(file)
|
|
50
|
+
return {
|
|
51
|
+
lang: string(body, 'lang')!,
|
|
52
|
+
code: string(body, 'code')!,
|
|
53
|
+
args: stringTable(body, 'args'),
|
|
54
|
+
stdin: string(body, 'stdin', true),
|
|
55
|
+
timeoutMs: typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined,
|
|
56
|
+
files,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function status(workspace: string): Promise<LocalEnvStatus> {
|
|
61
|
+
const definition = findDefinition(workspace)
|
|
62
|
+
const docker = await dockerAvailable()
|
|
63
|
+
return {
|
|
64
|
+
connected: true,
|
|
65
|
+
docker,
|
|
66
|
+
devcontainer: definition ? relative(workspace, definition).split(sep).join('/') : null,
|
|
67
|
+
languages: docker && definition ? Object.keys(definitionCommands(definition)) : [],
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function route(handle: (request: Request, member: string) => Promise<Response>): Route {
|
|
72
|
+
return {
|
|
73
|
+
handle: (request, ctx) => inLanguage(request.headers.get('accept-language') ?? undefined, async () => {
|
|
74
|
+
try {
|
|
75
|
+
return await handle(request, ctx.member.id)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof Refused) return problemResponse(error.status, error.message)
|
|
78
|
+
return problemResponse(error instanceof PathError ? 400 : 500, errorMessage(error))
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function localEnvRoutes(ctx: ServerContext): ServerContribution {
|
|
85
|
+
const { workspace: configured, buildTimeoutMs } = execLocalConfig(ctx.config)
|
|
86
|
+
const workspace = configured === undefined ? ctx.workspaceRoot : resolve(ctx.deckDir, configured)
|
|
87
|
+
return {
|
|
88
|
+
routes: {
|
|
89
|
+
status: route(async () => jsonResponse(await status(workspace))),
|
|
90
|
+
run: route(async (request, member) => {
|
|
91
|
+
const run = runRequest(await body(request))
|
|
92
|
+
const approvals = ctx.service(APPROVALS)
|
|
93
|
+
const allowed = await approvals?.request({ kind: 'operate', scope: 'content', title: text().runTitle(run.lang), detail: run.code, from: EXEC_LOCAL_ID, member }, request.signal)
|
|
94
|
+
if (!allowed) throw new Refused(403, text().notAllowed)
|
|
95
|
+
if (!findDefinition(workspace)) return jsonResponse(failedContainerRun(text().noDefinition))
|
|
96
|
+
const devcontainer = await readDevcontainer(workspace)
|
|
97
|
+
if (!devcontainer) return jsonResponse(failedContainerRun(text().unreadableDefinition))
|
|
98
|
+
return jsonResponse(await runInContainer({ workspace, devcontainer, request: run, build: { buildTimeoutMs }, signal: request.signal }))
|
|
99
|
+
}),
|
|
100
|
+
replicate: route(async (request) => {
|
|
101
|
+
const fields = await body(request)
|
|
102
|
+
const asked = { file: string(fields, 'file')!, content: string(fields, 'content')!, lang: string(fields, 'lang', true) }
|
|
103
|
+
const definition = findDefinition(workspace)
|
|
104
|
+
if (!definition) throw new Refused(404, text().noDefinition)
|
|
105
|
+
const { name, zip } = replicateArchive(workspace, definition, asked)
|
|
106
|
+
return new Response(new Uint8Array(zip), {
|
|
107
|
+
headers: {
|
|
108
|
+
'Content-Type': 'application/zip',
|
|
109
|
+
'Content-Disposition': `attachment; filename="${name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
110
|
+
'Cache-Control': 'no-store',
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|