@cloud-cli/on 0.1.9 → 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 CHANGED
@@ -1,180 +1,241 @@
1
- # Workflow Design
1
+ # 🏃 `@cloud-cli/on`
2
2
 
3
- This is a task runner using webhooks to process incoming events.
3
+ General-purpose workflows
4
4
 
5
- Every event is sent to a daemon as an HTTP request, with a JSON body, and can trigger one or more workflows.
6
- Every workflow is a set of steps, which can run on containers or in a shell on the host.
5
+ A self-hosted, lightweight, high-performance CI/CD runner engine built for Node.js.
7
6
 
8
- ## Running on Docker
7
+ Designed with a strict **security-first boundary**, native **JavaScript AST evaluation**, zero-DSL template literals, and a **built-in terminal log web dashboard**.
9
8
 
10
- - Steps run inside a docker container.
11
- - All steps run in the same workspace folder.
12
- - The current folder is mounted as a volume at /workspace by default. This can be changed by specifying a volume with `.` as the host path.
9
+ ---
13
10
 
14
- ## Running on the host machine
11
+ ## 🌟 Key Highlights
15
12
 
16
- - Steps are sent as stdin to a shell subprocess
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.
17
19
 
18
- ## General configuration syntax
20
+ ---
19
21
 
20
- Running with Docker:
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
21
32
 
22
- ```sh
23
- curl -X POST http://localhost:11235/ -d '{ "event-name": {...} }'
24
33
  ```
25
34
 
26
- ```yaml
27
- description: Run tests and build
35
+ ---
28
36
 
29
- vars: &vars
30
- image: node:latest
37
+ ## 🚀 Quick Start
31
38
 
32
- on:
33
- event-name:
34
- runner: docker
35
- if:
36
- - ${inputs.action} == 'published'
37
- secrets:
38
- - /path/to/secrets
39
- - /path/to/.env
40
- mappings:
41
- <field>: <path.to.value.in.inputs>
42
- env:
43
- A_SECRET: "${secrets.A_SECRET}"
44
- A_VALUE: "${inputs.some.value}"
45
- defaults:
46
- <<: *vars
47
- volumes:
48
- .: /home
49
- /dev/shm: /dev/shm
50
- args:
51
- net: host
52
- dns: 1.2.3.4
53
- steps:
54
- - pnpm i
55
- - pnpm run build
56
- - pnpm run test
57
- triggers:
58
- - path/to/output.json
59
- ```
39
+ ### 1. Install & Run
60
40
 
61
- Running with a shell on the same machine as the server:
41
+ Run the engine directly via `npx` or `pnpm dlx`:
62
42
 
63
- ```sh
64
- curl -X POST http://localhost:11235/ -d '{ "package": {...} }'
65
- ```
43
+ ```bash
44
+ # Start full engine (Ingress HTTP Gateway + 5 Worker Loops)
45
+ npx @cloud-cli/on start
66
46
 
67
- ```yaml
68
- description: Auto-release library
69
- on:
70
- event-name:
71
- runner: shell
72
- secrets:
73
- - /path/to/secrets
74
- - /path/to/.env
75
- mappings:
76
- <field>: <path.to.value.in.json.payload>
77
- env:
78
- A_SECRET: "${secrets.A_SECRET}"
79
- A_VALUE: "${inputs.some.value}"
80
- steps:
81
- - pnpm i
82
- - pnpm run build
83
- - pnpm run release
84
- triggers:
85
- - path/to/output.json
86
47
  ```
87
48
 
88
- ### Event Payload
49
+ ### 2. Configure Secrets (`.env`)
89
50
 
90
- The webhooks can have any shape. To match events to workflows, we look for the top-level keys in the request body, and matching workflows that expect them to be present.
51
+ Secrets are automatically loaded from `.env` at the root of your project. Prefix secrets with `SECRET_`:
91
52
 
92
- For example, given the request:
53
+ ```env
54
+ SECRET_NPM_TOKEN="npm_1234567890abcdef"
55
+ SECRET_GITHUB_TOKEN="ghp_1234567890abcdef"
56
+ SECRET_GITHUB_WEBHOOK_SECRET="my-webhook-secret"
93
57
 
94
- ```sh
95
- curl -X POST http://localhost:11235/ -d '{ "published": { "value" : 123 } }'
96
58
  ```
