@cloud-cli/on 0.1.7 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # 🏃 `@cloud-cli/on`
2
+
3
+ General-purpose workflows
4
+
5
+ A self-hosted, lightweight, high-performance CI/CD runner engine built for Node.js.
6
+
7
+ Designed with a strict **security-first boundary**, native **JavaScript AST evaluation**, zero-DSL template literals, and a **built-in terminal log web dashboard**.
8
+
9
+ ---
10
+
11
+ ## 🌟 Key Highlights
12
+
13
+ - **Strict Code/Data Separation:** `run:` steps are executed verbatim as raw process scripts. Expressions and dynamic data bindings are isolated strictly to `env:`, eliminating shell-injection vectors entirely.
14
+ - **Standard ES Template Syntax (`${...}`):** No custom DSL wrappers like `${{ }}` or `{{ }}`. If a field contains `${...}`, it evaluates standard JavaScript template string logic via AST.
15
+ - **Deterministic Field Evaluation:** No silent fallbacks or ambiguous type conversions. Plain strings remain literal strings; conditions in `if:` fields run as strict JS boolean expressions.
16
+ - **Built-in Dark Mode Web UI (`/runs`):** Monitor job statuses live, inspect workspace inputs, and view ANSI-colored terminal log streams rendered in real-time.
17
+ - **System & Container Execution Drivers:** Run steps directly as detached host process groups or inside isolated Docker/Systemd transient units.
18
+ - **Automatic Secret Redaction:** Secrets loaded from `.env` are automatically masked (`***`) across all terminal log outputs and report snapshots.
19
+
20
+ ---
21
+
22
+ ## 📦 Project Structure
23
+
24
+ ```text
25
+ my-project/
26
+ ├── .on/ # Workflow definitions directory
27
+ │ ├── release.yml
28
+ │ └── test.yml
29
+ ├── .env # Local secrets (git-ignored)
30
+ ├── runner.config.mjs # (Optional) Engine configuration
31
+ └── package.json
32
+
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 🚀 Quick Start
38
+
39
+ ### 1. Install & Run
40
+
41
+ Run the engine directly via `npx` or `pnpm dlx`:
42
+
43
+ ```bash
44
+ # Start full engine (Ingress HTTP Gateway + 5 Worker Loops)
45
+ npx @cloud-cli/on start
46
+
47
+ ```
48
+
49
+ ### 2. Configure Secrets (`.env`)
50
+
51
+ Secrets are automatically loaded from `.env` at the root of your project. Prefix secrets with `SECRET_`:
52
+
53
+ ```env
54
+ SECRET_NPM_TOKEN="npm_1234567890abcdef"
55
+ SECRET_GITHUB_TOKEN="ghp_1234567890abcdef"
56
+ SECRET_GITHUB_WEBHOOK_SECRET="my-webhook-secret"
57
+
58
+ ```
59
+
60
+ ### 3. Define a Workflow (`.on/release.yml`)
61
+
62
+ ```yaml
63
+ name: Build and Publish Release
64
+
65
+ on:
66
+ github:
67
+ if: inputs.event === 'push' && inputs.branch === 'main'
68
+
69
+ concurrency:
70
+ group: release-${inputs.repo}
71
+ cancel-in-progress: true
72
+
73
+ steps:
74
+ - id: checkout
75
+ name: Checkout Code Repository
76
+ env:
77
+ CLONE_URL: ${inputs.clone_url}
78
+ COMMIT_SHA: ${inputs.commit_sha}
79
+ run: |
80
+ git clone --depth 1 "$CLONE_URL" .
81
+ git checkout "$COMMIT_SHA"
82
+
83
+ - id: install-and-build
84
+ name: Install Dependencies & Build
85
+ run: |
86
+ pnpm install
87
+ pnpm run build
88
+
89
+ - id: publish
90
+ name: Publish to NPM
91
+ env:
92
+ NPM_TOKEN: ${secrets.NPM_TOKEN}
93
+ run: |
94
+ echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
95
+ npx --yes semantic-release@24 -b main --no-ci
96
+ ```
97
+
98
+ ---
99
+
100
+ ## 💻 CLI Usage & Commands
101
+
102
+ ```bash
103
+ npx @cloud-cli/on [command] [options]
104
+
105
+ ```
106
+
107
+ ### Commands
108
+
109
+ | Command | Description |
110
+ | ----------------------- | ------------------------------------------------------------------------- |
111
+ | **`start`** _(default)_ | Runs both Webhook Ingress Gateway and Worker execution loops together. |
112
+ | **`start-server`** | Runs Webhook Ingress Gateway only (API / Gateway mode). |
113
+ | **`start-workers`** | Runs Worker Polling loops only (Scalable Worker mode). |
114
+ | **`validate`** | Parses and validates all YAML workflows in `.on/` without executing jobs. |
115
+
116
+ ### CLI Options
117
+
118
+ | Flag | Option | Default | Description |
119
+ | ---- | ------------- | -------------------------- | ----------------------------------------- |
120
+ | `-c` | `--config` | `./runner.config.mjs` | Path to JavaScript configuration file. |
121
+ | `-d` | `--database` | `process.env.DATABASE_URL` | SQLite database file path or HTTP URL. |
122
+ | `-w` | `--workflows` | `.on/` | Directory where workflow YAML files live. |
123
+ | `-p` | `--port` | `3000` | Port for the Ingress HTTP server. |
124
+ | `-k` | `--workers` | `5` | Number of worker loop threads to spawn. |
125
+ | `-h` | `--help` | — | Prints CLI help message and exits. |
126
+
127
+ ---
128
+
129
+ ## ⚙️ Configuration Reference
130
+
131
+ You can customize engine behavior using `runner.config.mjs` in your project root:
132
+
133
+ ```javascript
134
+ // runner.config.mjs
135
+ import { HtmlReporter, SlackReporter, JsonFileReporter } from '@cloud-cli/on/reporters';
136
+
137
+ export default {
138
+ port: 3000,
139
+ workersCount: 5,
140
+ workflowsDir: '.on/',
141
+ storagePath: '/tmp/workspaces',
142
+ sqliteUrl: 'sqlite.db',
143
+
144
+ // Global environment variables passed to all steps
145
+ env: {
146
+ NODE_ENV: 'production',
147
+ },
148
+
149
+ // Custom execution reporters
150
+ reporters: [
151
+ new JsonFileReporter({ outputDir: './reports/json' }),
152
+ new HtmlReporter({ outputDir: './reports/html' }),
153
+ new SlackReporter({
154
+ webhookUrl: process.env.SLACK_WEBHOOK_URL,
155
+ channel: '#ci-deployments',
156
+ }),
157
+ ],
158
+ };
159
+ ```
160
+
161
+ ---
162
+
163
+ ## 📐 Deterministic Evaluation Rules
164
+
165
+ To prevent syntax ambiguity and injection risks, fields in workflow definitions operate under **three strict modes**:
166
+
167
+ ```
168
+ ┌──────────────────────────────────────────────────────────────────────────┐
169
+ │ 1. RAW PASSTHROUGH MODE (`run:`) │
170
+ │ • Executed verbatim as a shell process command. │
171
+ │ • No string replacements or engine parsing performed. │
172
+ │ • Access environment variables strictly via shell syntax: $MY_VAR. │
173
+ └──────────────────────────────────────────────────────────────────────────┘
174
+
175
+ ┌──────────────────────────────────────────────────────────────────────────┐
176
+ │ 2. EXPRESSION MODE (`if:`, `eval:`) │
177
+ │ • Evaluated strictly as pure JavaScript expressions via Acorn AST. │
178
+ │ • Must be valid JS syntax (e.g. `inputs.branch === 'main'`). │
179
+ │ • Automatically coerced to boolean in `if:` conditions. │
180
+ └──────────────────────────────────────────────────────────────────────────┘
181
+
182
+ ┌──────────────────────────────────────────────────────────────────────────┐
183
+ │ 3. DETERMINISTIC VALUE MODE (`env:`, `name:`, `image:`, `group:`) │
184
+ │ • Plain strings WITHOUT `${}` remain 100% raw literal strings. │
185
+ │ • Strings WITH `${...}` evaluate as standard ES Template Literals. │
186
+ │ • Example: `node:${inputs.node_version}-alpine` │
187
+ └──────────────────────────────────────────────────────────────────────────┘
188
+
189
+ ```
190
+
191
+ ### Context Scope Available in Expressions
192
+
193
+ Within `${...}`, `if:`, and `eval:` contexts, the following object scopes are exposed:
194
+
195
+ - **`inputs`**: Payload key-values received from incoming webhooks.
196
+ - **`env`**: Merged environment variables from global config and workflow definitions.
197
+ - **`secrets`**: Unmasked secret values loaded from `.env` (`SECRET_` prefix stripped).
198
+ - **`steps`**: Execution statuses and outputs from previous steps in the workflow (`steps.<id>.status`, `steps.<id>.outputs`).
199
+ - **`BUILTIN_HELPERS`**: JS utilities including `String`, `Number`, `Boolean`, and `JSON.parse` / `JSON.stringify`.
200
+
201
+ ---
202
+
203
+ ## 🌐 Webhook Ingress Gateway & Dashboard
204
+
205
+ The Ingress Gateway listens for incoming HTTP requests and serves the live web UI.
206
+
207
+ ### Endpoint Matrix
208
+
209
+ | Method | Endpoint | Description |
210
+ | ---------- | ------------------ | -------------------------------------------------------------------------------------- |
211
+ | **`POST`** | `/webhooks/github` | Webhook endpoint for GitHub events. Evaluates `on.github.if` triggers. |
212
+ | **`GET`** | `/runs` | **Dashboard:** Live dark-mode monitoring page listing recent jobs and worker health. |
213
+ | **`GET`** | `/runs/:jobId` | **Job Report:** Interactive HTML trace view with step timings and terminal log output. |
214
+
215
+ ### Dashboard Features
216
+
217
+ - **Real-time Auto-Refresh:** `/runs` automatically refreshes job queue statuses (`PENDING`, `RUNNING`, `SUCCESS`, `FAILED`, `CANCELLED`).
218
+ - **ANSI Terminal Rendering:** Uses `ansi_up` to render bash colors, bold highlights, and console outputs accurately in step log boxes.
219
+ - **Payload Inspection:** View JSON inputs received from webhooks for easy debugging.
220
+
221
+ ---
222
+
223
+ ## 🔒 Security & Hardening
224
+
225
+ 1. **Environment Variable Boundary:**
226
+ By forcing shell steps to consume data via process environment variables (`$CLONE_URL`), malicious webhook payloads containing shell delimiters (e.g. `; rm -rf /`) cannot mutate shell script execution trees.
227
+ 2. **Prototype Pollution Protection:**
228
+ AST evaluation explicitly blocks access to dangerous JS properties (`constructor`, `__proto__`, `prototype`).
229
+ 3. **Payload Size Guard:**
230
+ The Ingress server enforces a strict 5MB payload limit to prevent Out-Of-Memory (OOM) denial-of-service attacks.
231
+ 4. **Signal Traps & Resource Cleanup:**
232
+ Graceful process traps (`SIGINT`, `SIGTERM`) ensure active job handles are safely terminated, file descriptors are closed, and temp `.env`/`.out` files are removed via `try ... finally` blocks.
233
+
234
+ ## Development
235
+
236
+ ```bash
237
+ pnpm i
238
+ pnpm run lint
239
+ pnpm run test
240
+ pnpm run build
241
+ ```
package/dist/config.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Merges user config overrides with baseline defaults
3
+ */
4
+ export function resolveConfig(userConfig = {}) {
5
+ return {
6
+ port: userConfig.port ?? 3000,
7
+ adminToken: userConfig.adminToken ?? process.env.RUNNER_ADMIN_SECRET ?? '',
8
+ sqliteUrl: userConfig.sqliteUrl ?? process.env.DATABASE_URL ?? 'sqlite.db',
9
+ workflowsDir: userConfig.workflowsDir ?? '.on/',
10
+ workersCount: userConfig.workersCount ?? 5,
11
+ storagePath: userConfig.storagePath ?? process.env.RUNNER_TMP ?? '/tmp/workspaces',
12
+ env: userConfig.env ?? {},
13
+ reporters: userConfig.reporters ?? [],
14
+ };
15
+ }
@@ -0,0 +1,38 @@
1
+ const baseURL = process.env.DATABASE_URL;
2
+ let pragmas = [];
3
+ async function query(method, statement, data, pragma = pragmas) {
4
+ let req;
5
+ let error;
6
+ let retries = 1;
7
+ let max = 3;
8
+ while (retries < max) {
9
+ try {
10
+ req = await fetch(new URL('/query', baseURL), {
11
+ method: 'POST',
12
+ body: JSON.stringify({
13
+ s: statement,
14
+ d: data,
15
+ m: method,
16
+ p: pragma,
17
+ }),
18
+ });
19
+ if (req.ok) {
20
+ return await req.json();
21
+ }
22
+ await new Promise((r) => setTimeout(r, retries++ * 1000));
23
+ }
24
+ catch (e) {
25
+ error = e;
26
+ }
27
+ }
28
+ throw new Error(error || (await req.text()));
29
+ }
30
+ export const get = query.bind(null, 'get');
31
+ export const run = query.bind(null, 'run');
32
+ export const all = query.bind(null, 'all');
33
+ export function pragma(p) {
34
+ if (Array.isArray(p) && p.every((s) => typeof s === 'string')) {
35
+ pragmas = p;
36
+ }
37
+ }
38
+ export default { query, get, run, all, pragma };
@@ -0,0 +1,11 @@
1
+ import { SystemdDriver } from './systemd.driver.js';
2
+ import { StandardProcessDriver } from './standard-process.driver.js';
3
+ export async function resolveDriver() {
4
+ const systemd = new SystemdDriver();
5
+ if (await systemd.isSupported()) {
6
+ console.log('⚡ Selected Execution Driver: Systemd (cgroups enabled)');
7
+ return systemd;
8
+ }
9
+ console.log('📦 Selected Execution Driver: Standard Process (Fallback)');
10
+ return new StandardProcessDriver();
11
+ }
@@ -0,0 +1,156 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ export class StandardProcessDriver {
5
+ name = 'standard-process';
6
+ async isSupported() {
7
+ return true; // Supported on all OS platforms
8
+ }
9
+ async execute(ctx) {
10
+ const startTime = Date.now();
11
+ let logFd = null;
12
+ let logFilePath = '';
13
+ // 1. Guard Log Directory & File Handle Creation
14
+ try {
15
+ const logDir = path.join(ctx.workspacePath, '.logs');
16
+ fs.mkdirSync(logDir, { recursive: true });
17
+ logFilePath = path.join(logDir, `step-${ctx.stepId}.log`);
18
+ logFd = fs.openSync(logFilePath, 'a');
19
+ }
20
+ catch (err) {
21
+ return {
22
+ done: Promise.resolve({
23
+ exitCode: 1,
24
+ durationMs: 0,
25
+ error: new Error(`Failed to initialize step log file: ${err.message}`),
26
+ }),
27
+ cancel: async () => { },
28
+ logFilePath: '',
29
+ };
30
+ }
31
+ // 2. Format Execution Command
32
+ let cmd;
33
+ let args;
34
+ if (ctx.image) {
35
+ cmd = 'docker';
36
+ args = [
37
+ 'run',
38
+ '--rm',
39
+ '--init',
40
+ '-v',
41
+ `${ctx.workspacePath}:/workspace`,
42
+ '-w',
43
+ '/workspace',
44
+ '--entrypoint',
45
+ 'sh',
46
+ ctx.image,
47
+ '-c',
48
+ ctx.command,
49
+ ];
50
+ }
51
+ else {
52
+ cmd = 'sh';
53
+ args = ['-c', ctx.command];
54
+ }
55
+ // 3. Spawn Detached Child Process
56
+ let child;
57
+ try {
58
+ child = spawn(cmd, args, {
59
+ cwd: ctx.workspacePath,
60
+ env: { ...process.env, ...ctx.env },
61
+ detached: true, // Creates separate Process Group ID
62
+ stdio: ['ignore', logFd, logFd],
63
+ });
64
+ }
65
+ catch (spawnErr) {
66
+ try {
67
+ if (logFd !== null)
68
+ fs.closeSync(logFd);
69
+ }
70
+ catch { }
71
+ return {
72
+ done: Promise.resolve({
73
+ exitCode: 1,
74
+ durationMs: Date.now() - startTime,
75
+ error: new Error(`Failed to spawn process: ${spawnErr.message}`),
76
+ }),
77
+ cancel: async () => { },
78
+ logFilePath,
79
+ };
80
+ }
81
+ let isCancelled = false;
82
+ let timeoutTimer = null;
83
+ // 4. Safe Promise Resolution & Lifecycle Tracking
84
+ const done = new Promise((resolve) => {
85
+ let isResolved = false;
86
+ const safeResolve = (result) => {
87
+ if (isResolved)
88
+ return; // Prevent double-resolution
89
+ isResolved = true;
90
+ if (timeoutTimer)
91
+ clearTimeout(timeoutTimer);
92
+ // Always close log File Descriptor safely
93
+ try {
94
+ if (logFd !== null)
95
+ fs.closeSync(logFd);
96
+ }
97
+ catch { }
98
+ resolve(result);
99
+ };
100
+ // Optional step timeout
101
+ if (ctx.timeoutMs) {
102
+ timeoutTimer = setTimeout(() => {
103
+ isCancelled = true;
104
+ this.killProcessGroup(child);
105
+ }, ctx.timeoutMs);
106
+ // CRUCIAL: Unref timer so it doesn't hold event loop open
107
+ timeoutTimer.unref();
108
+ }
109
+ child.on('close', (code) => {
110
+ safeResolve({
111
+ exitCode: code ?? (isCancelled ? 130 : 1),
112
+ durationMs: Date.now() - startTime,
113
+ error: isCancelled ? new Error('Step timed out or was cancelled by user') : undefined,
114
+ });
115
+ });
116
+ child.on('error', (err) => {
117
+ safeResolve({
118
+ exitCode: 1,
119
+ durationMs: Date.now() - startTime,
120
+ error: err,
121
+ });
122
+ });
123
+ });
124
+ // 5. Cancellation Hook
125
+ const cancel = async () => {
126
+ isCancelled = true;
127
+ this.killProcessGroup(child);
128
+ };
129
+ return { done, cancel, logFilePath };
130
+ }
131
+ /**
132
+ * Kills the entire process group tree (-PID) with unref escalation
133
+ */
134
+ killProcessGroup(child) {
135
+ if (child.pid && !child.killed) {
136
+ try {
137
+ // Send SIGTERM to entire process group (-PID)
138
+ process.kill(-child.pid, 'SIGTERM');
139
+ // Escalate to SIGKILL after 5 seconds if process tree is still alive
140
+ const killTimer = setTimeout(() => {
141
+ try {
142
+ if (child.pid && !child.killed) {
143
+ process.kill(-child.pid, 'SIGKILL');
144
+ }
145
+ }
146
+ catch { }
147
+ }, 5000);
148
+ // CRUCIAL: Unref escalation timer so Node process can exit cleanly
149
+ killTimer.unref();
150
+ }
151
+ catch {
152
+ // Process group may already be dead
153
+ }
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,151 @@
1
+ import { spawn, exec } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const execAsync = promisify(exec);
6
+ export class SystemdDriver {
7
+ name = 'systemd';
8
+ /**
9
+ * Check if systemd bus is available on Linux host
10
+ */
11
+ async isSupported() {
12
+ try {
13
+ return fs.existsSync('/run/systemd/system');
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ async execute(ctx) {
20
+ const startTime = Date.now();
21
+ let logFd = null;
22
+ let logFilePath = '';
23
+ // 1. Guard Log Directory & File Handle Creation
24
+ try {
25
+ const logDir = path.join(ctx.workspacePath, '.logs');
26
+ fs.mkdirSync(logDir, { recursive: true });
27
+ logFilePath = path.join(logDir, `step-${ctx.stepId}.log`);
28
+ logFd = fs.openSync(logFilePath, 'a');
29
+ }
30
+ catch (err) {
31
+ return {
32
+ done: Promise.resolve({
33
+ exitCode: 1,
34
+ durationMs: 0,
35
+ error: new Error(`Failed to initialize step log file: ${err.message}`),
36
+ }),
37
+ cancel: async () => { },
38
+ logFilePath: '',
39
+ };
40
+ }
41
+ // 2. Format Sanitized Systemd Unit Name
42
+ const unitName = `workflow-${ctx.jobId}-${ctx.stepId}`.replace(/[^a-zA-Z0-9_-]/g, '_');
43
+ // 3. Build systemd-run Flags
44
+ const systemdFlags = [
45
+ `--unit=${unitName}`,
46
+ '--wait', // Block until unit completes
47
+ '--pipe', // Stream stdio directly to file handle
48
+ `--working-directory=${ctx.workspacePath}`,
49
+ ];
50
+ if (ctx.env) {
51
+ for (const [key, val] of Object.entries(ctx.env)) {
52
+ systemdFlags.push(`--setenv=${key}=${val}`);
53
+ }
54
+ }
55
+ if (ctx.timeoutMs) {
56
+ const timeoutSec = Math.ceil(ctx.timeoutMs / 1000);
57
+ systemdFlags.push(`--property=RuntimeMaxSec=${timeoutSec}`);
58
+ }
59
+ // 4. Construct Command
60
+ let commandArgs;
61
+ if (ctx.image) {
62
+ commandArgs = [
63
+ 'docker',
64
+ 'run',
65
+ '--rm',
66
+ '--init',
67
+ `--name=${unitName}`, // Predictable container name for stopping
68
+ '-v',
69
+ `${ctx.workspacePath}:/workspace`,
70
+ '-w',
71
+ '/workspace',
72
+ ctx.image,
73
+ 'sh',
74
+ '-c',
75
+ ctx.command,
76
+ ];
77
+ }
78
+ else {
79
+ commandArgs = ['sh', '-c', ctx.command];
80
+ }
81
+ // 5. Spawn systemd-run
82
+ let child;
83
+ try {
84
+ child = spawn('systemd-run', [...systemdFlags, '--', ...commandArgs], {
85
+ stdio: ['ignore', logFd, logFd],
86
+ });
87
+ }
88
+ catch (spawnErr) {
89
+ try {
90
+ if (logFd !== null)
91
+ fs.closeSync(logFd);
92
+ }
93
+ catch { }
94
+ return {
95
+ done: Promise.resolve({
96
+ exitCode: 1,
97
+ durationMs: Date.now() - startTime,
98
+ error: new Error(`Failed to spawn systemd-run: ${spawnErr.message}`),
99
+ }),
100
+ cancel: async () => { },
101
+ logFilePath,
102
+ };
103
+ }
104
+ let isCancelled = false;
105
+ // 6. Safe Promise Resolution & File Handle Cleanup
106
+ const done = new Promise((resolve) => {
107
+ let isResolved = false;
108
+ const safeResolve = (result) => {
109
+ if (isResolved)
110
+ return; // Prevent double-resolution
111
+ isResolved = true;
112
+ try {
113
+ if (logFd !== null)
114
+ fs.closeSync(logFd);
115
+ }
116
+ catch { }
117
+ resolve(result);
118
+ };
119
+ child.on('close', (code) => {
120
+ safeResolve({
121
+ exitCode: code ?? (isCancelled ? 130 : 1),
122
+ durationMs: Date.now() - startTime,
123
+ error: isCancelled ? new Error('Step cancelled by user or systemd timeout') : undefined,
124
+ });
125
+ });
126
+ child.on('error', (err) => {
127
+ safeResolve({
128
+ exitCode: 1,
129
+ durationMs: Date.now() - startTime,
130
+ error: err,
131
+ });
132
+ });
133
+ });
134
+ // 7. Systemd / Docker Graceful Cancellation
135
+ const cancel = async () => {
136
+ isCancelled = true;
137
+ try {
138
+ if (ctx.image) {
139
+ // Stop docker container gracefully if running
140
+ await execAsync(`docker stop -t 2 ${unitName}`).catch(() => { });
141
+ }
142
+ // Stop systemd transient unit (sends SIGTERM -> SIGKILL to Cgroup tree)
143
+ await execAsync(`systemctl stop ${unitName}.service`).catch(() => { });
144
+ }
145
+ catch {
146
+ // Unit or container may already be stopped
147
+ }
148
+ };
149
+ return { done, cancel, logFilePath };
150
+ }
151
+ }