@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,39 @@
1
+ import { environmentExecutor, EXECUTOR } from '@lincyaw/kernel'
2
+ import type { Executor, PluginDefinition, Unsubscribe } from '@lincyaw/kernel'
3
+ import { version } from '../../package.json'
4
+ import { DEVCONTAINER_KIND, EXEC_LOCAL_ID, execLocalConfig, LOCAL_ENV } from '../domain/index.ts'
5
+ import { createLocalEnvClient } from './local-env.ts'
6
+
7
+ let unregister: Unsubscribe[] = []
8
+
9
+ export const execLocalPlugin: PluginDefinition = {
10
+ manifest: {
11
+ id: EXEC_LOCAL_ID,
12
+ name: { 'zh-CN': '本机容器运行', en: 'Local Container Runner' },
13
+ version,
14
+ kernel: '^1.0.0',
15
+ provides: [LOCAL_ENV],
16
+ requires: [{ token: EXECUTOR, optional: true }],
17
+ capabilities: ['exec', 'fs'],
18
+ },
19
+ setup(ctx) {
20
+ const env = createLocalEnvClient()
21
+ ctx.provide(LOCAL_ENV, env)
22
+ const router = ctx.tryGet(EXECUTOR)
23
+ if (router) {
24
+ unregister = [router.register(env.executor, execLocalConfig(ctx.config).priority)]
25
+ for (const [name, spec] of Object.entries(ctx.environments ?? {})) {
26
+ if (spec.kind !== DEVCONTAINER_KIND) continue
27
+ const named: Executor = { id: environmentExecutor(name), supports: lang => env.executor.supports(lang), run: (request, signal) => env.executor.run(request, signal) }
28
+ unregister.push(router.register(named))
29
+ }
30
+ }
31
+ void env.status()
32
+ },
33
+ dispose() {
34
+ unregister.forEach(off => off())
35
+ unregister = []
36
+ },
37
+ }
38
+
39
+ export default execLocalPlugin
@@ -0,0 +1,25 @@
1
+ import { isRecord } from '@lincyaw/kernel'
2
+
3
+ export const EXEC_LOCAL_ID = 'presmium.execLocal'
4
+
5
+ export const DEVCONTAINER_KIND = 'devcontainer'
6
+
7
+ export interface ExecLocalConfig {
8
+ workspace?: string
9
+ priority: number
10
+ buildTimeoutMs: number
11
+ }
12
+
13
+ export const DEFAULT_PRIORITY = 10
14
+
15
+ export const DEFAULT_BUILD_TIMEOUT_MS = 600_000
16
+
17
+ export function execLocalConfig(options: unknown): ExecLocalConfig {
18
+ const config = isRecord(options) ? options : {}
19
+ const buildTimeoutMs = config.buildTimeoutMs
20
+ return {
21
+ workspace: typeof config.workspace === 'string' ? config.workspace : undefined,
22
+ priority: typeof config.priority === 'number' ? config.priority : DEFAULT_PRIORITY,
23
+ buildTimeoutMs: typeof buildTimeoutMs === 'number' && buildTimeoutMs > 0 ? buildTimeoutMs : DEFAULT_BUILD_TIMEOUT_MS,
24
+ }
25
+ }
@@ -0,0 +1,6 @@
1
+ export { DEFAULT_BUILD_TIMEOUT_MS, DEFAULT_PRIORITY, DEVCONTAINER_KIND, EXEC_LOCAL_ID, execLocalConfig } from './config.ts'
2
+ export type { ExecLocalConfig } from './config.ts'
3
+ export { CONTAINER_EXECUTOR_ID, disconnected, failedContainerRun, LOCAL_ENV } from './service.ts'
4
+ export type { DisconnectedReason, LocalEnvService, LocalEnvStatus, ReplicateRequest } from './service.ts'
5
+ export { archiveName, DEFAULT_LANGUAGE_COMMANDS, languageCommandsFrom, workspacePath } from './workspace.ts'
6
+ export type { LanguageCommands } from './workspace.ts'
@@ -0,0 +1,36 @@
1
+ import { defineService, failedRun } from '@lincyaw/kernel'
2
+ import type { RunRequest, RunResult } from '@lincyaw/kernel'
3
+
4
+ export const CONTAINER_EXECUTOR_ID = 'presmium.container'
5
+
6
+ export type DisconnectedReason = 'devServerOnly' | 'noEndpoints' | 'notAllowed' | 'unreachable' | 'pluginOff'
7
+
8
+ export interface LocalEnvStatus {
9
+ connected: boolean
10
+ docker: boolean
11
+ devcontainer: string | null
12
+ languages: string[]
13
+ reason?: DisconnectedReason
14
+ }
15
+
16
+ export interface ReplicateRequest {
17
+ file: string
18
+ content: string
19
+ lang?: string
20
+ }
21
+
22
+ export interface LocalEnvService {
23
+ status(): Promise<LocalEnvStatus>
24
+ run(request: RunRequest, signal?: AbortSignal): Promise<RunResult>
25
+ replicate(request: ReplicateRequest): Promise<Blob>
26
+ }
27
+
28
+ export const LOCAL_ENV = defineService<LocalEnvService>('presmium.localEnv')
29
+
30
+ export function disconnected(reason: DisconnectedReason): LocalEnvStatus {
31
+ return { connected: false, docker: false, devcontainer: null, languages: [], reason }
32
+ }
33
+
34
+ export function failedContainerRun(message: string, executorId = CONTAINER_EXECUTOR_ID): RunResult {
35
+ return failedRun(executorId, message)
36
+ }
@@ -0,0 +1,32 @@
1
+ import { isRecord, languageId } from '@lincyaw/kernel'
2
+
3
+ /** The command that runs code of each language in the container; the code follows as its last argument. */
4
+ export type LanguageCommands = Record<string, string[]>
5
+
6
+ export const DEFAULT_LANGUAGE_COMMANDS: LanguageCommands = { python: ['python3', '-c'] }
7
+
8
+ /** The commands of the devcontainer definition (`customizations.presmium.runners`) over the default ones. */
9
+ export function languageCommandsFrom(customizations: unknown): LanguageCommands {
10
+ const presmium = isRecord(customizations) ? customizations.presmium : undefined
11
+ const custom = isRecord(presmium) && isRecord(presmium.runners) ? presmium.runners : {}
12
+ const commands: LanguageCommands = { ...DEFAULT_LANGUAGE_COMMANDS }
13
+ for (const [lang, command] of Object.entries(custom)) {
14
+ if (Array.isArray(command) && command.length > 0 && command.every(part => typeof part === 'string')) {
15
+ commands[languageId(lang)] = command
16
+ }
17
+ }
18
+ return commands
19
+ }
20
+
21
+ export function workspacePath(file: string): string {
22
+ const parts = file.replaceAll('\\', '/').split('/').filter(part => part !== '' && part !== '.')
23
+ if (!file || /^([a-z]:)?[\\/]/i.test(file) || parts.length === 0 || parts.includes('..') || parts[0] === '.git') {
24
+ throw new Error(`not a path inside the workspace: ${file}`)
25
+ }
26
+ return parts.join('/')
27
+ }
28
+
29
+ export function archiveName(file: string): string {
30
+ const name = file.split('/').pop() ?? file
31
+ return `presmium-env-${name.replace(/\.[^.]*$/, '') || 'snippet'}.zip`
32
+ }
@@ -0,0 +1,163 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { tmpdir } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+ import { errorMessage, languageId } from '@lincyaw/kernel'
7
+ import type { Executor, RunContext, RunOutput, RunRequest, RunResult } from '@lincyaw/kernel'
8
+ import { RUN_REQUEST_FILE, runResultFile } from '@lincyaw/kernel/node'
9
+ import { CONTAINER_EXECUTOR_ID, failedContainerRun, workspacePath } from '../domain/index.ts'
10
+ import type { BuildOptions, Devcontainer } from './devcontainer.ts'
11
+ import { definitionCommands, definitionHash, devcontainerImage, findDefinition, readDevcontainer } from './devcontainer.ts'
12
+ import { dockerAvailable, dockerEnv, isDockerAvailableSync } from './docker.ts'
13
+ import { text } from './messages.ts'
14
+ import { runProcess } from './process.ts'
15
+
16
+ const DEFAULT_TIMEOUT_MS = 30_000
17
+ const RUN_DIR = '/presmium-run'
18
+ const PYTHON_RUNNER = `${readFileSync(createRequire(import.meta.url).resolve('@lincyaw/kernel/runner.py'), 'utf8')}\n_pm_main()\n`
19
+ const HOST_PATHS = new Set(['PATH', 'HOME', 'TMPDIR'])
20
+
21
+ export interface ContainerExecutor extends Executor {
22
+ bind(workspace: string, options?: BuildOptions): void
23
+ }
24
+
25
+ /** Runs code in a new container of the image of the bound workspace's devcontainer definition. */
26
+ export function createContainerExecutor(id = CONTAINER_EXECUTOR_ID): ContainerExecutor {
27
+ let workspace: string | undefined
28
+ let build: BuildOptions = {}
29
+ const definition = () => (workspace === undefined ? undefined : findDefinition(workspace))
30
+ const command = (configFile: string, lang: string) => definitionCommands(configFile)[languageId(lang)]
31
+ return {
32
+ id,
33
+ bind(dir, options = {}) {
34
+ workspace = dir
35
+ build = options
36
+ },
37
+ supports(lang) {
38
+ const configFile = definition()
39
+ return configFile !== undefined && command(configFile, lang) !== undefined && isDockerAvailableSync()
40
+ },
41
+ fingerprint(lang) {
42
+ const configFile = definition()
43
+ return configFile ? `${definitionHash(configFile)}:${JSON.stringify(command(configFile, lang) ?? null)}` : ''
44
+ },
45
+ async run(request, signal, ctx) {
46
+ const dir = workspace
47
+ if (dir === undefined || !definition()) throw new Error('the workspace has no devcontainer definition')
48
+ if (!await dockerAvailable()) throw new Error('Docker is not available')
49
+ const devcontainer = await readDevcontainer(dir, dockerEnv(ctx))
50
+ if (!devcontainer) throw new Error('cannot read the devcontainer definition')
51
+ const result = await runInContainer({ workspace: dir, devcontainer, request, build, signal, ctx, executorId: id })
52
+ if (result.exitCode === null && !result.timedOut) throw new Error(result.stderr)
53
+ return result
54
+ },
55
+ }
56
+ }
57
+
58
+ function runDirectory(files: Record<string, string> | undefined, runnerRequest?: unknown): { parent: string, dir: string } {
59
+ const parent = mkdtempSync(join(tmpdir(), 'presmium-container-'))
60
+ chmodSync(parent, 0o700)
61
+ const dir = join(parent, 'run')
62
+ try {
63
+ mkdirSync(join(dir, '.presmium'), { recursive: true })
64
+ chmodSync(dir, 0o777)
65
+ chmodSync(join(dir, '.presmium'), 0o777)
66
+ for (const [name, content] of Object.entries(files ?? {})) {
67
+ const file = join(dir, workspacePath(name))
68
+ mkdirSync(dirname(file), { recursive: true, mode: 0o777 })
69
+ writeFileSync(file, content, { mode: 0o666, flag: 'wx' })
70
+ }
71
+ if (runnerRequest !== undefined) writeFileSync(join(dir, RUN_REQUEST_FILE), JSON.stringify(runnerRequest), { mode: 0o666, flag: 'wx' })
72
+ } catch (error) {
73
+ rmSync(parent, { recursive: true, force: true })
74
+ throw error
75
+ }
76
+ return { parent, dir }
77
+ }
78
+
79
+ function pythonResult(dir: string): { stdout: string, stderr: string, exitCode: number, outputs?: RunOutput[] } | undefined {
80
+ try {
81
+ return JSON.parse(readFileSync(join(dir, runResultFile(0)), 'utf8'))
82
+ }
83
+ catch {
84
+ return undefined
85
+ }
86
+ }
87
+
88
+ export interface ContainerRun {
89
+ workspace: string
90
+ devcontainer: Devcontainer
91
+ request: RunRequest
92
+ build?: BuildOptions
93
+ signal?: AbortSignal
94
+ ctx?: RunContext
95
+ executorId?: string
96
+ }
97
+
98
+ /** Runs the request with `docker run` in a new container of the definition's image, without network; the workspace is mounted read-only. */
99
+ export async function runInContainer(run: ContainerRun): Promise<RunResult> {
100
+ const { workspace, devcontainer, request, signal, ctx, executorId = CONTAINER_EXECUTOR_ID } = run
101
+ const lang = languageId(request.lang)
102
+ const command = devcontainer.commands[lang]
103
+ if (!command) return failedContainerRun(text().unsupportedLanguage(request.lang), executorId)
104
+ const commandEnv = dockerEnv(ctx)
105
+ let image: string
106
+ try {
107
+ image = await devcontainerImage(workspace, devcontainer, run.build, signal, commandEnv)
108
+ }
109
+ catch (error) {
110
+ return failedContainerRun(text().buildFailed(errorMessage(error)), executorId)
111
+ }
112
+ const python = lang === 'python'
113
+ let directory: { parent: string, dir: string }
114
+ try {
115
+ directory = runDirectory(request.files, python ? { runs: [{ code: request.code, args: request.args ?? {}, stdin: request.stdin ?? '' }], path: [devcontainer.workspaceFolder] } : undefined)
116
+ }
117
+ catch (error) {
118
+ return failedContainerRun(errorMessage(error), executorId)
119
+ }
120
+ const { parent, dir } = directory
121
+ const started = Date.now()
122
+ const name = `presmium-run-${randomUUID()}`
123
+ const passed = Object.fromEntries(Object.entries(ctx?.env ?? {}).filter(([key]) => !HOST_PATHS.has(key)))
124
+ const env = { ...passed, ...devcontainer.env, ...python ? {} : request.args }
125
+ try {
126
+ const result = await runProcess('docker', [
127
+ 'run', '--rm', '-i', '--init', '--network', 'none', '--name', name,
128
+ '--mount', `${devcontainer.workspaceMount},readonly`,
129
+ '--mount', `type=bind,source=${dir},target=${RUN_DIR}`,
130
+ '--workdir', RUN_DIR,
131
+ ...(devcontainer.user ? ['--user', devcontainer.user] : []),
132
+ ...Object.entries(env).flatMap(([key, value]) => ['--env', `${key}=${value}`]),
133
+ image,
134
+ ...command,
135
+ python ? PYTHON_RUNNER : request.code,
136
+ ], {
137
+ input: python ? '' : request.stdin,
138
+ env: commandEnv,
139
+ timeoutMs: request.timeoutMs ?? DEFAULT_TIMEOUT_MS,
140
+ signal,
141
+ onStop: () => void runProcess('docker', ['kill', name], { env: commandEnv }),
142
+ })
143
+ const output = python && !result.timedOut ? pythonResult(dir) : undefined
144
+ const exitCode = result.timedOut ? null : output ? output.exitCode : result.code
145
+ return {
146
+ ok: exitCode === 0,
147
+ stdout: output ? output.stdout : result.stdout,
148
+ stderr: output ? output.stderr : result.stderr,
149
+ exitCode,
150
+ durationMs: Date.now() - started,
151
+ timedOut: result.timedOut,
152
+ source: 'live',
153
+ executorId,
154
+ ...output?.outputs ? { outputs: output.outputs } : {},
155
+ }
156
+ }
157
+ finally {
158
+ try {
159
+ rmSync(parent, { recursive: true, force: true })
160
+ }
161
+ catch {}
162
+ }
163
+ }
@@ -0,0 +1,165 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { basename, dirname, join, relative, resolve } from 'node:path'
5
+ import { parse } from 'jsonc-parser'
6
+ import { DEFAULT_BUILD_TIMEOUT_MS, languageCommandsFrom } from '../domain/index.ts'
7
+ import type { LanguageCommands } from '../domain/index.ts'
8
+ import { dockerEnv } from './docker.ts'
9
+ import { runProcess } from './process.ts'
10
+
11
+ const CLI = createRequire(import.meta.url).resolve('@devcontainers/cli/devcontainer.js')
12
+ const READ_TIMEOUT_MS = 60_000
13
+
14
+ export interface Devcontainer {
15
+ configFile: string
16
+ workspaceMount: string
17
+ workspaceFolder: string
18
+ commands: LanguageCommands
19
+ user?: string
20
+ env: Record<string, string>
21
+ }
22
+
23
+ export interface BuildOptions {
24
+ buildTimeoutMs?: number
25
+ }
26
+
27
+ function devcontainerCli(args: string[], timeoutMs: number, signal?: AbortSignal, env = dockerEnv()) {
28
+ return runProcess(process.execPath, [CLI, ...args], { timeoutMs, signal, env })
29
+ }
30
+
31
+ interface CliOutput {
32
+ outcome?: string
33
+ message?: string
34
+ description?: string
35
+ configuration?: {
36
+ configFilePath?: { fsPath?: string }
37
+ customizations?: unknown
38
+ remoteUser?: string
39
+ containerUser?: string
40
+ containerEnv?: Record<string, string>
41
+ }
42
+ workspace?: { workspaceFolder?: string, workspaceMount?: string }
43
+ }
44
+
45
+ function lastJson(stdout: string): CliOutput | undefined {
46
+ const line = stdout.trim().split('\n').pop()
47
+ try {
48
+ return line ? JSON.parse(line) : undefined
49
+ }
50
+ catch {
51
+ return undefined
52
+ }
53
+ }
54
+
55
+ export function findDefinition(workspace: string): string | undefined {
56
+ return ['.devcontainer/devcontainer.json', '.devcontainer.json'].map(file => join(workspace, file)).find(existsSync)
57
+ }
58
+
59
+ export function definitionFiles(configFile: string): string[] {
60
+ const dir = dirname(configFile)
61
+ const files = new Set(basename(configFile) === '.devcontainer.json' ? [configFile]
62
+ : readdirSync(dir, { recursive: true, encoding: 'utf8' }).map(name => join(dir, name)).filter(path => statSync(path).isFile()))
63
+ const config = parse(readFileSync(configFile, 'utf8'), [], { allowTrailingComma: true }) as {
64
+ dockerFile?: string, build?: { dockerfile?: string, context?: string }, dockerComposeFile?: string | string[]
65
+ } | undefined
66
+ const dockerfile = config?.build?.dockerfile ?? config?.dockerFile
67
+ const references = [dockerfile, ...typeof config?.dockerComposeFile === 'string' ? [config.dockerComposeFile] : config?.dockerComposeFile ?? []]
68
+ if (dockerfile) references.push(join(config?.build?.context ?? '.', '.dockerignore'))
69
+ for (const reference of references) {
70
+ if (!reference) continue
71
+ const path = resolve(dir, reference)
72
+ if (statSync(path, { throwIfNoEntry: false })?.isFile()) files.add(path)
73
+ }
74
+ return [...files].sort()
75
+ }
76
+
77
+ function definitionStamp(configFile: string): string {
78
+ return JSON.stringify([configFile, definitionFiles(configFile).map(path => {
79
+ const { size, mtimeMs, ctimeMs } = statSync(path)
80
+ return [path, size, mtimeMs, ctimeMs]
81
+ })])
82
+ }
83
+
84
+ function perVersion<T>(compute: (configFile: string) => T): (configFile: string) => T {
85
+ const values = new Map<string, T>()
86
+ return (configFile) => {
87
+ const stamp = definitionStamp(configFile)
88
+ if (!values.has(stamp)) values.set(stamp, compute(configFile))
89
+ return values.get(stamp)!
90
+ }
91
+ }
92
+
93
+ /** The language commands of the devcontainer definition, read again when its inputs change. */
94
+ export const definitionCommands = perVersion((configFile): LanguageCommands => {
95
+ const config = parse(readFileSync(configFile, 'utf8'), [], { allowTrailingComma: true }) as { customizations?: unknown } | undefined
96
+ return languageCommandsFrom(config?.customizations)
97
+ })
98
+
99
+ export const definitionHash = perVersion((configFile): string => {
100
+ const dir = dirname(configFile)
101
+ const hash = createHash('sha256')
102
+ for (const path of definitionFiles(configFile)) {
103
+ hash.update(`${relative(dir, path)}\0`).update(readFileSync(path)).update('\0')
104
+ }
105
+ return hash.digest('hex')
106
+ })
107
+
108
+ const configurations = new Map<string, Promise<Devcontainer | undefined>>()
109
+
110
+ export function readDevcontainer(workspace: string, env?: Record<string, string>): Promise<Devcontainer | undefined> {
111
+ const configFile = findDefinition(workspace)
112
+ if (!configFile) return Promise.resolve(undefined)
113
+ const key = `${workspace}\0${definitionStamp(configFile)}`
114
+ let read = configurations.get(key)
115
+ if (!read) {
116
+ read = readConfiguration(workspace, env)
117
+ configurations.set(key, read)
118
+ void read.then(found => found || configurations.delete(key))
119
+ }
120
+ return read
121
+ }
122
+
123
+ async function readConfiguration(workspace: string, env?: Record<string, string>): Promise<Devcontainer | undefined> {
124
+ const result = await devcontainerCli(['read-configuration', '--workspace-folder', workspace], READ_TIMEOUT_MS, undefined, env)
125
+ const output = lastJson(result.stdout)
126
+ const config = output?.configuration
127
+ const configFile = config?.configFilePath?.fsPath
128
+ if (result.code !== 0 || !config || !configFile) return undefined
129
+ const workspaceFolder = output?.workspace?.workspaceFolder ?? `/workspaces/${basename(workspace)}`
130
+ return {
131
+ configFile,
132
+ workspaceMount: output?.workspace?.workspaceMount ?? `type=bind,source=${workspace},target=${workspaceFolder}`,
133
+ workspaceFolder,
134
+ commands: languageCommandsFrom(config.customizations),
135
+ user: config.remoteUser ?? config.containerUser,
136
+ env: { ...config.containerEnv },
137
+ }
138
+ }
139
+
140
+ const images = new Map<string, Promise<string>>()
141
+
142
+ export function imageName(workspace: string): string {
143
+ return `presmium-devcontainer-${createHash('sha256').update(workspace).digest('hex').slice(0, 12)}`
144
+ }
145
+
146
+ export function devcontainerImage(workspace: string, devcontainer: Devcontainer, options: BuildOptions = {}, signal?: AbortSignal, env?: Record<string, string>): Promise<string> {
147
+ const key = definitionStamp(devcontainer.configFile)
148
+ let image = images.get(key)
149
+ if (!image) {
150
+ const name = imageName(workspace)
151
+ const timeoutMs = options.buildTimeoutMs ?? DEFAULT_BUILD_TIMEOUT_MS
152
+ image = devcontainerCli(['build', '--workspace-folder', workspace, '--image-name', name], timeoutMs, signal, env).then((result) => {
153
+ if (result.timedOut) throw new Error(`stopped after ${timeoutMs} ms`)
154
+ const output = lastJson(result.stdout)
155
+ if (output?.outcome !== 'success') {
156
+ const detail = [output?.message, output?.description].filter(Boolean).join('\n') || result.stderr.trim().slice(-2000)
157
+ throw new Error(detail)
158
+ }
159
+ return name
160
+ })
161
+ image.catch(() => images.delete(key))
162
+ images.set(key, image)
163
+ }
164
+ return image
165
+ }
@@ -0,0 +1,36 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import type { RunContext } from '@lincyaw/kernel'
3
+ import { minimalRunEnv } from '@lincyaw/kernel/node'
4
+ import { runProcess } from './process.ts'
5
+
6
+ const DOCKER_CLIENT = ['DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG', 'DOCKER_CERT_PATH', 'DOCKER_TLS_VERIFY', 'XDG_RUNTIME_DIR', 'HOME']
7
+
8
+ /** The run's environment with the variables the Docker client needs to reach the daemon. */
9
+ export function dockerEnv(ctx?: RunContext): Record<string, string> {
10
+ const env: Record<string, string> = { ...ctx?.env ?? minimalRunEnv() }
11
+ for (const name of DOCKER_CLIENT) {
12
+ const value = process.env[name]
13
+ if (value !== undefined) env[name] = value
14
+ }
15
+ return env
16
+ }
17
+
18
+ let docker: Promise<boolean> | undefined
19
+
20
+ export function dockerAvailable(): Promise<boolean> {
21
+ if (!docker) {
22
+ const probe = runProcess('docker', ['version', '--format', '{{.Server.Version}}'], { timeoutMs: 10_000, env: dockerEnv() }).then(result => result.code === 0)
23
+ docker = probe
24
+ void probe.then((answers) => {
25
+ if (!answers && docker === probe) docker = undefined
26
+ })
27
+ }
28
+ return docker
29
+ }
30
+
31
+ let dockerSync: boolean | undefined
32
+
33
+ export function isDockerAvailableSync(): boolean {
34
+ dockerSync ??= spawnSync('docker', ['version', '--format', '{{.Server.Version}}'], { stdio: 'ignore', timeout: 10_000, env: dockerEnv() }).status === 0
35
+ return dockerSync
36
+ }
@@ -0,0 +1,112 @@
1
+ import { dirname, relative, resolve, sep } from 'node:path'
2
+ import { componentCode, docBlocks } from '@lincyaw/kernel'
3
+ import type { CodeNode, ComponentNode, DirectiveSpec, Executor, IRBlock, IRPass } from '@lincyaw/kernel'
4
+ import { EXEC_LOCAL_ID, execLocalConfig, workspacePath } from '../domain/index.ts'
5
+ import type { ContainerExecutor } from './container.ts'
6
+ import { createContainerExecutor } from './container.ts'
7
+ import { findDefinition } from './devcontainer.ts'
8
+ import { inLanguage, text } from './messages.ts'
9
+ import { replicateArchive } from './replicate.ts'
10
+
11
+ /** The `:workspace:` each {local-env} names, resolved, for the workspace pass of the same compile. */
12
+ const requestedWorkspaces = new WeakMap<ComponentNode, string>()
13
+
14
+ export const localEnvDirective: DirectiveSpec = {
15
+ name: 'local-env',
16
+ arg: true,
17
+ body: true,
18
+ options: { file: 'string', workspace: 'string' },
19
+ toIR(input, ctx) {
20
+ const lang = input.arg?.trim() || 'python'
21
+ let file: string
22
+ try {
23
+ file = workspacePath(String(input.options.file ?? ''))
24
+ }
25
+ catch {
26
+ const message = '{local-env} needs :file:, the path of the snippet inside the workspace'
27
+ ctx.report({ checkId: 'presmium.execLocal.syntax', severity: 'error', message })
28
+ return { node: { type: 'markdown', mdast: ctx.parse(`> ${message}`) } }
29
+ }
30
+ const code: CodeNode = { type: 'code', lang, value: input.body.replace(/\n+$/, '') }
31
+ const node: ComponentNode = {
32
+ type: 'component',
33
+ plugin: EXEC_LOCAL_ID,
34
+ name: 'PmLocalEnv',
35
+ props: { file, lang, code: code.value },
36
+ children: [{ id: 'source', role: 'visual', at: 0, source: ctx.source, node: code }],
37
+ fallback: { type: 'root', children: [{ type: 'code', lang, value: code.value }] },
38
+ }
39
+ if (typeof input.options.workspace === 'string') requestedWorkspaces.set(node, resolve(dirname(ctx.file), input.options.workspace))
40
+ return { node }
41
+ },
42
+ }
43
+
44
+ const deckContainers = new Map<string, ContainerExecutor>()
45
+
46
+ /** Each deck runs in its own workspace: the deck's container executor takes the place of the plugin's in the executors of this compile. */
47
+ function bindDeckContainer(deckDir: string, executors: Executor[], pluginContainer: Executor): ContainerExecutor {
48
+ let container = deckContainers.get(deckDir)
49
+ if (!container) deckContainers.set(deckDir, container = createContainerExecutor(pluginContainer.id))
50
+ const at = executors.indexOf(pluginContainer)
51
+ if (at >= 0) executors[at] = container
52
+ return container
53
+ }
54
+
55
+ export function localEnvPass(pluginContainer: Executor): IRPass {
56
+ return {
57
+ name: 'presmium.execLocal.workspace',
58
+ run(doc, ctx) {
59
+ const envs = [...docBlocks(doc)].map(({ block }) => block)
60
+ .filter((block): block is IRBlock & { node: ComponentNode } => block.node.type === 'component' && block.node.plugin === EXEC_LOCAL_ID && block.node.name === 'PmLocalEnv')
61
+ const config = execLocalConfig(ctx.config)
62
+ const workspace = config.workspace !== undefined
63
+ ? resolve(ctx.deckDir, config.workspace)
64
+ : envs.map(env => requestedWorkspaces.get(env.node)).find(dir => dir !== undefined) ?? ctx.workspaceRoot
65
+ bindDeckContainer(ctx.deckDir, ctx.executors, pluginContainer).bind(workspace, config)
66
+ const shared = relative(ctx.deckDir, workspace).split(sep).join('/') || '.'
67
+ doc.config.presmium = { ...doc.config.presmium, [EXEC_LOCAL_ID]: { ...ctx.config, workspace: shared } }
68
+ const named = (dir: string) => relative(ctx.deckDir, dir).split(sep).join('/') || '.'
69
+
70
+ for (const { node, source, stepStates } of envs) {
71
+ const dir = requestedWorkspaces.get(node) ?? workspace
72
+ const at = { file: source.file, line: source.line }
73
+ const code = componentCode(node)
74
+ if (!code || node.children?.filter(child => child.node.type === 'code').length !== 1) {
75
+ ctx.report({ checkId: EXEC_LOCAL_ID, severity: 'error', message: inLanguage(ctx.lang, () => text().codeChild), source })
76
+ continue
77
+ }
78
+ node.props.code = code.value
79
+ node.props.lang = code.lang
80
+ delete node.props.archive
81
+ delete node.props.devcontainer
82
+ for (const state of stepStates ?? []) if (state.parameters) {
83
+ for (const key of ['code', 'lang', 'archive', 'devcontainer']) delete state.parameters[key]
84
+ }
85
+ if (dir !== workspace) {
86
+ ctx.report({
87
+ checkId: 'presmium.execLocal.workspace',
88
+ severity: 'error',
89
+ message: `{local-env} names the workspace ${named(dir)}, but the deck's workspace is ${named(workspace)}: the snippet would run in ${named(workspace)}`,
90
+ hint: 'give every {local-env} of the deck the same :workspace:, or set presmium.execLocal.workspace',
91
+ source: at,
92
+ })
93
+ }
94
+ const definition = findDefinition(dir)
95
+ if (!definition) {
96
+ ctx.report({
97
+ checkId: 'presmium.execLocal.devcontainer',
98
+ severity: 'warning',
99
+ message: `no devcontainer definition in ${named(dir)}; the snippet cannot be run in a container or exported`,
100
+ source: at,
101
+ })
102
+ continue
103
+ }
104
+ const file = String(node.props.file)
105
+ const request = { file, content: `${code.value}\n`, lang: String(node.props.lang) }
106
+ const { zip } = inLanguage(ctx.lang, () => replicateArchive(dir, definition, request))
107
+ node.props.devcontainer = relative(dir, definition).split(sep).join('/')
108
+ node.props.archive = Buffer.from(zip).toString('base64')
109
+ }
110
+ },
111
+ }
112
+ }