97
59
 
98
- Then the workflow should map the `published` key:
60
+ ### 3. Define a Workflow (`.on/release.yml`)
99
61
 
100
62
  ```yaml
63
+ name: Build and Publish Release
64
+
101
65
  on:
102
- published:
103
- steps:
104
- - echo ${inputs.value}
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
105
96
  ```
106
97
 
107
- Here, the expression `inputs` in the workflow context is defined as the value set in the `published` key from the parsed JSON.
108
- The expression `${inputs.value}` contains `123`.
98
+ ---
109
99
 
110
- To differentiate between events coming from the same place, multiple webhooks can be created.
111
- The incoming webhook URL can have a path, and that is considered as the event source.
100
+ ## 💻 CLI Usage & Commands
112
101
 
113
- Consider this request:
102
+ ```bash
103
+ npx @cloud-cli/on [command] [options]
114
104
 
115
- ```sh
116
- curl -X POST http://localhost:11235/source -d '{ "event": { "value" : 123 } }'
117
105
  ```
118
106
 
119
- The key `source` is added to the event payload. The workflow should now be defined as:
120
-
121
- ```yaml
122
- on:
123
- source:
124
- published:
125
- steps:
126
- - echo ${inputs.value}
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
+ };
127
159
  ```
128
160
 
129
- This difference in payload processing allows multiple webhooks from the same source, to separate different events with a similar JSON body.
161
+ ---
130
162
 
131
- ### Secrets
163
+ ## 📐 Deterministic Evaluation Rules
132
164
 
133
- The daemon can fetch secrets from its host environment, or from a file.
165
+ To prevent syntax ambiguity and injection risks, fields in workflow definitions operate under **three strict modes**:
134
166
 
135
- ### Inputs
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
+ └──────────────────────────────────────────────────────────────────────────┘
136
188
 
137
- Inputs are defined from the incoming JSON payload. The payload is parsed and made available as the `inputs` variable
189
+ ```
138
190
 
139
- ### Mappings
191
+ ### Context Scope Available in Expressions
140
192
 
141
- These are shortcuts to make scripting easier.
193
+ Within `${...}`, `if:`, and `eval:` contexts, the following object scopes are exposed:
142
194
 
143
- After the JSON payload is parsed, these mappings are evaluated, and added to `inputs` as shortcuts for long/deep properties in the payload.
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`.
144
200
 
145
- For example: from a GitHub webhook event that contains a lot of fields, we can define `image` from `package.package_version.package_url`
201
+ ---
146
202
 
147
- ```yaml
148
- mappings:
149
- image: ${inputs.package.package_version.package_url}
150
- ```
203
+ ## 🌐 Webhook Ingress Gateway & Dashboard
151
204
 
152
- ### Environment variables
205
+ The Ingress Gateway listens for incoming HTTP requests and serves the live web UI.
153
206
 
154
- After resolving secrets and mappings, we proceed to resolve env variables from template strings or literal strings.
207
+ ### Endpoint Matrix
155
208
 
156
- An env variable is interpreted a JS template string, with `${value}` syntax used to interpolate values from `inputs` or `secrets`.
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. |
157
214
 
158
- ## Sequence of operation
215
+ ### Dashboard Features
159
216
 
160
- For every incoming event, these steps are followed:
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.
161
220
 
162
- - Parse and validate payload
163
- - Load secrets
164
- - Map inputs
165
- - Populate env with secrets
166
- - Populate env with additional workflow definitions (section `env`)
167
- - Create a temporary working directory
168
- - Add a volume to defaults at `/workspace`, or a custom path, if a volume with a host path `.` is defined in the workflow
169
- - Run steps:
170
- - For every step, either a string, or a step definition is accepted.
171
- - If string, run with the defaults defined in the workflow
172
- - If a definition, merge defaults into it, and run the step
173
- - The step is a shell command, executed inside a short-lived container
174
- - Trigger new events
221
+ ---
175
222
 
176
- ## Triggers
223
+ ## 🔒 Security & Hardening
177
224
 
178
- After steps are executed, a list of one or more JSON files can be defined to trigger new workflows.
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.
179
233
 
180
- These files are read one by one and sent back to the daemon as new events.
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
+ }