@checkstack/ui 1.8.2 → 1.9.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # @checkstack/ui
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a06b899: Overhaul shell + inline-script health checks with real shell semantics, real ESM execution, and upstream Node/Bun IntelliSense.
8
+
9
+ **BREAKING CHANGES**
10
+
11
+ - **Shell collector** — the `Execute Script` collector now takes a single `script` string instead of `{ command, args }`. Existing configs are auto-migrated to v2: `command` + `args` are joined with POSIX single-quote escaping into the new `script` field, so behaviour is preserved. Custom UIs that hard-coded `command`/`args` field names need to switch to `script`.
12
+ - **Inline collector** — scripts are now executed as real ES modules in a Bun subprocess (was: `new Function()` inside a Web Worker). The legacy `return X;` style still works (it's auto-wrapped in an async IIFE), but mixed scripts that `import` _and_ `return` at the top level need to use `export default` for their result.
13
+
14
+ **FIXES**
15
+
16
+ - Shell scripts containing pipes, redirects, `awk`, command substitution, conditionals etc. no longer fail with `ENOENT`. The collector now runs through `sh -c <script>` instead of passing the full expression as `Bun.spawn`'s argv[0]. This was the original `awk … failed with ENOENT` regression.
17
+ - Inline scripts can now `import { loadavg } from "node:os"` (and any other `node:*` or `bun` import). They could not before, because the executor wrapped user code inside `new Function(...)` and ran it in a Web Worker that had no Node module access; the wrapper also made top-level `import` syntactically invalid (`Unexpected token '{'`).
18
+ - Healthcheck editor fields no longer reset while you're editing. The page was re-running its form-state init `useEffect` on every refetch of the configuration query — and that query is invalidated on every realtime `HEALTH_CHECK_RUN_COMPLETED` signal across the platform, so in-progress edits got wiped within seconds. Replaced the naive `useEffect([existingConfig])` with a new `useInitOnceForKey` hook from `@checkstack/ui` that initialises the form only on first load per healthcheck id and ignores background refetches. The hook's decision logic is a pure function (`shouldInitForKey`) and is unit-tested in `useInitOnceForKey.test.ts`.
19
+ - Switching between healthcheck collectors no longer mis-applies the previous collector's tokenizer / language service to the new editor. `MultiTypeEditorField` was reusing the same React instance across collector switches (same `key="script"` in both `DynamicForm` renders) and `selectedType` was initialised from `useState` only once on first mount. After a shell→typescript switch the new collector's TS content rendered through the shell branch (no TS highlighting, no IntelliSense); the reverse direction tokenised shell content through TS and surfaced nonsense errors like `2304 "Cannot find name 'and'"` on shell comments. Now a `useEffect` re-derives `selectedType` whenever `editorTypes` changes to a set that doesn't contain the current selection.
20
+ - Monaco workers are now bundled locally via Vite `?worker` imports and wired up through `MonacoEnvironment.getWorker` in a new `monacoWorkers.ts` module. The default `@monaco-editor/loader` CDN path silently failed CORS on worker scripts in some browsers, leaving Monaco's TS service with only the generic `editorWorkerService` — which is enough for tokenizer-only languages like shell but breaks TypeScript's semantic features entirely. Same module configures the TS service singleton (compiler options, eager-model-sync, diagnostics-options-ignore-1108) at module load instead of inside per-editor `onMount`, so the service starts pre-configured regardless of which language opens first. Migrated from the deprecated `monaco.languages.typescript.*` path to `monaco.typescript.*` (the old path is marked `{ deprecated: true }` in monaco-editor 0.55).
21
+ - `defineIntegration` / `defineHealthCheck` callback parameters are now typed against the schema. Previously the virtual module declared them as `(ctx: unknown) => …`, so writing `defineIntegration(async (context) => { console.log(context.event.eventId) })` produced `'context' is of type 'unknown'. (18046)`. The result type and the shared `IntegrationScriptContext` / `HealthCheckScriptContext` interfaces are now generated together in `scriptContext.ts`, so both the function-arg form and the ambient `declare const context` reference the same schema-typed shape.
22
+ - The shell starter template no longer uses Linux-only `/proc/loadavg` (which fails on macOS satellites with `awk: can't open file /proc/loadavg`). It now reads the 1-minute load average via `uptime` and parses both the Linux (`load average: 0.00, 0.01, 0.05`) and macOS (`load averages: 0.45 0.55 0.65`) output formats with a portable `sed`/`awk`/`tr` pipeline.
23
+ - Starter-template seeding is now self-healing. `DynamicForm`'s schema-defaults `useEffect` fires AFTER child seed effects in React's child-before-parent order, so the previous one-shot seed got clobbered back to `""` by the defaults call on first mount and never re-fired. Replaced the `[]`-deps effect with a two-effect pattern: an observer that latches `hasSeededRef = true` the first time `value` is observed non-empty, and a seed effect that keeps re-installing the starter while the latch is open. Once the seed sticks the latch closes; subsequent edits and realtime refetches don't re-trigger.
24
+
25
+ **NEW**
26
+
27
+ - The Monaco editor for inline scripts now mounts the real upstream `@types/node` + `bun-types` declarations as a virtual filesystem (lazy-loaded as its own JS chunk), so IntelliSense covers the full Node/Bun stdlib, the `Bun` global, `process.env`, `Buffer`, etc. DOM types are deliberately excluded so suggestions stay focused on the backend surface. `context.config` is typed from the collector's own JSON Schema.
28
+ - New `healthcheckScriptContext` / `integrationScriptContext` helpers (exported from `@checkstack/ui`) build a complete editor bundle in one call: TS declarations (`context.config` / `context.event.payload` + the virtual `@checkstack/healthcheck` / `@checkstack/integration` result-type modules), starter templates per language, and the shell env-var list (with platform-injected `EVENT_ID` / `DELIVERY_ID` / `PAYLOAD_*` for integrations). Both call sites — `CollectorSection.tsx` and `CreateSubscriptionDialog.tsx` — were rewired to use them, fixing a long-standing wiring gap where IntelliSense for injected values silently never reached the editor.
29
+ - Inline scripts can now `import { defineHealthCheck } from "@checkstack/healthcheck"` (or `defineIntegration` for integrations) for a typed return-shape assertion. The editor catches `{ success: "yes" }` as a type error against `HealthCheckScriptResult`. The runtime is just an identity function — the collector rewrites the import to a sibling helper file in the temp dir before executing.
30
+ - Shell editors now autocomplete env-vars after `$` and `${`. The completion list is supplied by `healthcheckScriptContext` (safe-vars whitelist) and `integrationScriptContext` (whitelist + `EVENT_ID` etc. + `PAYLOAD_*` flattened from the event's payload schema). The matcher is pure and unit-tested in `shellEnvVarMatcher.test.ts` so regex regressions are caught locally.
31
+ - Empty editor fields are now seeded with a working starter template per language (inline TS uses `defineHealthCheck`, inline shell does the `awk` load-average check, integration TS shows `defineIntegration` with `context.event`, integration shell lists the `$EVENT_ID` / `$PAYLOAD_*` env vars). Users see a runnable example instead of a blank canvas; once they edit, we leave their content alone.
32
+ - Hardened concurrency + cleanup model documented and tested: each invocation gets its own `mkdtemp` directory + UUID result marker; the `finally` block clears the timeout handle, kills any surviving subprocess (idempotent), and removes the temp directory on success, throw, _and_ timeout. New `concurrency.test.ts` proves 20 parallel inline scripts don't cross wires and that the temp-dir count returns to baseline after throws and timeouts.
33
+
34
+ **TESTING**
35
+
36
+ Tight unit tests added so changes to the editor surface don't need smoke testing:
37
+
38
+ - `scriptContext.test.ts` — 18 tests covering the generated type declarations (including explicit regression guards for `defineIntegration` / `defineHealthCheck` callback params being typed against the shared context interface rather than `unknown`), starter templates (including a guard that the shell starter doesn't depend on Linux-only `/proc/loadavg`), shell env-vars for both healthcheck + integration flavours, plus the schema-flattening utility.
39
+ - `shellEnvVarMatcher.test.ts` — 12 tests covering the bare `$` / braced `${` / partial-name / case-insensitive matching logic that powers Monaco's shell completion.
40
+ - `inline-script-normaliser.test.ts` — 13 tests covering the legacy `return X;` → IIFE wrap path, the ESM-passthrough path, and the `@checkstack/healthcheck` import rewriter.
41
+ - `inline-script-collector.test.ts` — 18 tests including ones that actually execute a script importing `defineHealthCheck` (named-import form) AND using the global `defineHealthCheck` (no import) to prove both code paths resolve at runtime.
42
+ - `concurrency.test.ts` — 4 tests proving 20 parallel runs don't collide and that the temp-dir count returns to baseline after success, throw, and timeout.
43
+ - `useInitOnceForKey.test.ts` — 10 tests proving the healthcheck-editor form state isn't reset when react-query refetches in the background (the original "fields reset while I'm typing" regression).
44
+ - `starterTemplateSelector.test.ts` — 7 tests for the pure decision function powering empty-field seeding.
45
+ - `security.test.ts` — added an integration test that actually executes the portable load-average pipeline through `Bun.spawn` on the current OS, catching `/proc/loadavg`-style regressions at CI time on macOS runners.
46
+
47
+ **SECURITY**
48
+
49
+ - Same env-var whitelist as before (`PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `HOSTNAME`, `SHELL`). Backend secrets in the satellite process's environment remain invisible to user scripts.
50
+
51
+ See `docs/src/content/docs/backend/script-healthchecks.md` for the full user-facing guide.
52
+
53
+ ## 1.8.3
54
+
55
+ ### Patch Changes
56
+
57
+ - 1909a61: Address open CodeQL code-scanning findings:
58
+
59
+ - **`@checkstack/ui` (`LinksEditor`)**: validate URL scheme on render and on
60
+ add; only `http:` / `https:` URLs are accepted, defeating stored XSS via
61
+ `javascript:` / `data:` schemes in user-supplied hotlinks
62
+ (`js/xss-through-dom`).
63
+ - **`@checkstack/backend-api` (`markdownToPlainText`)**: decode HTML entities
64
+ before stripping tags, then strip tags in a loop until the output
65
+ stabilizes. Decoding `&amp;` last avoids reintroducing tag delimiters
66
+ via `&amp;lt;` round-trips (`js/double-escaping`,
67
+ `js/incomplete-multi-character-sanitization`).
68
+ - **`@checkstack/backend` (`createScopedWsRegistry`)**: drop the
69
+ identity-replacement on the path suffix; the leading-slash invariant
70
+ is documented on `WebSocketRouteRegistry` (`js/identity-replacement`).
71
+
3
72
  ## 1.8.2
4
73
 
5
74
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/ui",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -39,9 +39,12 @@
39
39
  "@storybook/react": "^10.3.6",
40
40
  "@storybook/react-vite": "^10.3.6",
41
41
  "@testing-library/react": "^16.0.0",
42
+ "@types/bun": "^1.3.14",
43
+ "@types/node": "^20.19.27",
42
44
  "@types/react": "^18.2.0",
43
45
  "@types/react-dom": "^18.2.0",
44
46
  "@vitejs/plugin-react": "^6.0.1",
47
+ "bun-types": "^1.3.14",
45
48
  "autoprefixer": "^10.4.18",
46
49
  "postcss": "^8.4.35",
47
50
  "storybook": "^10.3.6",
@@ -56,7 +59,8 @@
56
59
  "lint:code": "eslint . --max-warnings 0",
57
60
  "test": "bun test",
58
61
  "storybook": "storybook dev -p 6006",
59
- "storybook:build": "storybook build -o storybook-static"
62
+ "storybook:build": "storybook build -o storybook-static",
63
+ "generate:monaco-types": "bun run scripts/generate-stdlib-types.ts"
60
64
  },
61
65
  "checkstack": {
62
66
  "type": "tooling"
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Bundle the real `@types/node` and `bun-types` ambient declarations into a
4
+ * single JSON record so Monaco's TypeScript service can mount them as a
5
+ * virtual filesystem. The user's inline-script editor uses these for
6
+ * IntelliSense — `import { loadavg } from "node:os"` should autocomplete
7
+ * because the actual upstream `os.d.ts` is reachable, not a hand-rolled
8
+ * approximation.
9
+ *
10
+ * The result is keyed by virtual filesystem path so Monaco resolves
11
+ * cross-file `/// <reference>` directives correctly:
12
+ *
13
+ * {
14
+ * "node_modules/@types/node/index.d.ts": "...",
15
+ * "node_modules/@types/node/os.d.ts": "...",
16
+ * "node_modules/bun-types/index.d.ts": "...",
17
+ * ...
18
+ * }
19
+ *
20
+ * Run with `bun run generate:monaco-types` from `core/ui`. The output JSON
21
+ * lives at `src/components/CodeEditor/generated/stdlib-types.json` and is
22
+ * lazy-imported by MonacoEditor (so the ~3 MB payload is code-split into
23
+ * its own chunk and never blocks initial page load).
24
+ */
25
+ import { createRequire } from "node:module";
26
+ import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
27
+ import path from "node:path";
28
+
29
+ const require = createRequire(import.meta.url);
30
+
31
+ async function walkDts(
32
+ dir: string,
33
+ pathPrefix: string,
34
+ ): Promise<Record<string, string>> {
35
+ const out: Record<string, string> = {};
36
+ const entries = await readdir(dir, { withFileTypes: true });
37
+ for (const entry of entries) {
38
+ const abs = path.join(dir, entry.name);
39
+ const rel = path.relative(dir, abs);
40
+ const virtualPath = `${pathPrefix}/${rel}`;
41
+ if (entry.isDirectory()) {
42
+ Object.assign(out, await walkDts(abs, virtualPath));
43
+ } else if (entry.name.endsWith(".d.ts")) {
44
+ out[virtualPath] = await readFile(abs, "utf8");
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ async function bundle(pkg: string, virtualPrefix: string) {
51
+ const pkgJsonPath = require.resolve(`${pkg}/package.json`);
52
+ const pkgRoot = path.dirname(pkgJsonPath);
53
+ console.log(` • ${pkg} ← ${pkgRoot}`);
54
+ return walkDts(pkgRoot, virtualPrefix);
55
+ }
56
+
57
+ console.log("📦 Bundling Monaco stdlib types...");
58
+
59
+ // Build the file map immutably, then merge — eslint's
60
+ // `unicorn/no-immediate-mutation` flags `Object.assign(literal, ...)`.
61
+ const nodeFiles = await bundle("@types/node", "node_modules/@types/node");
62
+ const bunFiles = await bundle("bun-types", "node_modules/bun-types");
63
+
64
+ const files: Record<string, string> = {
65
+ ...nodeFiles,
66
+ ...bunFiles,
67
+ // `@types/bun` is just a one-line wrapper that triple-slash-references
68
+ // bun-types. Inline it so consumers can `import "bun"` and have Monaco
69
+ // resolve it without needing the wrapper package.
70
+ "node_modules/@types/bun/index.d.ts": `/// <reference types="bun-types" />\n`,
71
+ };
72
+
73
+ const outDir = path.join(
74
+ import.meta.dir,
75
+ "..",
76
+ "src",
77
+ "components",
78
+ "CodeEditor",
79
+ "generated",
80
+ );
81
+ const outFile = path.join(outDir, "stdlib-types.json");
82
+
83
+ await mkdir(outDir, { recursive: true });
84
+ // Compact JSON — this is consumed by code, not humans.
85
+ await writeFile(outFile, JSON.stringify(files), "utf8");
86
+
87
+ const totalBytes = Object.values(files).reduce((acc, c) => acc + c.length, 0);
88
+ console.log(
89
+ `✅ Wrote ${Object.keys(files).length} files (${(totalBytes / 1024 / 1024).toFixed(2)} MB) → ${path.relative(process.cwd(), outFile)}`,
90
+ );
@@ -6,9 +6,16 @@ export {
6
6
  type CodeEditorProps,
7
7
  type CodeEditorLanguage,
8
8
  type TemplateProperty,
9
+ type ShellEnvVar,
9
10
  } from "./MonacoEditor";
10
11
 
11
12
  export {
12
13
  generateTypeDefinitions,
13
14
  type GenerateTypesOptions,
14
15
  } from "./generateTypeDefinitions";
16
+
17
+ export {
18
+ healthcheckScriptContext,
19
+ integrationScriptContext,
20
+ type ScriptEditorContext,
21
+ } from "./scriptContext";
@@ -1,17 +1,34 @@
1
+ // Side-effect import: bundles Monaco's per-language workers via Vite
2
+ // `?worker` imports and points `@monaco-editor/react`'s loader at the
3
+ // local Monaco rather than its CDN default. Must run before any
4
+ // `<Editor>` mount. See monacoWorkers.ts for the full explanation
5
+ // (TL;DR: without local workers, the TS service can't spawn `ts.worker`,
6
+ // so TypeScript editors silently degrade to no-language-service mode
7
+ // while shell editors keep working because they only need the generic
8
+ // editor worker).
9
+ import "./monacoWorkers";
10
+
1
11
  import React from "react";
2
12
  import Editor, {
3
- loader,
4
13
  type OnMount,
5
14
  type Monaco,
6
15
  } from "@monaco-editor/react";
7
16
  import type { editor, Position } from "monaco-editor";
8
17
  import { detectOpenTemplate, detectAutoClosedBraces } from "./templateUtils";
9
-
10
- // Configure Monaco to use self-hosted files (for air-gapped environments)
11
- // The @monaco-editor/react package will load from node_modules by default
12
- loader.config({
13
- "vs/nls": { availableLanguages: { "*": "en" } },
14
- });
18
+ import { ensureMonacoStdlib } from "./monacoStdlib";
19
+ import {
20
+ matchShellEnvVarTrigger,
21
+ buildShellEnvVarInsertText,
22
+ } from "./shellEnvVarMatcher";
23
+
24
+ // Note on loader config: previously this module called
25
+ // loader.config({ "vs/nls": { availableLanguages: { "*": "en" } } })
26
+ // to keep Monaco in English when the loader fetched it from a CDN. We
27
+ // now bundle Monaco locally (see monacoWorkers.ts → loader.config({
28
+ // monaco })), which short-circuits the AMD loader entirely — the NLS
29
+ // config can't apply because no remote Monaco is ever fetched. English
30
+ // is the default for the locally bundled `monaco-editor` package, so
31
+ // removing the call has no observable effect.
15
32
 
16
33
  export type CodeEditorLanguage =
17
34
  | "json"
@@ -34,6 +51,19 @@ export interface TemplateProperty {
34
51
  description?: string;
35
52
  }
36
53
 
54
+ /**
55
+ * A single environment variable available to shell scripts. Surfaced as
56
+ * a Monaco completion item after the user types `$` or `${` in shell mode.
57
+ */
58
+ export interface ShellEnvVar {
59
+ /** Variable name without the leading `$`, e.g. `EVENT_ID`. */
60
+ name: string;
61
+ /** Optional description shown in the completion popup. */
62
+ description?: string;
63
+ /** Optional example value shown in the completion item detail. */
64
+ example?: string;
65
+ }
66
+
37
67
  export interface CodeEditorProps {
38
68
  /** Unique identifier for the editor */
39
69
  id?: string;
@@ -59,6 +89,13 @@ export interface CodeEditorProps {
59
89
  * When provided, typing "{{" triggers autocomplete with available template variables.
60
90
  */
61
91
  templateProperties?: TemplateProperty[];
92
+ /**
93
+ * Optional environment-variable hints for shell mode. When provided and
94
+ * `language === "shell"`, Monaco autocompletes them after `$` and `${`.
95
+ * Use this to surface platform-injected vars like `EVENT_ID` or
96
+ * `PAYLOAD_*` without forcing users to memorise the names.
97
+ */
98
+ shellEnvVars?: ShellEnvVar[];
62
99
  }
63
100
 
64
101
  // Map language names to Monaco language IDs
@@ -72,76 +109,60 @@ const languageMap: Record<string, string> = {
72
109
  shell: "shell",
73
110
  };
74
111
 
112
+ /**
113
+ * File-extension hint used in the per-editor Monaco model URI. Monaco
114
+ * decides which language service to use partly from the URI's extension,
115
+ * so a path like `<id>.ts` reliably yields the TypeScript service even
116
+ * if the `language` prop is still being reconciled by the React wrapper
117
+ * during a fast tab-switch. This is what fixes the "TS check now has
118
+ * shell highlighting after I created an SSH check" bug: without
119
+ * different paths, every `<CodeEditor>` mount reuses Monaco's _default_
120
+ * model — and the model's previous language sticks around.
121
+ */
122
+ const LANGUAGE_EXTENSIONS: Record<string, string> = {
123
+ json: "json",
124
+ yaml: "yaml",
125
+ xml: "xml",
126
+ markdown: "md",
127
+ javascript: "js",
128
+ typescript: "ts",
129
+ shell: "sh",
130
+ "json-template": "json",
131
+ };
132
+
75
133
  // Track if we've registered the json-template language
76
134
  let jsonTemplateLanguageRegistered = false;
77
135
 
78
136
  /**
79
- * Default type definitions for backend TypeScript/JavaScript editors.
80
- * Provides console and fetch APIs without DOM types.
81
- * Used when no custom typeDefinitions are provided to the editor.
137
+ * Default type definitions injected on top of the bundled Node/Bun stdlib.
138
+ *
139
+ * `ensureMonacoStdlib` mounts the real upstream `@types/node` + `bun-types`
140
+ * d.ts files into Monaco's virtual filesystem, so `import { loadavg } from
141
+ * "node:os"`, `fetch`, `console`, `URL`, etc. are all already declared.
142
+ * This block only adds what's _specific_ to the inline-script runner —
143
+ * the `context` global and the expected return shape.
144
+ *
145
+ * Used when no custom typeDefinitions prop is passed.
82
146
  */
83
147
  const DEFAULT_BACKEND_TYPE_DEFINITIONS = `
84
- /** Expected return type for healthcheck scripts */
148
+ /** Expected return shape for inline-script health checks. */
85
149
  interface HealthCheckScriptResult {
86
- /** Whether the health check passed */
150
+ /** Whether the health check passed. */
87
151
  success: boolean;
88
- /** Optional status message */
152
+ /** Optional status message — surfaces in the run detail. */
89
153
  message?: string;
90
- /** Optional numeric value for metrics */
154
+ /** Optional numeric value feeds the value chart + anomaly detection. */
91
155
  value?: number;
92
156
  }
93
157
 
94
- /** Console for logging */
95
- declare const console: {
96
- /** Log an info message */
97
- log(...args: unknown[]): void;
98
- /** Log a warning message */
99
- warn(...args: unknown[]): void;
100
- /** Log an error message */
101
- error(...args: unknown[]): void;
102
- /** Log an info message */
103
- info(...args: unknown[]): void;
158
+ /**
159
+ * Runtime context exposed by the inline-script runner.
160
+ * Available as a global, so just reference \`context.config\`.
161
+ */
162
+ declare const context: {
163
+ /** The raw collector configuration object. */
164
+ readonly config: Record<string, unknown>;
104
165
  };
105
-
106
- /** HTTP Request configuration */
107
- interface RequestInit {
108
- method?: string;
109
- headers?: Record<string, string> | Headers;
110
- body?: string | FormData | URLSearchParams;
111
- mode?: 'cors' | 'no-cors' | 'same-origin';
112
- credentials?: 'omit' | 'same-origin' | 'include';
113
- cache?: 'default' | 'no-store' | 'reload' | 'no-cache' | 'force-cache';
114
- redirect?: 'follow' | 'error' | 'manual';
115
- signal?: AbortSignal;
116
- }
117
-
118
- /** HTTP Response */
119
- interface Response {
120
- readonly ok: boolean;
121
- readonly status: number;
122
- readonly statusText: string;
123
- readonly headers: Headers;
124
- readonly url: string;
125
- readonly redirected: boolean;
126
- json(): Promise<unknown>;
127
- text(): Promise<string>;
128
- blob(): Promise<Blob>;
129
- arrayBuffer(): Promise<ArrayBuffer>;
130
- clone(): Response;
131
- }
132
-
133
- /** HTTP Headers */
134
- interface Headers {
135
- get(name: string): string | null;
136
- has(name: string): boolean;
137
- set(name: string, value: string): void;
138
- append(name: string, value: string): void;
139
- delete(name: string): void;
140
- forEach(callback: (value: string, key: string) => void): void;
141
- }
142
-
143
- /** Fetch API for making HTTP requests */
144
- declare function fetch(input: string | URL, init?: RequestInit): Promise<Response>;
145
166
  `;
146
167
 
147
168
  /**
@@ -158,6 +179,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
158
179
  placeholder,
159
180
  typeDefinitions,
160
181
  templateProperties,
182
+ shellEnvVars,
161
183
  }) => {
162
184
  const editorRef = React.useRef<editor.IStandaloneCodeEditor | null>(null);
163
185
  const monacoRef = React.useRef<Monaco | null>(null);
@@ -216,39 +238,24 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
216
238
  monacoRef.current = monaco;
217
239
  setIsEditorMounted(true);
218
240
 
219
- // Configure TypeScript/JavaScript to exclude DOM types for backend scripts
220
- // This prevents Web API autocompletions (AudioContext, Canvas, etc.)
221
- if (language === "typescript" || language === "javascript") {
222
- const defaults =
223
- language === "typescript"
224
- ? monaco.languages.typescript.typescriptDefaults
225
- : monaco.languages.typescript.javascriptDefaults;
226
-
227
- // Configure compiler options to exclude DOM types but keep ES libs
228
- // This gives us Promise, Array, etc. but not AudioContext, Canvas, etc.
229
- defaults.setCompilerOptions({
230
- target: monaco.languages.typescript.ScriptTarget.ESNext,
231
- module: monaco.languages.typescript.ModuleKind.ESNext,
232
- lib: ["esnext"], // Include ES libs but NOT DOM
233
- allowNonTsExtensions: true,
234
- noEmit: true,
235
- strict: true,
236
- });
237
-
238
- // Suppress certain diagnostics that don't apply to our script context
239
- // - 1108: "A 'return' statement can only be used within a function body"
240
- // Our scripts are wrapped in async function at runtime, so return is valid
241
- defaults.setDiagnosticsOptions({
242
- diagnosticCodesToIgnore: [1108],
243
- });
244
241
 
245
- // Disable fetching default lib content (prevents DOM types loading)
246
- defaults.setEagerModelSync(true);
247
242
 
248
- // Add custom type definitions if provided, otherwise add minimal defaults
249
- const definitions = typeDefinitions ?? DEFAULT_BACKEND_TYPE_DEFINITIONS;
250
- const lib = defaults.addExtraLib(definitions, "file:///context.d.ts");
251
- disposablesRef.current.push(lib);
243
+ // Per-editor TS/JS setup. All GLOBAL TS-service config (compiler
244
+ // options, diagnostics options, eager model sync) is done once at
245
+ // module load in monacoWorkers.ts — doing it here, after the
246
+ // service may have already initialised lazily from a previous
247
+ // mount, was the root cause of the "open shell first, TS service
248
+ // is broken" bug.
249
+ //
250
+ // The per-editor `addExtraLib` for THIS editor's `context.d.ts` is
251
+ // handled by the useEffect below (so it can also refresh when the
252
+ // `typeDefinitions` prop changes), keyed by `modelIdRef.current` so
253
+ // two TS editors with different schemas don't share a virtual path.
254
+ //
255
+ // Here we only kick off the lazy stdlib bundle so the ~3 MB chunk
256
+ // starts streaming on the first TS editor mount.
257
+ if (language === "typescript" || language === "javascript") {
258
+ void ensureMonacoStdlib(monaco);
252
259
  }
253
260
 
254
261
  // Handle validation for template syntax in JSON
@@ -432,41 +439,86 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
432
439
  };
433
440
  }, [templateProperties, language, isEditorMounted]);
434
441
 
435
- // Update type definitions when they change
442
+ // Install (and refresh) the per-editor context.d.ts.
443
+ //
444
+ // - Includes `isEditorMounted` in the deps so the effect runs after
445
+ // `monacoRef.current` is wired up by `handleEditorDidMount`.
446
+ // - Uses a unique virtual path keyed by this editor instance
447
+ // (`context-<modelId>.d.ts`) so two TS editors with different
448
+ // collector schemas can't overwrite each other's context types.
449
+ // - Returns a cleanup that disposes the registered lib, both on
450
+ // unmount and when `typeDefinitions` / `language` change.
436
451
  React.useEffect(() => {
452
+ if (!isEditorMounted) return;
437
453
  if (!monacoRef.current) return;
438
454
  if (language !== "typescript" && language !== "javascript") return;
439
455
 
440
456
  const monaco = monacoRef.current;
441
457
  const defaults =
442
458
  language === "typescript"
443
- ? monaco.languages.typescript.typescriptDefaults
444
- : monaco.languages.typescript.javascriptDefaults;
445
-
446
- // Configure compiler options to exclude DOM types but keep ES libs
447
- defaults.setCompilerOptions({
448
- target: monaco.languages.typescript.ScriptTarget.ESNext,
449
- module: monaco.languages.typescript.ModuleKind.ESNext,
450
- lib: ["esnext"], // Include ES libs but NOT DOM
451
- allowNonTsExtensions: true,
452
- noEmit: true,
453
- strict: true,
454
- });
459
+ ? monaco.typescript.typescriptDefaults
460
+ : monaco.typescript.javascriptDefaults;
455
461
 
456
- // Suppress certain diagnostics that don't apply to our script context
457
- defaults.setDiagnosticsOptions({
458
- diagnosticCodesToIgnore: [1108], // "A 'return' statement can only be used within a function body"
459
- });
460
-
461
- // Add type definitions (custom or default)
462
462
  const definitions = typeDefinitions ?? DEFAULT_BACKEND_TYPE_DEFINITIONS;
463
- const lib = defaults.addExtraLib(definitions, "file:///context.d.ts");
464
- disposablesRef.current.push(lib);
463
+ const contextLibPath = `file:///context-${modelIdRef.current}.d.ts`;
464
+ const lib = defaults.addExtraLib(definitions, contextLibPath);
465
465
 
466
466
  return () => {
467
467
  lib.dispose();
468
468
  };
469
- }, [typeDefinitions, language]);
469
+ }, [typeDefinitions, language, isEditorMounted]);
470
+
471
+ // Shell env-var completion. When the caller passes `shellEnvVars`, we
472
+ // register a Monaco provider that suggests variable names after the
473
+ // user types `$` or `${` — and also brace-closes `${name}` correctly.
474
+ // Re-registers when the var list or language changes.
475
+ React.useEffect(() => {
476
+ if (!isEditorMounted) return;
477
+ if (!monacoRef.current) return;
478
+ if (language !== "shell") return;
479
+ if (!shellEnvVars || shellEnvVars.length === 0) return;
480
+
481
+ const monaco = monacoRef.current;
482
+ const provider = monaco.languages.registerCompletionItemProvider("shell", {
483
+ triggerCharacters: ["$", "{"],
484
+ provideCompletionItems: (model: editor.ITextModel, position: Position) => {
485
+ const lineText = model.getLineContent(position.lineNumber);
486
+ const textBefore = lineText.slice(0, position.column - 1);
487
+ const match = matchShellEnvVarTrigger(textBefore);
488
+ if (!match) return { suggestions: [] };
489
+
490
+ const startColumn = position.column - match.prefixLength;
491
+
492
+ const suggestions = shellEnvVars
493
+ .filter(
494
+ (v) =>
495
+ match.query === "" ||
496
+ v.name.toUpperCase().includes(match.query),
497
+ )
498
+ .map((v, index) => ({
499
+ label: `$${v.name}`,
500
+ kind: monaco.languages.CompletionItemKind.Variable,
501
+ detail: v.example ? `e.g. ${v.example}` : "shell env var",
502
+ documentation: v.description,
503
+ insertText: buildShellEnvVarInsertText(match, v.name),
504
+ sortText: ` ${String(index).padStart(4, "0")}`,
505
+ filterText: `$${v.name}`,
506
+ range: {
507
+ startLineNumber: position.lineNumber,
508
+ startColumn,
509
+ endLineNumber: position.lineNumber,
510
+ endColumn: position.column,
511
+ },
512
+ }));
513
+
514
+ return { suggestions, incomplete: false };
515
+ },
516
+ });
517
+
518
+ return () => {
519
+ provider.dispose();
520
+ };
521
+ }, [shellEnvVars, language, isEditorMounted]);
470
522
 
471
523
  // Calculate height from minHeight
472
524
  const heightValue = minHeight.replace("px", "");
@@ -479,6 +531,39 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
479
531
  ? "json-template"
480
532
  : (languageMap[language] ?? language);
481
533
 
534
+ // Stable, unique-per-instance model path. We need this for two reasons:
535
+ //
536
+ // 1. Without a `path`, all `@monaco-editor/react` instances share the
537
+ // single default in-memory model. When you switch between a TS
538
+ // inline-script check and an SSH (shell) check, the new mount
539
+ // inherits the previous model's language, producing visibly wrong
540
+ // syntax highlighting until something else triggers a refresh.
541
+ //
542
+ // 2. The extension portion (`.ts`, `.sh`, `.json`, ...) is one of the
543
+ // signals Monaco's language service uses to decide which tokenizer
544
+ // and worker to apply, so encoding the effective language in the
545
+ // path makes the language choice unambiguous.
546
+ //
547
+ // We deliberately use a fresh UUID per mount (in a `useRef`, lazy
548
+ // init) rather than `useId`: `useId`'s output is derived from the
549
+ // call-site's position in the React tree, so a remount of the same
550
+ // `<CodeEditor>` slot — typical when the user switches between two
551
+ // healthcheck pages that React Router reuses — can return the same
552
+ // id as before. `@monaco-editor/react` then reuses the previous
553
+ // model (and its language) from Monaco's registry, which is exactly
554
+ // the "TS check now has shell highlighting" bug.
555
+ const modelIdRef = React.useRef<string | null>(null);
556
+ if (modelIdRef.current === null) {
557
+ modelIdRef.current = `cs-editor-${
558
+ typeof crypto !== "undefined" && "randomUUID" in crypto
559
+ ? crypto.randomUUID()
560
+ : Math.random().toString(36).slice(2, 11)
561
+ }`;
562
+ }
563
+ const pathExtension =
564
+ LANGUAGE_EXTENSIONS[effectiveLanguage] ?? effectiveLanguage;
565
+ const modelPath = `${modelIdRef.current}.${pathExtension}`;
566
+
482
567
  return (
483
568
  <div
484
569
  id={id}
@@ -488,6 +573,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
488
573
  <Editor
489
574
  height={`${Math.max(numericHeight, 100)}px`}
490
575
  language={effectiveLanguage}
576
+ path={modelPath}
491
577
  value={value}
492
578
  onChange={(newValue) => onChange(newValue ?? "")}
493
579
  beforeMount={handleEditorWillMount}