@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.
@@ -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
+ }