@tsplc/build 0.2.6 → 0.2.8
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/dist/cli.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/python/build.py +160 -291
- package/python/codesys_online.py +18 -40
- package/python/hardware.py +261 -0
- package/python/test_build.py +105 -17
- package/python/test_codesys_online.py +31 -12
- package/src/controller-session.ts +9 -5
- package/src/host/codesys-worker.ts +8 -3
package/dist/cli.js.map
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"import { resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n// Both source modules and published bundles live one directory below the package root.\nexport const packageDirectory = resolve(fileURLToPath(new URL(\"..\", import.meta.url)));\n",
|
|
11
11
|
"import { z } from \"zod\";\n\nexport const compilerDiagnosticSchema = z\n .object({\n category: z.string(),\n text: z.string(),\n severity: z.string().nullable(),\n project: z.string().nullable(),\n object: z\n .object({\n name: z.string(),\n guid: z.string(),\n path: z.array(z.string()),\n /** Verified against this message project's active application GUID. */\n isApplicationChild: z.boolean().nullable(),\n })\n .passthrough()\n .nullable(),\n /** Opaque vendor position. It must not be decoded as a line or column. */\n position: z.string().nullable(),\n positionText: z.string().nullable(),\n positionOffset: z.int().nullable(),\n length: z.int().nullable(),\n prefix: z.string().nullable(),\n number: z.int().nullable(),\n })\n .passthrough();\n\nexport type CodesysCompilerDiagnostic = z.infer<typeof compilerDiagnosticSchema>;\n\n/** Preserves vendor diagnostics as the cause of a buildProject failure. */\nexport class CodesysCompilerError extends Error {\n constructor(\n message: string,\n readonly diagnostics: readonly CodesysCompilerDiagnostic[],\n ) {\n super(message);\n this.name = \"CodesysCompilerError\";\n }\n}\n\n// A failed container job publishes diagnostics separately from its successful build receipt.\nexport const buildFailureFile = \".codesys/docker/failure.json\";\n/** Why the last download, online change or inspection failed, for the host to report. */\nexport const controllerFailureFile = \".codesys/controller/failure.txt\";\nexport const buildFailureSchema = z.strictObject({\n operationId: z.uuid(),\n message: z.string(),\n diagnostics: z.array(compilerDiagnosticSchema),\n});\n\n/** Keep the primary compiler location and the shutdown context across the Docker boundary. */\nexport function describeBuildFailure(cause: unknown) {\n const messages: string[] = [];\n let diagnostics: readonly CodesysCompilerDiagnostic[] = [];\n let current = cause;\n while (current instanceof Error) {\n messages.push(current.message);\n if (current instanceof AggregateError) {\n // withWorkerShutdown puts the primary failure in cause and secondary failures in errors.\n for (const secondary of current.errors.slice(1))\n messages.push(secondary instanceof Error ? secondary.message : String(secondary));\n }\n if (current instanceof CodesysCompilerError) diagnostics = current.diagnostics;\n current = current.cause;\n }\n return { message: messages.length ? messages.join(\"\\n\") : String(cause), diagnostics };\n}\n",
|
|
12
12
|
"import { spawn } from \"node:child_process\";\nimport { createHash } from \"node:crypto\";\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { basename, posix, relative, resolve, sep } from \"node:path\";\nimport type { ResolvedConfig } from \"./config.ts\";\n\n/** Run a Docker client command attached to this terminal and forward interruption to it. */\nexport const runDocker = (args: string[], failure: (status: string | number | null) => string) =>\n new Promise<void>((accept, reject) => {\n const child = spawn(\"docker\", args, {\n stdio: [\"inherit\", process.env[\"TSPLC_LOG_STDERR\"] === \"1\" ? 2 : \"inherit\", \"inherit\"],\n });\n const interrupt = () => child.kill(\"SIGINT\");\n const terminate = () => child.kill(\"SIGTERM\");\n process.on(\"SIGINT\", interrupt);\n process.on(\"SIGTERM\", terminate);\n const cleanup = () => {\n process.off(\"SIGINT\", interrupt);\n process.off(\"SIGTERM\", terminate);\n };\n child.once(\"error\", (cause) => {\n cleanup();\n reject(\n new Error(\"Cannot start Docker. Install and start Docker Desktop or Docker Engine\", {\n cause,\n }),\n );\n });\n child.once(\"exit\", (code, signal) => {\n cleanup();\n if (code === 0) {\n accept();\n } else {\n reject(new Error(failure(signal ?? code)));\n }\n });\n });\n\nconst bindMount = (source: string, target: string, readonly = false) =>\n `type=bind,\"source=${source.replaceAll('\"', '\"\"')}\",target=${target}${readonly ? \",readonly\" : \"\"}`;\n\nasync function mountLibraries(libraries: ResolvedConfig[\"libraries\"], directory: string) {\n if (!libraries?.length) return undefined;\n await mkdir(resolve(directory, \"libraries\"), { recursive: true });\n return Promise.all(\n libraries.map(async (library) => {\n const data = await readFile(library.path);\n if (createHash(\"sha256\").update(data).digest(\"hex\") !== library.sha256)\n throw new Error(`Library changed after installation: ${library.title}`);\n const filename = `${library.sha256}-${basename(library.path)}`;\n await writeFile(resolve(directory, \"libraries\", filename), data);\n return { ...library, path: `/input/libraries/${filename}` };\n }),\n );\n}\n\n/**\n * Write the builder container's request and return the `docker run` options that precede\n * the image; the caller chooses `--rm` or `--detach`. The request names only container paths;\n * host paths enter through these mounts, and host-only Docker, CODESYS and runtime settings\n * stay outside.\n */\nexport const prepareContainerRun = async (\n config: ResolvedConfig,\n requestDirectory: string,\n options: { network: string; environment?: string[]; sources: boolean },\n) => {\n await writeFile(\n resolve(requestDirectory, \"config.json\"),\n JSON.stringify({\n ...config,\n libraries: options.sources ? await mountLibraries(config.libraries, requestDirectory) : [],\n configFile: undefined,\n docker: undefined,\n codesys: undefined,\n sourceDir: \"/sources\",\n entry: posix.resolve(\n \"/sources\",\n relative(config.sourceDir, config.entry).split(sep).join(\"/\"),\n ),\n template: \"/template.project\",\n outDir: \"/output\",\n runtime: undefined,\n }),\n );\n return [\n \"--init\",\n ...(config.docker?.cpuSet ? [\"--cpuset-cpus\", config.docker.cpuSet] : []),\n \"--network\",\n options.network,\n ...(process.getuid && process.getgid\n ? [\"--user\", `${process.getuid()}:${process.getgid()}`]\n : []),\n ...(options.environment ?? []).flatMap((name) => [\"--env\", name]),\n \"--mount\",\n bindMount(requestDirectory, \"/input\", true),\n ...(options.sources ? [\"--mount\", bindMount(config.sourceDir, \"/sources\", true)] : []),\n \"--mount\",\n bindMount(config.template, \"/template.project\", true),\n \"--mount\",\n bindMount(config.outDir, \"/output\"),\n ];\n};\n",
|
|
13
|
-
"// Engineering CLI lifecycle and diagnostics.\nimport { createHash, randomUUID } from \"node:crypto\";\nimport {\n access,\n copyFile,\n mkdir,\n open,\n readFile,\n rename,\n rm,\n unlink,\n writeFile,\n} from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { z } from \"zod\";\nimport type { ResolvedConfig } from \"../config\";\nimport { CodesysCompilerError, compilerDiagnosticSchema } from \"../diagnostics\";\nimport { packageDirectory } from \"../package-path\";\nimport { codesysCommand, getHostCommand } from \"./codesys-command\";\nimport { createCodesysHost } from \"./codesys-host\";\n\nconst pythonDirectory = resolve(packageDirectory, \"python\");\n\nconst sessionSchema = z.object({\n directory: z.string(),\n fingerprint: z.string(),\n pid: z.int().positive(),\n});\n\ntype Session = z.infer<typeof sessionSchema>;\n\ntype WorkerCommand =\n | { action: \"build\" | \"online\" | \"ping\" | \"stop\" }\n | { action: \"login\"; arguments: Record<string, unknown> };\n\nconst hasCode = (error: unknown, code: string) =>\n error instanceof Error && \"code\" in error && error.code === code;\n\nconst isProcessAlive = (pid: number) => {\n try {\n // Signal 0 checks process existence and permissions without killing it.\n // Only ESRCH means it is gone; permission errors must still propagate.\n process.kill(pid, 0);\n return true;\n } catch (error) {\n if (hasCode(error, \"ESRCH\")) {\n return false;\n }\n throw error;\n }\n};\n\nconst readJson = async (path: string): Promise<unknown> => {\n try {\n return JSON.parse(await readFile(path, \"utf8\"));\n } catch (error) {\n if (hasCode(error, \"ENOENT\")) {\n return undefined;\n }\n throw error;\n }\n};\n\nexport const writeJson = async (path: string, value: unknown) => {\n // Readers may poll while we write. A temporary file in the same directory\n // lets rename publish the complete JSON atomically, without exposing partial data.\n const temporary = `${path}.${randomUUID()}.tmp`;\n await writeFile(temporary, JSON.stringify(value, null, 2));\n await rename(temporary, path);\n};\n\n/** Serializes startup and builds across CLI processes, including separate terminals. */\nexport const withWorkerLock = async <T>(\n config: Pick<ResolvedConfig, \"outDir\" | \"timeouts\">,\n run: () => Promise<T>,\n) => {\n await mkdir(resolve(config.outDir, \".codesys\"), { recursive: true });\n const path = resolve(config.outDir, \".codesys/command.lock\");\n const deadline = Date.now() + config.timeouts.build + config.timeouts.startup;\n while (true) {\n const lock = await open(path, \"wx\").catch((error: unknown) => {\n if (hasCode(error, \"EEXIST\")) {\n return;\n }\n throw error;\n });\n\n if (lock) {\n try {\n await lock.writeFile(String(process.pid));\n return await run();\n } finally {\n await lock.close();\n await unlink(path);\n }\n }\n\n const owner = Number(\n await readFile(path, \"utf8\").catch((error: unknown) => {\n if (hasCode(error, \"ENOENT\")) {\n return \"\";\n }\n throw error;\n }),\n );\n if (owner > 0 && !isProcessAlive(owner)) {\n // Do not race another client by deleting a lock that it may have replaced.\n throw new Error(`An interrupted command left ${path}. Remove that lock file and retry.`);\n }\n if (Date.now() >= deadline) {\n throw new Error(`Timed out waiting for another CODESYS command (${path})`);\n }\n await delay(200);\n }\n};\n\n/** The caller holds withWorkerLock throughout a command, so the mailbox has one writer. */\nexport const createCodesysWorker = (config: ResolvedConfig) => {\n const host = createCodesysHost(config);\n const statePath = resolve(config.outDir, \".codesys/session.json\");\n const verifiedProcesses = new Set<number>();\n\n const readSession = async (): Promise<Session | undefined> => {\n const value = await readJson(statePath);\n if (value === undefined) {\n return undefined;\n }\n const session = sessionSchema.safeParse(value);\n if (!session.success) {\n throw new Error(`Invalid CODESYS session file: ${statePath}`, {\n cause: session.error,\n });\n }\n return session.data;\n };\n\n const terminate = async (session: Session) => {\n if (isProcessAlive(session.pid)) {\n await host.terminate(session.pid).catch((error: unknown) => {\n if (isProcessAlive(session.pid)) {\n throw error;\n }\n });\n }\n // Let the launcher reap its child before another error handler or CLI\n // checks this PID. Sending a second group kill can otherwise race exit.\n const deadline = Date.now() + config.timeouts.terminate;\n while (isProcessAlive(session.pid)) {\n if (Date.now() >= deadline) {\n throw new Error(\"CODESYS launcher did not exit after termination\");\n }\n await delay(50);\n }\n };\n\n const request = async (session: Session, command: WorkerCommand, timeoutMs: number) => {\n const id = randomUUID();\n const reply = resolve(session.directory, `${id}.json`);\n await writeJson(resolve(session.directory, \"request.json\"), {\n ...command,\n id,\n });\n const deadline = Date.now() + timeoutMs;\n while (true) {\n const result = await readJson(reply);\n if (result !== undefined) {\n await unlink(reply);\n const response = z\n .object({\n error: z.string().optional(),\n diagnostics: z.array(compilerDiagnosticSchema).optional(),\n id: z.literal(id),\n ok: z.boolean(),\n result: z.unknown(),\n })\n .safeParse(result);\n if (!response.success) {\n throw new Error(\"CODESYS returned an invalid worker response\", {\n cause: response.error,\n });\n }\n verifiedProcesses.add(session.pid);\n if (!response.data.ok) {\n const message = response.data.error ?? \"CODESYS build failed\";\n if (response.data.diagnostics) {\n throw new CodesysCompilerError(message, response.data.diagnostics);\n }\n throw new Error(message);\n }\n return response.data.result;\n }\n if (!isProcessAlive(session.pid)) {\n throw new Error(\n `CODESYS exited before completing ${command.action}. See ${session.directory}/codesys.log`,\n );\n }\n if (Date.now() >= deadline) {\n // A saved PID can belong to another process after a reboot. Only kill\n // a process we launched or whose worker answered in this invocation.\n if (!verifiedProcesses.has(session.pid)) {\n throw new Error(\n `CODESYS ${command.action} timed out without a worker response. Close the dedicated IDE if it is still running, then retry. Log: ${session.directory}/codesys.log`,\n );\n }\n await terminate(session);\n throw new Error(\n `CODESYS ${command.action} timed out after ${timeoutMs} ms; the worker was stopped. See ${session.directory}/codesys.log`,\n );\n }\n await delay(200);\n }\n };\n\n const launch = async () => {\n await access(config.codesys.executable);\n const fingerprint = createHash(\"sha256\")\n .update(\n JSON.stringify({\n builder: await readFile(resolve(pythonDirectory, \"build.py\"), \"utf8\"),\n online: await readFile(resolve(pythonDirectory, \"codesys_online.py\"), \"utf8\"),\n login: await readFile(resolve(pythonDirectory, \"codesys_login.py\"), \"utf8\"),\n command: codesysCommand,\n codesys: config.codesys,\n launcher: getHostCommand(config.codesys),\n worker: await readFile(resolve(pythonDirectory, \"worker.py\"), \"utf8\"),\n }),\n )\n .digest(\"hex\");\n const previous = await readSession();\n if (previous && isProcessAlive(previous.pid)) {\n if (previous.fingerprint !== fingerprint) {\n throw new Error(\n \"CODESYS settings or worker scripts changed. Run stop-worker, then launch-worker to reload them.\",\n );\n }\n await request(previous, { action: \"ping\" }, config.timeouts.startup);\n console.log(\n `Reusing CODESYS worker (${previous.pid}). Log: ${previous.directory}/codesys.log`,\n );\n return previous;\n }\n\n const directory = resolve(config.outDir, \".codesys/worker\");\n await rm(directory, { recursive: true, force: true });\n await mkdir(directory, { recursive: true });\n await copyFile(resolve(pythonDirectory, \"build.py\"), resolve(directory, \"build.py\"));\n await copyFile(resolve(pythonDirectory, \"worker.py\"), resolve(directory, \"worker.py\"));\n for (const script of [\"codesys_online.py\", \"codesys_login.py\"]) {\n await copyFile(resolve(pythonDirectory, script), resolve(directory, script));\n }\n console.log(`Starting CODESYS. Log: ${directory}/codesys.log`);\n const pid = await host.startWorker(directory);\n const session = { directory, fingerprint, pid };\n verifiedProcesses.add(pid);\n try {\n await writeJson(statePath, session);\n await request(session, { action: \"ping\" }, config.timeouts.startup);\n } catch (error) {\n await terminate(session);\n throw error;\n }\n console.log(`CODESYS worker ready (${session.pid})`);\n return session;\n };\n\n const checkExit = async (session: Session) => {\n const status = await host.exitStatus(session.directory, session.pid);\n if (!status) {\n throw new Error(\n `CODESYS launcher stopped but its exit status is unavailable. See ${session.directory}/codesys.log`,\n );\n }\n if (status.code !== 0 || status.signal !== null) {\n throw new Error(\n `CODESYS launcher exited with ${status.signal ?? status.code ?? \"unknown status\"}. See ${session.directory}/codesys.log`,\n );\n }\n };\n\n const stop = async () => {\n const session = await readSession();\n if (!session) {\n console.log(\"CODESYS worker is already stopped\");\n return;\n }\n if (!isProcessAlive(session.pid)) {\n await checkExit(session);\n console.log(\"CODESYS worker is already stopped\");\n return;\n }\n await request(session, { action: \"stop\" }, config.timeouts.stop);\n const deadline = Date.now() + config.timeouts.stop;\n while (isProcessAlive(session.pid)) {\n if (Date.now() >= deadline) {\n const message = `CODESYS stop timed out after ${config.timeouts.stop} ms. See ${session.directory}/codesys.log`;\n try {\n await terminate(session);\n } catch (cause) {\n throw new Error(`${message}; the worker could not be terminated`, { cause });\n }\n throw new Error(`${message}; the worker required forced termination`);\n }\n await delay(200);\n }\n await checkExit(session);\n console.log(\"CODESYS worker stopped\");\n };\n\n return { launch, request, stop };\n};\n\n/** Container jobs own a temporary worker; always stop it before returning a result. */\nexport const withWorkerShutdown = async <T>(\n config: ResolvedConfig,\n worker: Pick<ReturnType<typeof createCodesysWorker>, \"stop\">,\n operation: () => Promise<T>,\n) => {\n const stop = async () => {\n try {\n await withWorkerLock(config, () => worker.stop());\n } finally {\n await rm(resolve(config.outDir, \".codesys/session.json\"), { force: true });\n }\n };\n return Promise.resolve()\n .then(operation)\n .then(\n async (result) => {\n await stop();\n return result;\n },\n async (failure: unknown) => {\n await stop().catch((shutdownFailure: unknown) => {\n // Diagnostic mapping follows cause; keep the operation there and\n // expose its shutdown failure alongside it in errors.\n throw new AggregateError(\n [failure, shutdownFailure],\n \"CODESYS operation and worker shutdown failed\",\n {\n cause: failure,\n },\n );\n });\n throw failure;\n },\n );\n};\n",
|
|
13
|
+
"// Engineering CLI lifecycle and diagnostics.\nimport { createHash, randomUUID } from \"node:crypto\";\nimport {\n access,\n copyFile,\n mkdir,\n open,\n readFile,\n rename,\n rm,\n unlink,\n writeFile,\n} from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { z } from \"zod\";\nimport type { ResolvedConfig } from \"../config\";\nimport { CodesysCompilerError, compilerDiagnosticSchema } from \"../diagnostics\";\nimport { packageDirectory } from \"../package-path\";\nimport { codesysCommand, getHostCommand } from \"./codesys-command\";\nimport { createCodesysHost } from \"./codesys-host\";\n\nconst pythonDirectory = resolve(packageDirectory, \"python\");\n\nconst sessionSchema = z.object({\n directory: z.string(),\n fingerprint: z.string(),\n pid: z.int().positive(),\n});\n\ntype Session = z.infer<typeof sessionSchema>;\n\ntype WorkerCommand =\n | { action: \"build\" | \"online\" | \"ping\" | \"stop\" }\n | { action: \"login\"; arguments: Record<string, unknown> };\n\nconst hasCode = (error: unknown, code: string) =>\n error instanceof Error && \"code\" in error && error.code === code;\n\nconst isProcessAlive = (pid: number) => {\n try {\n // Signal 0 checks process existence and permissions without killing it.\n // Only ESRCH means it is gone; permission errors must still propagate.\n process.kill(pid, 0);\n return true;\n } catch (error) {\n if (hasCode(error, \"ESRCH\")) {\n return false;\n }\n throw error;\n }\n};\n\nconst readJson = async (path: string): Promise<unknown> => {\n try {\n return JSON.parse(await readFile(path, \"utf8\"));\n } catch (error) {\n if (hasCode(error, \"ENOENT\")) {\n return undefined;\n }\n throw error;\n }\n};\n\nexport const writeJson = async (path: string, value: unknown) => {\n // Readers may poll while we write. A temporary file in the same directory\n // lets rename publish the complete JSON atomically, without exposing partial data.\n const temporary = `${path}.${randomUUID()}.tmp`;\n await writeFile(temporary, JSON.stringify(value, null, 2));\n await rename(temporary, path);\n};\n\n/** Serializes startup and builds across CLI processes, including separate terminals. */\nexport const withWorkerLock = async <T>(\n config: Pick<ResolvedConfig, \"outDir\" | \"timeouts\">,\n run: () => Promise<T>,\n) => {\n await mkdir(resolve(config.outDir, \".codesys\"), { recursive: true });\n const path = resolve(config.outDir, \".codesys/command.lock\");\n const deadline = Date.now() + config.timeouts.build + config.timeouts.startup;\n while (true) {\n const lock = await open(path, \"wx\").catch((error: unknown) => {\n if (hasCode(error, \"EEXIST\")) {\n return;\n }\n throw error;\n });\n\n if (lock) {\n try {\n await lock.writeFile(String(process.pid));\n return await run();\n } finally {\n await lock.close();\n await unlink(path);\n }\n }\n\n const owner = Number(\n await readFile(path, \"utf8\").catch((error: unknown) => {\n if (hasCode(error, \"ENOENT\")) {\n return \"\";\n }\n throw error;\n }),\n );\n if (owner > 0 && !isProcessAlive(owner)) {\n // Do not race another client by deleting a lock that it may have replaced.\n throw new Error(`An interrupted command left ${path}. Remove that lock file and retry.`);\n }\n if (Date.now() >= deadline) {\n throw new Error(`Timed out waiting for another CODESYS command (${path})`);\n }\n await delay(200);\n }\n};\n\n/** The caller holds withWorkerLock throughout a command, so the mailbox has one writer. */\nexport const createCodesysWorker = (config: ResolvedConfig) => {\n const host = createCodesysHost(config);\n const statePath = resolve(config.outDir, \".codesys/session.json\");\n const verifiedProcesses = new Set<number>();\n\n const readSession = async (): Promise<Session | undefined> => {\n const value = await readJson(statePath);\n if (value === undefined) {\n return undefined;\n }\n const session = sessionSchema.safeParse(value);\n if (!session.success) {\n throw new Error(`Invalid CODESYS session file: ${statePath}`, {\n cause: session.error,\n });\n }\n return session.data;\n };\n\n const terminate = async (session: Session) => {\n if (isProcessAlive(session.pid)) {\n await host.terminate(session.pid).catch((error: unknown) => {\n if (isProcessAlive(session.pid)) {\n throw error;\n }\n });\n }\n // Let the launcher reap its child before another error handler or CLI\n // checks this PID. Sending a second group kill can otherwise race exit.\n const deadline = Date.now() + config.timeouts.terminate;\n while (isProcessAlive(session.pid)) {\n if (Date.now() >= deadline) {\n throw new Error(\"CODESYS launcher did not exit after termination\");\n }\n await delay(50);\n }\n };\n\n const request = async (session: Session, command: WorkerCommand, timeoutMs: number) => {\n const id = randomUUID();\n const reply = resolve(session.directory, `${id}.json`);\n await writeJson(resolve(session.directory, \"request.json\"), {\n ...command,\n id,\n });\n const deadline = Date.now() + timeoutMs;\n while (true) {\n const result = await readJson(reply);\n if (result !== undefined) {\n await unlink(reply);\n const response = z\n .object({\n error: z.string().optional(),\n diagnostics: z.array(compilerDiagnosticSchema).optional(),\n id: z.literal(id),\n ok: z.boolean(),\n result: z.unknown(),\n })\n .safeParse(result);\n if (!response.success) {\n throw new Error(\"CODESYS returned an invalid worker response\", {\n cause: response.error,\n });\n }\n verifiedProcesses.add(session.pid);\n if (!response.data.ok) {\n const message = response.data.error ?? \"CODESYS build failed\";\n if (response.data.diagnostics) {\n throw new CodesysCompilerError(message, response.data.diagnostics);\n }\n throw new Error(message);\n }\n return response.data.result;\n }\n if (!isProcessAlive(session.pid)) {\n throw new Error(\n `CODESYS exited before completing ${command.action}. See ${session.directory}/codesys.log`,\n );\n }\n if (Date.now() >= deadline) {\n // A saved PID can belong to another process after a reboot. Only kill\n // a process we launched or whose worker answered in this invocation.\n if (!verifiedProcesses.has(session.pid)) {\n throw new Error(\n `CODESYS ${command.action} timed out without a worker response. Close the dedicated IDE if it is still running, then retry. Log: ${session.directory}/codesys.log`,\n );\n }\n await terminate(session);\n throw new Error(\n `CODESYS ${command.action} timed out after ${timeoutMs} ms; the worker was stopped. See ${session.directory}/codesys.log`,\n );\n }\n await delay(200);\n }\n };\n\n const launch = async () => {\n await access(config.codesys.executable);\n const fingerprint = createHash(\"sha256\")\n .update(\n JSON.stringify({\n builder: await readFile(resolve(pythonDirectory, \"build.py\"), \"utf8\"),\n hardware: await readFile(resolve(pythonDirectory, \"hardware.py\"), \"utf8\"),\n online: await readFile(resolve(pythonDirectory, \"codesys_online.py\"), \"utf8\"),\n login: await readFile(resolve(pythonDirectory, \"codesys_login.py\"), \"utf8\"),\n command: codesysCommand,\n codesys: config.codesys,\n launcher: getHostCommand(config.codesys),\n worker: await readFile(resolve(pythonDirectory, \"worker.py\"), \"utf8\"),\n }),\n )\n .digest(\"hex\");\n const previous = await readSession();\n if (previous && isProcessAlive(previous.pid)) {\n if (previous.fingerprint !== fingerprint) {\n throw new Error(\n \"CODESYS settings or worker scripts changed. Run stop-worker, then launch-worker to reload them.\",\n );\n }\n await request(previous, { action: \"ping\" }, config.timeouts.startup);\n console.log(\n `Reusing CODESYS worker (${previous.pid}). Log: ${previous.directory}/codesys.log`,\n );\n return previous;\n }\n\n const directory = resolve(config.outDir, \".codesys/worker\");\n await rm(directory, { recursive: true, force: true });\n await mkdir(directory, { recursive: true });\n for (const script of [\n \"build.py\",\n \"hardware.py\",\n \"worker.py\",\n \"codesys_online.py\",\n \"codesys_login.py\",\n ]) {\n await copyFile(resolve(pythonDirectory, script), resolve(directory, script));\n }\n console.log(`Starting CODESYS. Log: ${directory}/codesys.log`);\n const pid = await host.startWorker(directory);\n const session = { directory, fingerprint, pid };\n verifiedProcesses.add(pid);\n try {\n await writeJson(statePath, session);\n await request(session, { action: \"ping\" }, config.timeouts.startup);\n } catch (error) {\n await terminate(session);\n throw error;\n }\n console.log(`CODESYS worker ready (${session.pid})`);\n return session;\n };\n\n const checkExit = async (session: Session) => {\n const status = await host.exitStatus(session.directory, session.pid);\n if (!status) {\n throw new Error(\n `CODESYS launcher stopped but its exit status is unavailable. See ${session.directory}/codesys.log`,\n );\n }\n if (status.code !== 0 || status.signal !== null) {\n throw new Error(\n `CODESYS launcher exited with ${status.signal ?? status.code ?? \"unknown status\"}. See ${session.directory}/codesys.log`,\n );\n }\n };\n\n const stop = async () => {\n const session = await readSession();\n if (!session) {\n console.log(\"CODESYS worker is already stopped\");\n return;\n }\n if (!isProcessAlive(session.pid)) {\n await checkExit(session);\n console.log(\"CODESYS worker is already stopped\");\n return;\n }\n await request(session, { action: \"stop\" }, config.timeouts.stop);\n const deadline = Date.now() + config.timeouts.stop;\n while (isProcessAlive(session.pid)) {\n if (Date.now() >= deadline) {\n const message = `CODESYS stop timed out after ${config.timeouts.stop} ms. See ${session.directory}/codesys.log`;\n try {\n await terminate(session);\n } catch (cause) {\n throw new Error(`${message}; the worker could not be terminated`, { cause });\n }\n throw new Error(`${message}; the worker required forced termination`);\n }\n await delay(200);\n }\n await checkExit(session);\n console.log(\"CODESYS worker stopped\");\n };\n\n return { launch, request, stop };\n};\n\n/** Container jobs own a temporary worker; always stop it before returning a result. */\nexport const withWorkerShutdown = async <T>(\n config: ResolvedConfig,\n worker: Pick<ReturnType<typeof createCodesysWorker>, \"stop\">,\n operation: () => Promise<T>,\n) => {\n const stop = async () => {\n try {\n await withWorkerLock(config, () => worker.stop());\n } finally {\n await rm(resolve(config.outDir, \".codesys/session.json\"), { force: true });\n }\n };\n return Promise.resolve()\n .then(operation)\n .then(\n async (result) => {\n await stop();\n return result;\n },\n async (failure: unknown) => {\n await stop().catch((shutdownFailure: unknown) => {\n // Diagnostic mapping follows cause; keep the operation there and\n // expose its shutdown failure alongside it in errors.\n throw new AggregateError(\n [failure, shutdownFailure],\n \"CODESYS operation and worker shutdown failed\",\n {\n cause: failure,\n },\n );\n });\n throw failure;\n },\n );\n};\n",
|
|
14
14
|
"export { commandArgumentSchema } from \"@tsplc/config/build\";\nimport type { ResolvedConfig } from \"../config\";\n\nexport const codesysCommandFilename = \"run.cmd\";\n\n// Keep arguments in the Windows command line: Wine's direct launcher loses the\n// quoting CODESYS needs around profile names containing spaces.\nexport const codesysCommand = [\n \"@echo off\",\n \"setlocal DisableDelayedExpansion\",\n '\"%CODESYS_BUILD_EXE%\" --profile=\"%CODESYS_BUILD_PROFILE%\" --noUI --runscript=\"%CODESYS_BUILD_SCRIPT%\"',\n 'set \"CODESYS_BUILD_STATUS=%errorlevel%\"',\n 'if not \"%CODESYS_BUILD_STATUS%\"==\"0\" echo CODESYS Windows exit status: %CODESYS_BUILD_STATUS%',\n \"exit /b %CODESYS_BUILD_STATUS%\",\n \"\",\n].join(\"\\r\\n\");\n\nexport const getHostCommand = (\n config: Pick<ResolvedConfig[\"codesys\"], \"commandProcessor\" | \"wine\">,\n platform: NodeJS.Platform = process.platform,\n) => {\n if (platform === \"win32\") {\n return {\n args: [\"/d\", \"/c\", codesysCommandFilename],\n executable: config.commandProcessor,\n };\n }\n const wine = config.wine?.binary ?? \"wine\";\n const args = [\"cmd\", \"/d\", \"/c\", codesysCommandFilename];\n // --noUI suppresses the IDE, but CODESYS can still allocate a Wine console.\n // A private PTY makes Wine use its Unix console instead of a desktop window.\n return {\n args:\n platform === \"darwin\"\n ? [\"-q\", \"/dev/null\", wine, ...args]\n : [\n \"-q\",\n \"-e\",\n \"-c\",\n [wine, ...args].map((argument) => `'${argument.replaceAll(\"'\", \"'\\\\''\")}'`).join(\" \"),\n \"/dev/null\",\n ],\n executable: \"script\",\n };\n};\n",
|
|
15
15
|
"import { execFile, spawn } from \"node:child_process\";\nimport { appendFileSync, constants, renameSync, writeFileSync } from \"node:fs\";\nimport { access, open, readFile, rm, writeFile } from \"node:fs/promises\";\nimport { delimiter, resolve } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { z } from \"zod\";\nimport type { ResolvedConfig } from \"../config\";\nimport { codesysCommand, codesysCommandFilename, getHostCommand } from \"./codesys-command\";\nimport { toWindowsPath } from \"./windows-path\";\n\nconst execute = promisify(execFile);\nconst exitSchema = z.object({\n pid: z.int().positive(),\n code: z.int().nullable(),\n signal: z.string().nullable(),\n});\n\nexport const createCodesysHost = (config: ResolvedConfig) => {\n const native = process.platform === \"win32\";\n const { codesys, timeouts } = config;\n const hostEnv = {\n // Child processes inherit OS settings such as PATH and DISPLAY.\n ...process.env,\n WINEPREFIX: codesys.wine?.prefix,\n WINEDEBUG: codesys.wine?.debug,\n CODESYS_BUILD_NATIVE_EXIT: String(codesys.wine?.nativeExit ?? false),\n };\n\n /** Start the headless worker script; its launcher outlives this CLI process. */\n const startWorker = async (directory: string) => {\n if (!native) {\n await execute(codesys.wine?.binary ?? \"wine\", [\"--version\"], {\n env: hostEnv,\n timeout: timeouts.hostCommand,\n }).catch((cause: unknown) => {\n throw new Error(\"Wine is missing. Install Wine; Winetricks alone cannot run CODESYS\", {\n cause,\n });\n });\n const candidates = (process.env[\"PATH\"] ?? \"\").split(delimiter);\n const available = await Promise.all(\n candidates.map((path) =>\n access(resolve(path, \"script\"), constants.X_OK).then(\n () => true,\n () => false,\n ),\n ),\n );\n if (!available.some(Boolean)) {\n throw new Error(\n \"Headless Wine builds require the script utility (install util-linux on Linux)\",\n );\n }\n }\n await writeFile(resolve(directory, codesysCommandFilename), codesysCommand);\n const env = {\n ...hostEnv,\n CODESYS_BUILD_SCRIPT: await toWindowsPath(resolve(directory, \"worker.py\"), codesys.wine),\n CODESYS_BUILD_SESSION: await toWindowsPath(directory, codesys.wine),\n CODESYS_BUILD_EXE: await toWindowsPath(codesys.executable, codesys.wine),\n CODESYS_BUILD_PROFILE: codesys.profile,\n };\n const launcher = getHostCommand(codesys);\n const logPath = resolve(directory, \"codesys.log\");\n const exitPath = resolve(directory, \"launcher-exit.json\");\n await rm(exitPath, { force: true });\n const log = await open(logPath, \"w\");\n try {\n const child = spawn(launcher.executable, launcher.args, {\n cwd: directory,\n detached: true,\n windowsHide: true,\n env,\n stdio: [\"ignore\", log.fd, log.fd],\n });\n child.once(\"exit\", (code, signal) => {\n try {\n appendFileSync(logPath, `\\nCODESYS launcher exit status: ${signal ?? code}\\n`);\n } catch {\n // A failed diagnostic write must preserve the original process failure.\n }\n try {\n const temporary = `${exitPath}.${child.pid}.tmp`;\n writeFileSync(temporary, JSON.stringify({ pid: child.pid, code, signal }));\n renameSync(temporary, exitPath);\n } catch {\n // The log still records the exit if the optional status file cannot be published.\n }\n });\n await new Promise<void>((resolve, reject) => {\n child.once(\"error\", reject);\n child.once(\"spawn\", resolve);\n });\n if (!child.pid) {\n throw new Error(\"CODESYS launcher has no process ID\");\n }\n child.unref();\n return child.pid;\n } finally {\n await log.close();\n }\n };\n\n return {\n exitStatus: async (directory: string, pid: number) => {\n const path = resolve(directory, \"launcher-exit.json\");\n const value = await readFile(path, \"utf8\").catch((error: unknown) => {\n if (error instanceof Error && \"code\" in error && error.code === \"ENOENT\") return;\n throw error;\n });\n if (value === undefined) return;\n const status = exitSchema.parse(JSON.parse(value));\n if (status.pid !== pid) return;\n if (!native && status.code === 0 && status.signal === null) {\n const logPath = resolve(directory, \"codesys.log\");\n const log = (await readFile(logPath, \"utf8\"))\n // Terminal color escapes must not hide a native assertion signature.\n // eslint-disable-next-line no-control-regex\n .replace(/\\u001b\\[[0-?]*[ -/]*[@-~]/g, \"\")\n .replace(/\\r/g, \"\");\n // A Wine helper can abort under Rosetta while the main launcher returns 0.\n // That command still failed; publishing its artifacts would hide the fault.\n const assertion =\n /^assertion failed \\[[^\\n]+\\]:[^\\n]*\\n[ \\t]*\\(ThreadContext[^\\n]*\\)/m.exec(log);\n if (assertion) {\n throw new Error(\n `Wine translation aborted: ${assertion[0].replace(/\\s+/g, \" \")}. See ${logPath}`,\n );\n }\n }\n return status;\n },\n startWorker,\n terminate: async (pid: number) => {\n if (native) {\n await execute(\"taskkill.exe\", [\"/pid\", String(pid), \"/t\", \"/f\"], {\n env: hostEnv,\n timeout: timeouts.hostCommand,\n });\n return;\n }\n process.kill(-pid, \"SIGKILL\");\n },\n };\n};\n",
|
|
16
16
|
"import { execFile } from \"node:child_process\";\nimport { readdir, realpath } from \"node:fs/promises\";\nimport { relative, resolve, sep, win32 } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport type { ResolvedConfig } from \"../config\";\nimport { commandArgumentSchema } from \"./codesys-command\";\nimport { codesysTimeouts } from \"@tsplc/config/build\";\n\nconst execute = promisify(execFile);\n\nexport const toWindowsPath = async (\n path: string,\n wine: ResolvedConfig[\"codesys\"][\"wine\"],\n platform: NodeJS.Platform = process.platform,\n) => {\n if (platform === \"win32\") {\n return commandArgumentSchema.parse(win32.resolve(path));\n }\n if (!wine?.prefix) {\n throw new Error(\"Configure codesys.wine.prefix for CODESYS on macOS/Linux\");\n }\n\n // Resolve filesystem aliases (e.g. macOS /var -> /private/var) so the file\n // and drive roots are compared using the same paths. Fall back to the absolute\n // spelling when realpath cannot resolve the file, such as before it exists.\n const absolute = await realpath(resolve(path)).catch(() => resolve(path));\n // Wine stores drive mappings as symlinks, e.g. dosdevices/c: -> ../drive_c.\n // Reading these links avoids starting Wine just to convert a path.\n const devices = resolve(wine.prefix, \"dosdevices\");\n const drives = await readdir(devices).catch(() => []);\n const mappings = await Promise.all(\n drives\n .filter((drive) => /^[a-z]:$/i.test(drive))\n .map(async (drive) => {\n const root = await realpath(resolve(devices, drive)).catch(() => undefined);\n if (!root) {\n return;\n }\n\n const suffix = relative(root, absolute);\n // \"..\" or \"../...\" means the file is outside this drive's root.\n // Match a full segment so a name such as \"..notes\" remains valid.\n if (suffix === \"..\" || suffix.startsWith(`..${sep}`)) {\n return;\n }\n\n return {\n root,\n windows: win32.join(`${drive.toUpperCase()}\\\\`, ...suffix.split(sep)),\n };\n }),\n );\n // Mappings can overlap: C: -> /wine/drive_c and Z: -> / may both contain\n // the file. Prefer the longest matching root to use the most specific drive.\n const [mapping] = mappings\n .filter((value) => value !== undefined)\n .sort((a, b) => b.root.length - a.root.length);\n if (mapping) {\n return commandArgumentSchema.parse(mapping.windows);\n }\n\n // No readable drive mapping covers the path; ask Wine to convert it instead.\n const result = await execute(\n wine.pathBinary,\n [\"-w\", resolve(path)],\n // Wine inherits OS process settings.\n {\n env: { ...process.env, WINEPREFIX: wine.prefix, WINEDEBUG: wine.debug },\n timeout: codesysTimeouts.hostCommand,\n },\n );\n return commandArgumentSchema.parse(result.stdout.trim());\n};\n",
|
package/dist/index.js.map
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"import { resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n// Both source modules and published bundles live one directory below the package root.\nexport const packageDirectory = resolve(fileURLToPath(new URL(\"..\", import.meta.url)));\n",
|
|
10
10
|
"import { z } from \"zod\";\n\nexport const compilerDiagnosticSchema = z\n .object({\n category: z.string(),\n text: z.string(),\n severity: z.string().nullable(),\n project: z.string().nullable(),\n object: z\n .object({\n name: z.string(),\n guid: z.string(),\n path: z.array(z.string()),\n /** Verified against this message project's active application GUID. */\n isApplicationChild: z.boolean().nullable(),\n })\n .passthrough()\n .nullable(),\n /** Opaque vendor position. It must not be decoded as a line or column. */\n position: z.string().nullable(),\n positionText: z.string().nullable(),\n positionOffset: z.int().nullable(),\n length: z.int().nullable(),\n prefix: z.string().nullable(),\n number: z.int().nullable(),\n })\n .passthrough();\n\nexport type CodesysCompilerDiagnostic = z.infer<typeof compilerDiagnosticSchema>;\n\n/** Preserves vendor diagnostics as the cause of a buildProject failure. */\nexport class CodesysCompilerError extends Error {\n constructor(\n message: string,\n readonly diagnostics: readonly CodesysCompilerDiagnostic[],\n ) {\n super(message);\n this.name = \"CodesysCompilerError\";\n }\n}\n\n// A failed container job publishes diagnostics separately from its successful build receipt.\nexport const buildFailureFile = \".codesys/docker/failure.json\";\n/** Why the last download, online change or inspection failed, for the host to report. */\nexport const controllerFailureFile = \".codesys/controller/failure.txt\";\nexport const buildFailureSchema = z.strictObject({\n operationId: z.uuid(),\n message: z.string(),\n diagnostics: z.array(compilerDiagnosticSchema),\n});\n\n/** Keep the primary compiler location and the shutdown context across the Docker boundary. */\nexport function describeBuildFailure(cause: unknown) {\n const messages: string[] = [];\n let diagnostics: readonly CodesysCompilerDiagnostic[] = [];\n let current = cause;\n while (current instanceof Error) {\n messages.push(current.message);\n if (current instanceof AggregateError) {\n // withWorkerShutdown puts the primary failure in cause and secondary failures in errors.\n for (const secondary of current.errors.slice(1))\n messages.push(secondary instanceof Error ? secondary.message : String(secondary));\n }\n if (current instanceof CodesysCompilerError) diagnostics = current.diagnostics;\n current = current.cause;\n }\n return { message: messages.length ? messages.join(\"\\n\") : String(cause), diagnostics };\n}\n",
|
|
11
11
|
"import { spawn } from \"node:child_process\";\nimport { createHash } from \"node:crypto\";\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { basename, posix, relative, resolve, sep } from \"node:path\";\nimport type { ResolvedConfig } from \"./config.ts\";\n\n/** Run a Docker client command attached to this terminal and forward interruption to it. */\nexport const runDocker = (args: string[], failure: (status: string | number | null) => string) =>\n new Promise<void>((accept, reject) => {\n const child = spawn(\"docker\", args, {\n stdio: [\"inherit\", process.env[\"TSPLC_LOG_STDERR\"] === \"1\" ? 2 : \"inherit\", \"inherit\"],\n });\n const interrupt = () => child.kill(\"SIGINT\");\n const terminate = () => child.kill(\"SIGTERM\");\n process.on(\"SIGINT\", interrupt);\n process.on(\"SIGTERM\", terminate);\n const cleanup = () => {\n process.off(\"SIGINT\", interrupt);\n process.off(\"SIGTERM\", terminate);\n };\n child.once(\"error\", (cause) => {\n cleanup();\n reject(\n new Error(\"Cannot start Docker. Install and start Docker Desktop or Docker Engine\", {\n cause,\n }),\n );\n });\n child.once(\"exit\", (code, signal) => {\n cleanup();\n if (code === 0) {\n accept();\n } else {\n reject(new Error(failure(signal ?? code)));\n }\n });\n });\n\nconst bindMount = (source: string, target: string, readonly = false) =>\n `type=bind,\"source=${source.replaceAll('\"', '\"\"')}\",target=${target}${readonly ? \",readonly\" : \"\"}`;\n\nasync function mountLibraries(libraries: ResolvedConfig[\"libraries\"], directory: string) {\n if (!libraries?.length) return undefined;\n await mkdir(resolve(directory, \"libraries\"), { recursive: true });\n return Promise.all(\n libraries.map(async (library) => {\n const data = await readFile(library.path);\n if (createHash(\"sha256\").update(data).digest(\"hex\") !== library.sha256)\n throw new Error(`Library changed after installation: ${library.title}`);\n const filename = `${library.sha256}-${basename(library.path)}`;\n await writeFile(resolve(directory, \"libraries\", filename), data);\n return { ...library, path: `/input/libraries/${filename}` };\n }),\n );\n}\n\n/**\n * Write the builder container's request and return the `docker run` options that precede\n * the image; the caller chooses `--rm` or `--detach`. The request names only container paths;\n * host paths enter through these mounts, and host-only Docker, CODESYS and runtime settings\n * stay outside.\n */\nexport const prepareContainerRun = async (\n config: ResolvedConfig,\n requestDirectory: string,\n options: { network: string; environment?: string[]; sources: boolean },\n) => {\n await writeFile(\n resolve(requestDirectory, \"config.json\"),\n JSON.stringify({\n ...config,\n libraries: options.sources ? await mountLibraries(config.libraries, requestDirectory) : [],\n configFile: undefined,\n docker: undefined,\n codesys: undefined,\n sourceDir: \"/sources\",\n entry: posix.resolve(\n \"/sources\",\n relative(config.sourceDir, config.entry).split(sep).join(\"/\"),\n ),\n template: \"/template.project\",\n outDir: \"/output\",\n runtime: undefined,\n }),\n );\n return [\n \"--init\",\n ...(config.docker?.cpuSet ? [\"--cpuset-cpus\", config.docker.cpuSet] : []),\n \"--network\",\n options.network,\n ...(process.getuid && process.getgid\n ? [\"--user\", `${process.getuid()}:${process.getgid()}`]\n : []),\n ...(options.environment ?? []).flatMap((name) => [\"--env\", name]),\n \"--mount\",\n bindMount(requestDirectory, \"/input\", true),\n ...(options.sources ? [\"--mount\", bindMount(config.sourceDir, \"/sources\", true)] : []),\n \"--mount\",\n bindMount(config.template, \"/template.project\", true),\n \"--mount\",\n bindMount(config.outDir, \"/output\"),\n ];\n};\n",
|
|
12
|
-
"// Engineering CLI lifecycle and diagnostics.\nimport { createHash, randomUUID } from \"node:crypto\";\nimport {\n access,\n copyFile,\n mkdir,\n open,\n readFile,\n rename,\n rm,\n unlink,\n writeFile,\n} from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { z } from \"zod\";\nimport type { ResolvedConfig } from \"../config\";\nimport { CodesysCompilerError, compilerDiagnosticSchema } from \"../diagnostics\";\nimport { packageDirectory } from \"../package-path\";\nimport { codesysCommand, getHostCommand } from \"./codesys-command\";\nimport { createCodesysHost } from \"./codesys-host\";\n\nconst pythonDirectory = resolve(packageDirectory, \"python\");\n\nconst sessionSchema = z.object({\n directory: z.string(),\n fingerprint: z.string(),\n pid: z.int().positive(),\n});\n\ntype Session = z.infer<typeof sessionSchema>;\n\ntype WorkerCommand =\n | { action: \"build\" | \"online\" | \"ping\" | \"stop\" }\n | { action: \"login\"; arguments: Record<string, unknown> };\n\nconst hasCode = (error: unknown, code: string) =>\n error instanceof Error && \"code\" in error && error.code === code;\n\nconst isProcessAlive = (pid: number) => {\n try {\n // Signal 0 checks process existence and permissions without killing it.\n // Only ESRCH means it is gone; permission errors must still propagate.\n process.kill(pid, 0);\n return true;\n } catch (error) {\n if (hasCode(error, \"ESRCH\")) {\n return false;\n }\n throw error;\n }\n};\n\nconst readJson = async (path: string): Promise<unknown> => {\n try {\n return JSON.parse(await readFile(path, \"utf8\"));\n } catch (error) {\n if (hasCode(error, \"ENOENT\")) {\n return undefined;\n }\n throw error;\n }\n};\n\nexport const writeJson = async (path: string, value: unknown) => {\n // Readers may poll while we write. A temporary file in the same directory\n // lets rename publish the complete JSON atomically, without exposing partial data.\n const temporary = `${path}.${randomUUID()}.tmp`;\n await writeFile(temporary, JSON.stringify(value, null, 2));\n await rename(temporary, path);\n};\n\n/** Serializes startup and builds across CLI processes, including separate terminals. */\nexport const withWorkerLock = async <T>(\n config: Pick<ResolvedConfig, \"outDir\" | \"timeouts\">,\n run: () => Promise<T>,\n) => {\n await mkdir(resolve(config.outDir, \".codesys\"), { recursive: true });\n const path = resolve(config.outDir, \".codesys/command.lock\");\n const deadline = Date.now() + config.timeouts.build + config.timeouts.startup;\n while (true) {\n const lock = await open(path, \"wx\").catch((error: unknown) => {\n if (hasCode(error, \"EEXIST\")) {\n return;\n }\n throw error;\n });\n\n if (lock) {\n try {\n await lock.writeFile(String(process.pid));\n return await run();\n } finally {\n await lock.close();\n await unlink(path);\n }\n }\n\n const owner = Number(\n await readFile(path, \"utf8\").catch((error: unknown) => {\n if (hasCode(error, \"ENOENT\")) {\n return \"\";\n }\n throw error;\n }),\n );\n if (owner > 0 && !isProcessAlive(owner)) {\n // Do not race another client by deleting a lock that it may have replaced.\n throw new Error(`An interrupted command left ${path}. Remove that lock file and retry.`);\n }\n if (Date.now() >= deadline) {\n throw new Error(`Timed out waiting for another CODESYS command (${path})`);\n }\n await delay(200);\n }\n};\n\n/** The caller holds withWorkerLock throughout a command, so the mailbox has one writer. */\nexport const createCodesysWorker = (config: ResolvedConfig) => {\n const host = createCodesysHost(config);\n const statePath = resolve(config.outDir, \".codesys/session.json\");\n const verifiedProcesses = new Set<number>();\n\n const readSession = async (): Promise<Session | undefined> => {\n const value = await readJson(statePath);\n if (value === undefined) {\n return undefined;\n }\n const session = sessionSchema.safeParse(value);\n if (!session.success) {\n throw new Error(`Invalid CODESYS session file: ${statePath}`, {\n cause: session.error,\n });\n }\n return session.data;\n };\n\n const terminate = async (session: Session) => {\n if (isProcessAlive(session.pid)) {\n await host.terminate(session.pid).catch((error: unknown) => {\n if (isProcessAlive(session.pid)) {\n throw error;\n }\n });\n }\n // Let the launcher reap its child before another error handler or CLI\n // checks this PID. Sending a second group kill can otherwise race exit.\n const deadline = Date.now() + config.timeouts.terminate;\n while (isProcessAlive(session.pid)) {\n if (Date.now() >= deadline) {\n throw new Error(\"CODESYS launcher did not exit after termination\");\n }\n await delay(50);\n }\n };\n\n const request = async (session: Session, command: WorkerCommand, timeoutMs: number) => {\n const id = randomUUID();\n const reply = resolve(session.directory, `${id}.json`);\n await writeJson(resolve(session.directory, \"request.json\"), {\n ...command,\n id,\n });\n const deadline = Date.now() + timeoutMs;\n while (true) {\n const result = await readJson(reply);\n if (result !== undefined) {\n await unlink(reply);\n const response = z\n .object({\n error: z.string().optional(),\n diagnostics: z.array(compilerDiagnosticSchema).optional(),\n id: z.literal(id),\n ok: z.boolean(),\n result: z.unknown(),\n })\n .safeParse(result);\n if (!response.success) {\n throw new Error(\"CODESYS returned an invalid worker response\", {\n cause: response.error,\n });\n }\n verifiedProcesses.add(session.pid);\n if (!response.data.ok) {\n const message = response.data.error ?? \"CODESYS build failed\";\n if (response.data.diagnostics) {\n throw new CodesysCompilerError(message, response.data.diagnostics);\n }\n throw new Error(message);\n }\n return response.data.result;\n }\n if (!isProcessAlive(session.pid)) {\n throw new Error(\n `CODESYS exited before completing ${command.action}. See ${session.directory}/codesys.log`,\n );\n }\n if (Date.now() >= deadline) {\n // A saved PID can belong to another process after a reboot. Only kill\n // a process we launched or whose worker answered in this invocation.\n if (!verifiedProcesses.has(session.pid)) {\n throw new Error(\n `CODESYS ${command.action} timed out without a worker response. Close the dedicated IDE if it is still running, then retry. Log: ${session.directory}/codesys.log`,\n );\n }\n await terminate(session);\n throw new Error(\n `CODESYS ${command.action} timed out after ${timeoutMs} ms; the worker was stopped. See ${session.directory}/codesys.log`,\n );\n }\n await delay(200);\n }\n };\n\n const launch = async () => {\n await access(config.codesys.executable);\n const fingerprint = createHash(\"sha256\")\n .update(\n JSON.stringify({\n builder: await readFile(resolve(pythonDirectory, \"build.py\"), \"utf8\"),\n online: await readFile(resolve(pythonDirectory, \"codesys_online.py\"), \"utf8\"),\n login: await readFile(resolve(pythonDirectory, \"codesys_login.py\"), \"utf8\"),\n command: codesysCommand,\n codesys: config.codesys,\n launcher: getHostCommand(config.codesys),\n worker: await readFile(resolve(pythonDirectory, \"worker.py\"), \"utf8\"),\n }),\n )\n .digest(\"hex\");\n const previous = await readSession();\n if (previous && isProcessAlive(previous.pid)) {\n if (previous.fingerprint !== fingerprint) {\n throw new Error(\n \"CODESYS settings or worker scripts changed. Run stop-worker, then launch-worker to reload them.\",\n );\n }\n await request(previous, { action: \"ping\" }, config.timeouts.startup);\n console.log(\n `Reusing CODESYS worker (${previous.pid}). Log: ${previous.directory}/codesys.log`,\n );\n return previous;\n }\n\n const directory = resolve(config.outDir, \".codesys/worker\");\n await rm(directory, { recursive: true, force: true });\n await mkdir(directory, { recursive: true });\n await copyFile(resolve(pythonDirectory, \"build.py\"), resolve(directory, \"build.py\"));\n await copyFile(resolve(pythonDirectory, \"worker.py\"), resolve(directory, \"worker.py\"));\n for (const script of [\"codesys_online.py\", \"codesys_login.py\"]) {\n await copyFile(resolve(pythonDirectory, script), resolve(directory, script));\n }\n console.log(`Starting CODESYS. Log: ${directory}/codesys.log`);\n const pid = await host.startWorker(directory);\n const session = { directory, fingerprint, pid };\n verifiedProcesses.add(pid);\n try {\n await writeJson(statePath, session);\n await request(session, { action: \"ping\" }, config.timeouts.startup);\n } catch (error) {\n await terminate(session);\n throw error;\n }\n console.log(`CODESYS worker ready (${session.pid})`);\n return session;\n };\n\n const checkExit = async (session: Session) => {\n const status = await host.exitStatus(session.directory, session.pid);\n if (!status) {\n throw new Error(\n `CODESYS launcher stopped but its exit status is unavailable. See ${session.directory}/codesys.log`,\n );\n }\n if (status.code !== 0 || status.signal !== null) {\n throw new Error(\n `CODESYS launcher exited with ${status.signal ?? status.code ?? \"unknown status\"}. See ${session.directory}/codesys.log`,\n );\n }\n };\n\n const stop = async () => {\n const session = await readSession();\n if (!session) {\n console.log(\"CODESYS worker is already stopped\");\n return;\n }\n if (!isProcessAlive(session.pid)) {\n await checkExit(session);\n console.log(\"CODESYS worker is already stopped\");\n return;\n }\n await request(session, { action: \"stop\" }, config.timeouts.stop);\n const deadline = Date.now() + config.timeouts.stop;\n while (isProcessAlive(session.pid)) {\n if (Date.now() >= deadline) {\n const message = `CODESYS stop timed out after ${config.timeouts.stop} ms. See ${session.directory}/codesys.log`;\n try {\n await terminate(session);\n } catch (cause) {\n throw new Error(`${message}; the worker could not be terminated`, { cause });\n }\n throw new Error(`${message}; the worker required forced termination`);\n }\n await delay(200);\n }\n await checkExit(session);\n console.log(\"CODESYS worker stopped\");\n };\n\n return { launch, request, stop };\n};\n\n/** Container jobs own a temporary worker; always stop it before returning a result. */\nexport const withWorkerShutdown = async <T>(\n config: ResolvedConfig,\n worker: Pick<ReturnType<typeof createCodesysWorker>, \"stop\">,\n operation: () => Promise<T>,\n) => {\n const stop = async () => {\n try {\n await withWorkerLock(config, () => worker.stop());\n } finally {\n await rm(resolve(config.outDir, \".codesys/session.json\"), { force: true });\n }\n };\n return Promise.resolve()\n .then(operation)\n .then(\n async (result) => {\n await stop();\n return result;\n },\n async (failure: unknown) => {\n await stop().catch((shutdownFailure: unknown) => {\n // Diagnostic mapping follows cause; keep the operation there and\n // expose its shutdown failure alongside it in errors.\n throw new AggregateError(\n [failure, shutdownFailure],\n \"CODESYS operation and worker shutdown failed\",\n {\n cause: failure,\n },\n );\n });\n throw failure;\n },\n );\n};\n",
|
|
12
|
+
"// Engineering CLI lifecycle and diagnostics.\nimport { createHash, randomUUID } from \"node:crypto\";\nimport {\n access,\n copyFile,\n mkdir,\n open,\n readFile,\n rename,\n rm,\n unlink,\n writeFile,\n} from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { z } from \"zod\";\nimport type { ResolvedConfig } from \"../config\";\nimport { CodesysCompilerError, compilerDiagnosticSchema } from \"../diagnostics\";\nimport { packageDirectory } from \"../package-path\";\nimport { codesysCommand, getHostCommand } from \"./codesys-command\";\nimport { createCodesysHost } from \"./codesys-host\";\n\nconst pythonDirectory = resolve(packageDirectory, \"python\");\n\nconst sessionSchema = z.object({\n directory: z.string(),\n fingerprint: z.string(),\n pid: z.int().positive(),\n});\n\ntype Session = z.infer<typeof sessionSchema>;\n\ntype WorkerCommand =\n | { action: \"build\" | \"online\" | \"ping\" | \"stop\" }\n | { action: \"login\"; arguments: Record<string, unknown> };\n\nconst hasCode = (error: unknown, code: string) =>\n error instanceof Error && \"code\" in error && error.code === code;\n\nconst isProcessAlive = (pid: number) => {\n try {\n // Signal 0 checks process existence and permissions without killing it.\n // Only ESRCH means it is gone; permission errors must still propagate.\n process.kill(pid, 0);\n return true;\n } catch (error) {\n if (hasCode(error, \"ESRCH\")) {\n return false;\n }\n throw error;\n }\n};\n\nconst readJson = async (path: string): Promise<unknown> => {\n try {\n return JSON.parse(await readFile(path, \"utf8\"));\n } catch (error) {\n if (hasCode(error, \"ENOENT\")) {\n return undefined;\n }\n throw error;\n }\n};\n\nexport const writeJson = async (path: string, value: unknown) => {\n // Readers may poll while we write. A temporary file in the same directory\n // lets rename publish the complete JSON atomically, without exposing partial data.\n const temporary = `${path}.${randomUUID()}.tmp`;\n await writeFile(temporary, JSON.stringify(value, null, 2));\n await rename(temporary, path);\n};\n\n/** Serializes startup and builds across CLI processes, including separate terminals. */\nexport const withWorkerLock = async <T>(\n config: Pick<ResolvedConfig, \"outDir\" | \"timeouts\">,\n run: () => Promise<T>,\n) => {\n await mkdir(resolve(config.outDir, \".codesys\"), { recursive: true });\n const path = resolve(config.outDir, \".codesys/command.lock\");\n const deadline = Date.now() + config.timeouts.build + config.timeouts.startup;\n while (true) {\n const lock = await open(path, \"wx\").catch((error: unknown) => {\n if (hasCode(error, \"EEXIST\")) {\n return;\n }\n throw error;\n });\n\n if (lock) {\n try {\n await lock.writeFile(String(process.pid));\n return await run();\n } finally {\n await lock.close();\n await unlink(path);\n }\n }\n\n const owner = Number(\n await readFile(path, \"utf8\").catch((error: unknown) => {\n if (hasCode(error, \"ENOENT\")) {\n return \"\";\n }\n throw error;\n }),\n );\n if (owner > 0 && !isProcessAlive(owner)) {\n // Do not race another client by deleting a lock that it may have replaced.\n throw new Error(`An interrupted command left ${path}. Remove that lock file and retry.`);\n }\n if (Date.now() >= deadline) {\n throw new Error(`Timed out waiting for another CODESYS command (${path})`);\n }\n await delay(200);\n }\n};\n\n/** The caller holds withWorkerLock throughout a command, so the mailbox has one writer. */\nexport const createCodesysWorker = (config: ResolvedConfig) => {\n const host = createCodesysHost(config);\n const statePath = resolve(config.outDir, \".codesys/session.json\");\n const verifiedProcesses = new Set<number>();\n\n const readSession = async (): Promise<Session | undefined> => {\n const value = await readJson(statePath);\n if (value === undefined) {\n return undefined;\n }\n const session = sessionSchema.safeParse(value);\n if (!session.success) {\n throw new Error(`Invalid CODESYS session file: ${statePath}`, {\n cause: session.error,\n });\n }\n return session.data;\n };\n\n const terminate = async (session: Session) => {\n if (isProcessAlive(session.pid)) {\n await host.terminate(session.pid).catch((error: unknown) => {\n if (isProcessAlive(session.pid)) {\n throw error;\n }\n });\n }\n // Let the launcher reap its child before another error handler or CLI\n // checks this PID. Sending a second group kill can otherwise race exit.\n const deadline = Date.now() + config.timeouts.terminate;\n while (isProcessAlive(session.pid)) {\n if (Date.now() >= deadline) {\n throw new Error(\"CODESYS launcher did not exit after termination\");\n }\n await delay(50);\n }\n };\n\n const request = async (session: Session, command: WorkerCommand, timeoutMs: number) => {\n const id = randomUUID();\n const reply = resolve(session.directory, `${id}.json`);\n await writeJson(resolve(session.directory, \"request.json\"), {\n ...command,\n id,\n });\n const deadline = Date.now() + timeoutMs;\n while (true) {\n const result = await readJson(reply);\n if (result !== undefined) {\n await unlink(reply);\n const response = z\n .object({\n error: z.string().optional(),\n diagnostics: z.array(compilerDiagnosticSchema).optional(),\n id: z.literal(id),\n ok: z.boolean(),\n result: z.unknown(),\n })\n .safeParse(result);\n if (!response.success) {\n throw new Error(\"CODESYS returned an invalid worker response\", {\n cause: response.error,\n });\n }\n verifiedProcesses.add(session.pid);\n if (!response.data.ok) {\n const message = response.data.error ?? \"CODESYS build failed\";\n if (response.data.diagnostics) {\n throw new CodesysCompilerError(message, response.data.diagnostics);\n }\n throw new Error(message);\n }\n return response.data.result;\n }\n if (!isProcessAlive(session.pid)) {\n throw new Error(\n `CODESYS exited before completing ${command.action}. See ${session.directory}/codesys.log`,\n );\n }\n if (Date.now() >= deadline) {\n // A saved PID can belong to another process after a reboot. Only kill\n // a process we launched or whose worker answered in this invocation.\n if (!verifiedProcesses.has(session.pid)) {\n throw new Error(\n `CODESYS ${command.action} timed out without a worker response. Close the dedicated IDE if it is still running, then retry. Log: ${session.directory}/codesys.log`,\n );\n }\n await terminate(session);\n throw new Error(\n `CODESYS ${command.action} timed out after ${timeoutMs} ms; the worker was stopped. See ${session.directory}/codesys.log`,\n );\n }\n await delay(200);\n }\n };\n\n const launch = async () => {\n await access(config.codesys.executable);\n const fingerprint = createHash(\"sha256\")\n .update(\n JSON.stringify({\n builder: await readFile(resolve(pythonDirectory, \"build.py\"), \"utf8\"),\n hardware: await readFile(resolve(pythonDirectory, \"hardware.py\"), \"utf8\"),\n online: await readFile(resolve(pythonDirectory, \"codesys_online.py\"), \"utf8\"),\n login: await readFile(resolve(pythonDirectory, \"codesys_login.py\"), \"utf8\"),\n command: codesysCommand,\n codesys: config.codesys,\n launcher: getHostCommand(config.codesys),\n worker: await readFile(resolve(pythonDirectory, \"worker.py\"), \"utf8\"),\n }),\n )\n .digest(\"hex\");\n const previous = await readSession();\n if (previous && isProcessAlive(previous.pid)) {\n if (previous.fingerprint !== fingerprint) {\n throw new Error(\n \"CODESYS settings or worker scripts changed. Run stop-worker, then launch-worker to reload them.\",\n );\n }\n await request(previous, { action: \"ping\" }, config.timeouts.startup);\n console.log(\n `Reusing CODESYS worker (${previous.pid}). Log: ${previous.directory}/codesys.log`,\n );\n return previous;\n }\n\n const directory = resolve(config.outDir, \".codesys/worker\");\n await rm(directory, { recursive: true, force: true });\n await mkdir(directory, { recursive: true });\n for (const script of [\n \"build.py\",\n \"hardware.py\",\n \"worker.py\",\n \"codesys_online.py\",\n \"codesys_login.py\",\n ]) {\n await copyFile(resolve(pythonDirectory, script), resolve(directory, script));\n }\n console.log(`Starting CODESYS. Log: ${directory}/codesys.log`);\n const pid = await host.startWorker(directory);\n const session = { directory, fingerprint, pid };\n verifiedProcesses.add(pid);\n try {\n await writeJson(statePath, session);\n await request(session, { action: \"ping\" }, config.timeouts.startup);\n } catch (error) {\n await terminate(session);\n throw error;\n }\n console.log(`CODESYS worker ready (${session.pid})`);\n return session;\n };\n\n const checkExit = async (session: Session) => {\n const status = await host.exitStatus(session.directory, session.pid);\n if (!status) {\n throw new Error(\n `CODESYS launcher stopped but its exit status is unavailable. See ${session.directory}/codesys.log`,\n );\n }\n if (status.code !== 0 || status.signal !== null) {\n throw new Error(\n `CODESYS launcher exited with ${status.signal ?? status.code ?? \"unknown status\"}. See ${session.directory}/codesys.log`,\n );\n }\n };\n\n const stop = async () => {\n const session = await readSession();\n if (!session) {\n console.log(\"CODESYS worker is already stopped\");\n return;\n }\n if (!isProcessAlive(session.pid)) {\n await checkExit(session);\n console.log(\"CODESYS worker is already stopped\");\n return;\n }\n await request(session, { action: \"stop\" }, config.timeouts.stop);\n const deadline = Date.now() + config.timeouts.stop;\n while (isProcessAlive(session.pid)) {\n if (Date.now() >= deadline) {\n const message = `CODESYS stop timed out after ${config.timeouts.stop} ms. See ${session.directory}/codesys.log`;\n try {\n await terminate(session);\n } catch (cause) {\n throw new Error(`${message}; the worker could not be terminated`, { cause });\n }\n throw new Error(`${message}; the worker required forced termination`);\n }\n await delay(200);\n }\n await checkExit(session);\n console.log(\"CODESYS worker stopped\");\n };\n\n return { launch, request, stop };\n};\n\n/** Container jobs own a temporary worker; always stop it before returning a result. */\nexport const withWorkerShutdown = async <T>(\n config: ResolvedConfig,\n worker: Pick<ReturnType<typeof createCodesysWorker>, \"stop\">,\n operation: () => Promise<T>,\n) => {\n const stop = async () => {\n try {\n await withWorkerLock(config, () => worker.stop());\n } finally {\n await rm(resolve(config.outDir, \".codesys/session.json\"), { force: true });\n }\n };\n return Promise.resolve()\n .then(operation)\n .then(\n async (result) => {\n await stop();\n return result;\n },\n async (failure: unknown) => {\n await stop().catch((shutdownFailure: unknown) => {\n // Diagnostic mapping follows cause; keep the operation there and\n // expose its shutdown failure alongside it in errors.\n throw new AggregateError(\n [failure, shutdownFailure],\n \"CODESYS operation and worker shutdown failed\",\n {\n cause: failure,\n },\n );\n });\n throw failure;\n },\n );\n};\n",
|
|
13
13
|
"export { commandArgumentSchema } from \"@tsplc/config/build\";\nimport type { ResolvedConfig } from \"../config\";\n\nexport const codesysCommandFilename = \"run.cmd\";\n\n// Keep arguments in the Windows command line: Wine's direct launcher loses the\n// quoting CODESYS needs around profile names containing spaces.\nexport const codesysCommand = [\n \"@echo off\",\n \"setlocal DisableDelayedExpansion\",\n '\"%CODESYS_BUILD_EXE%\" --profile=\"%CODESYS_BUILD_PROFILE%\" --noUI --runscript=\"%CODESYS_BUILD_SCRIPT%\"',\n 'set \"CODESYS_BUILD_STATUS=%errorlevel%\"',\n 'if not \"%CODESYS_BUILD_STATUS%\"==\"0\" echo CODESYS Windows exit status: %CODESYS_BUILD_STATUS%',\n \"exit /b %CODESYS_BUILD_STATUS%\",\n \"\",\n].join(\"\\r\\n\");\n\nexport const getHostCommand = (\n config: Pick<ResolvedConfig[\"codesys\"], \"commandProcessor\" | \"wine\">,\n platform: NodeJS.Platform = process.platform,\n) => {\n if (platform === \"win32\") {\n return {\n args: [\"/d\", \"/c\", codesysCommandFilename],\n executable: config.commandProcessor,\n };\n }\n const wine = config.wine?.binary ?? \"wine\";\n const args = [\"cmd\", \"/d\", \"/c\", codesysCommandFilename];\n // --noUI suppresses the IDE, but CODESYS can still allocate a Wine console.\n // A private PTY makes Wine use its Unix console instead of a desktop window.\n return {\n args:\n platform === \"darwin\"\n ? [\"-q\", \"/dev/null\", wine, ...args]\n : [\n \"-q\",\n \"-e\",\n \"-c\",\n [wine, ...args].map((argument) => `'${argument.replaceAll(\"'\", \"'\\\\''\")}'`).join(\" \"),\n \"/dev/null\",\n ],\n executable: \"script\",\n };\n};\n",
|
|
14
14
|
"import { execFile, spawn } from \"node:child_process\";\nimport { appendFileSync, constants, renameSync, writeFileSync } from \"node:fs\";\nimport { access, open, readFile, rm, writeFile } from \"node:fs/promises\";\nimport { delimiter, resolve } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { z } from \"zod\";\nimport type { ResolvedConfig } from \"../config\";\nimport { codesysCommand, codesysCommandFilename, getHostCommand } from \"./codesys-command\";\nimport { toWindowsPath } from \"./windows-path\";\n\nconst execute = promisify(execFile);\nconst exitSchema = z.object({\n pid: z.int().positive(),\n code: z.int().nullable(),\n signal: z.string().nullable(),\n});\n\nexport const createCodesysHost = (config: ResolvedConfig) => {\n const native = process.platform === \"win32\";\n const { codesys, timeouts } = config;\n const hostEnv = {\n // Child processes inherit OS settings such as PATH and DISPLAY.\n ...process.env,\n WINEPREFIX: codesys.wine?.prefix,\n WINEDEBUG: codesys.wine?.debug,\n CODESYS_BUILD_NATIVE_EXIT: String(codesys.wine?.nativeExit ?? false),\n };\n\n /** Start the headless worker script; its launcher outlives this CLI process. */\n const startWorker = async (directory: string) => {\n if (!native) {\n await execute(codesys.wine?.binary ?? \"wine\", [\"--version\"], {\n env: hostEnv,\n timeout: timeouts.hostCommand,\n }).catch((cause: unknown) => {\n throw new Error(\"Wine is missing. Install Wine; Winetricks alone cannot run CODESYS\", {\n cause,\n });\n });\n const candidates = (process.env[\"PATH\"] ?? \"\").split(delimiter);\n const available = await Promise.all(\n candidates.map((path) =>\n access(resolve(path, \"script\"), constants.X_OK).then(\n () => true,\n () => false,\n ),\n ),\n );\n if (!available.some(Boolean)) {\n throw new Error(\n \"Headless Wine builds require the script utility (install util-linux on Linux)\",\n );\n }\n }\n await writeFile(resolve(directory, codesysCommandFilename), codesysCommand);\n const env = {\n ...hostEnv,\n CODESYS_BUILD_SCRIPT: await toWindowsPath(resolve(directory, \"worker.py\"), codesys.wine),\n CODESYS_BUILD_SESSION: await toWindowsPath(directory, codesys.wine),\n CODESYS_BUILD_EXE: await toWindowsPath(codesys.executable, codesys.wine),\n CODESYS_BUILD_PROFILE: codesys.profile,\n };\n const launcher = getHostCommand(codesys);\n const logPath = resolve(directory, \"codesys.log\");\n const exitPath = resolve(directory, \"launcher-exit.json\");\n await rm(exitPath, { force: true });\n const log = await open(logPath, \"w\");\n try {\n const child = spawn(launcher.executable, launcher.args, {\n cwd: directory,\n detached: true,\n windowsHide: true,\n env,\n stdio: [\"ignore\", log.fd, log.fd],\n });\n child.once(\"exit\", (code, signal) => {\n try {\n appendFileSync(logPath, `\\nCODESYS launcher exit status: ${signal ?? code}\\n`);\n } catch {\n // A failed diagnostic write must preserve the original process failure.\n }\n try {\n const temporary = `${exitPath}.${child.pid}.tmp`;\n writeFileSync(temporary, JSON.stringify({ pid: child.pid, code, signal }));\n renameSync(temporary, exitPath);\n } catch {\n // The log still records the exit if the optional status file cannot be published.\n }\n });\n await new Promise<void>((resolve, reject) => {\n child.once(\"error\", reject);\n child.once(\"spawn\", resolve);\n });\n if (!child.pid) {\n throw new Error(\"CODESYS launcher has no process ID\");\n }\n child.unref();\n return child.pid;\n } finally {\n await log.close();\n }\n };\n\n return {\n exitStatus: async (directory: string, pid: number) => {\n const path = resolve(directory, \"launcher-exit.json\");\n const value = await readFile(path, \"utf8\").catch((error: unknown) => {\n if (error instanceof Error && \"code\" in error && error.code === \"ENOENT\") return;\n throw error;\n });\n if (value === undefined) return;\n const status = exitSchema.parse(JSON.parse(value));\n if (status.pid !== pid) return;\n if (!native && status.code === 0 && status.signal === null) {\n const logPath = resolve(directory, \"codesys.log\");\n const log = (await readFile(logPath, \"utf8\"))\n // Terminal color escapes must not hide a native assertion signature.\n // eslint-disable-next-line no-control-regex\n .replace(/\\u001b\\[[0-?]*[ -/]*[@-~]/g, \"\")\n .replace(/\\r/g, \"\");\n // A Wine helper can abort under Rosetta while the main launcher returns 0.\n // That command still failed; publishing its artifacts would hide the fault.\n const assertion =\n /^assertion failed \\[[^\\n]+\\]:[^\\n]*\\n[ \\t]*\\(ThreadContext[^\\n]*\\)/m.exec(log);\n if (assertion) {\n throw new Error(\n `Wine translation aborted: ${assertion[0].replace(/\\s+/g, \" \")}. See ${logPath}`,\n );\n }\n }\n return status;\n },\n startWorker,\n terminate: async (pid: number) => {\n if (native) {\n await execute(\"taskkill.exe\", [\"/pid\", String(pid), \"/t\", \"/f\"], {\n env: hostEnv,\n timeout: timeouts.hostCommand,\n });\n return;\n }\n process.kill(-pid, \"SIGKILL\");\n },\n };\n};\n",
|
|
15
15
|
"import { execFile } from \"node:child_process\";\nimport { readdir, realpath } from \"node:fs/promises\";\nimport { relative, resolve, sep, win32 } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport type { ResolvedConfig } from \"../config\";\nimport { commandArgumentSchema } from \"./codesys-command\";\nimport { codesysTimeouts } from \"@tsplc/config/build\";\n\nconst execute = promisify(execFile);\n\nexport const toWindowsPath = async (\n path: string,\n wine: ResolvedConfig[\"codesys\"][\"wine\"],\n platform: NodeJS.Platform = process.platform,\n) => {\n if (platform === \"win32\") {\n return commandArgumentSchema.parse(win32.resolve(path));\n }\n if (!wine?.prefix) {\n throw new Error(\"Configure codesys.wine.prefix for CODESYS on macOS/Linux\");\n }\n\n // Resolve filesystem aliases (e.g. macOS /var -> /private/var) so the file\n // and drive roots are compared using the same paths. Fall back to the absolute\n // spelling when realpath cannot resolve the file, such as before it exists.\n const absolute = await realpath(resolve(path)).catch(() => resolve(path));\n // Wine stores drive mappings as symlinks, e.g. dosdevices/c: -> ../drive_c.\n // Reading these links avoids starting Wine just to convert a path.\n const devices = resolve(wine.prefix, \"dosdevices\");\n const drives = await readdir(devices).catch(() => []);\n const mappings = await Promise.all(\n drives\n .filter((drive) => /^[a-z]:$/i.test(drive))\n .map(async (drive) => {\n const root = await realpath(resolve(devices, drive)).catch(() => undefined);\n if (!root) {\n return;\n }\n\n const suffix = relative(root, absolute);\n // \"..\" or \"../...\" means the file is outside this drive's root.\n // Match a full segment so a name such as \"..notes\" remains valid.\n if (suffix === \"..\" || suffix.startsWith(`..${sep}`)) {\n return;\n }\n\n return {\n root,\n windows: win32.join(`${drive.toUpperCase()}\\\\`, ...suffix.split(sep)),\n };\n }),\n );\n // Mappings can overlap: C: -> /wine/drive_c and Z: -> / may both contain\n // the file. Prefer the longest matching root to use the most specific drive.\n const [mapping] = mappings\n .filter((value) => value !== undefined)\n .sort((a, b) => b.root.length - a.root.length);\n if (mapping) {\n return commandArgumentSchema.parse(mapping.windows);\n }\n\n // No readable drive mapping covers the path; ask Wine to convert it instead.\n const result = await execute(\n wine.pathBinary,\n [\"-w\", resolve(path)],\n // Wine inherits OS process settings.\n {\n env: { ...process.env, WINEPREFIX: wine.prefix, WINEDEBUG: wine.debug },\n timeout: codesysTimeouts.hostCommand,\n },\n );\n return commandArgumentSchema.parse(result.stdout.trim());\n};\n",
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"url": "git+https://github.com/livingforjesus/tsplc.git",
|
|
16
16
|
"directory": "packages/build"
|
|
17
17
|
},
|
|
18
|
-
"version": "0.2.
|
|
18
|
+
"version": "0.2.8",
|
|
19
19
|
"type": "module",
|
|
20
20
|
"exports": {
|
|
21
21
|
".": {
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"zod": "4.3.6",
|
|
46
|
-
"@tsplc/config": "0.2.
|
|
47
|
-
"@tsplc/typegen": "0.2.
|
|
46
|
+
"@tsplc/config": "0.2.8",
|
|
47
|
+
"@tsplc/typegen": "0.2.8"
|
|
48
48
|
},
|
|
49
49
|
"bin": {
|
|
50
50
|
"tsplc-build": "./dist/cli.js"
|