@checkstack/ui 1.10.0 → 1.12.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.
Files changed (72) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +565 -0
  3. package/package.json +15 -7
  4. package/scripts/generate-stdlib-types.ts +25 -2
  5. package/src/components/ActionCard.tsx +309 -0
  6. package/src/components/CodeEditor/CodeEditor.tsx +132 -9
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +1024 -0
  8. package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
  9. package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
  10. package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
  11. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  12. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  13. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  14. package/src/components/CodeEditor/index.ts +26 -0
  15. package/src/components/CodeEditor/monacoTsService.ts +217 -0
  16. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  17. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  18. package/src/components/CodeEditor/scriptContext.test.ts +41 -0
  19. package/src/components/CodeEditor/scriptContext.ts +76 -1
  20. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  21. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  22. package/src/components/CodeEditor/templateValidation.ts +51 -0
  23. package/src/components/CodeEditor/types.ts +168 -0
  24. package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
  25. package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
  26. package/src/components/CodeEditor/validateScripts.ts +132 -0
  27. package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
  28. package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
  29. package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
  30. package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
  31. package/src/components/Dialog.tsx +32 -11
  32. package/src/components/DurationInput.tsx +121 -0
  33. package/src/components/DynamicForm/DynamicForm.tsx +27 -1
  34. package/src/components/DynamicForm/FormField.tsx +138 -10
  35. package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
  36. package/src/components/DynamicForm/MultiTypeEditorField.tsx +83 -9
  37. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  38. package/src/components/DynamicForm/index.ts +6 -0
  39. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  40. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  41. package/src/components/DynamicForm/types.ts +83 -1
  42. package/src/components/DynamicForm/utils.ts +32 -0
  43. package/src/components/Popover.tsx +6 -1
  44. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  45. package/src/components/ScriptTestPanel.logic.ts +137 -0
  46. package/src/components/ScriptTestPanel.tsx +394 -0
  47. package/src/components/Sheet.tsx +21 -6
  48. package/src/components/TemplateInput.tsx +104 -0
  49. package/src/components/TemplateInputToggle.tsx +111 -0
  50. package/src/components/TemplateValueInput.test.ts +98 -0
  51. package/src/components/TemplateValueInput.tsx +470 -0
  52. package/src/components/TimeOfDayInput.tsx +116 -0
  53. package/src/components/VariablePicker.tsx +271 -0
  54. package/src/components/comboboxInteraction.ts +39 -0
  55. package/src/components/portalContainer.ts +24 -0
  56. package/src/hooks/useInitOnceForKey.test.ts +27 -0
  57. package/src/hooks/useInitOnceForKey.ts +21 -18
  58. package/src/index.ts +9 -0
  59. package/stories/ActionCard.stories.tsx +122 -0
  60. package/stories/Alert.stories.tsx +5 -5
  61. package/stories/CodeEditor.stories.tsx +47 -2
  62. package/stories/DurationInput.stories.tsx +59 -0
  63. package/stories/ScriptTestPanel.stories.tsx +106 -0
  64. package/stories/SecretEnvEditor.stories.tsx +80 -0
  65. package/stories/TemplateInputToggle.stories.tsx +77 -0
  66. package/stories/TemplateValueInput.stories.tsx +65 -0
  67. package/stories/TimeOfDayInput.stories.tsx +34 -0
  68. package/stories/VariablePicker.stories.tsx +109 -0
  69. package/tsconfig.json +1 -0
  70. package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
  71. package/src/components/CodeEditor/monacoStdlib.ts +0 -62
  72. package/src/components/CodeEditor/monacoWorkers.ts +0 -118
@@ -0,0 +1,267 @@
1
+ // Pure helpers for the editor's lazy Automatic Type Acquisition (ATA) loop.
2
+ //
3
+ // `parseBareImportSpecifiers` extracts the BARE package specifiers a script
4
+ // buffer imports/requires, so the editor can fetch + register only those
5
+ // packages' `.d.ts` on demand. `planAcquisitions` diffs that set against the
6
+ // already-acquired set so re-parsing on every keystroke never refetches.
7
+ //
8
+ // Both are pure + unit-tested (no monaco/DOM), per the no-DOM-tests rule.
9
+
10
+ /** Bare specifiers we never try to acquire (already typed or runtime built-ins). */
11
+ const IGNORED_SPECIFIERS = new Set<string>(["context"]);
12
+
13
+ /**
14
+ * True for specifiers the editor must NOT try to acquire:
15
+ * - relative paths (`./x`, `../x`) — not packages.
16
+ * - `node:`-prefixed built-ins — already covered by the bundled @types/node.
17
+ * - `bun` / `bun:*` — covered by the bundled bun-types.
18
+ * - the injected `context` ambient global.
19
+ */
20
+ function isIgnoredSpecifier(specifier: string): boolean {
21
+ if (specifier.length === 0) return true;
22
+ if (specifier.startsWith("./") || specifier.startsWith("../")) return true;
23
+ if (specifier === "." || specifier === "..") return true;
24
+ if (specifier.startsWith("node:")) return true;
25
+ if (specifier === "bun" || specifier.startsWith("bun:")) return true;
26
+ return IGNORED_SPECIFIERS.has(specifier);
27
+ }
28
+
29
+ /**
30
+ * Reduce a possibly-subpath specifier to the package name to acquire:
31
+ * - `lodash/fp` -> `lodash`
32
+ * - `@scope/pkg/sub` -> `@scope/pkg`
33
+ * - `lodash` -> `lodash`
34
+ * - `@scope/pkg` -> `@scope/pkg`
35
+ */
36
+ export function packageNameFromSpecifier(specifier: string): string {
37
+ if (specifier.startsWith("@")) {
38
+ const parts = specifier.split("/");
39
+ return parts.slice(0, 2).join("/");
40
+ }
41
+ return specifier.split("/")[0];
42
+ }
43
+
44
+ /**
45
+ * Parse the unique BARE package names imported/required by a script buffer.
46
+ * Handles every import/require form:
47
+ * import x from "p"; import {a} from "p"; import * as p from "p";
48
+ * import "p"; import type {T} from "p"; export {a} from "p";
49
+ * export * from "p"; const x = require("p"); await import("p")
50
+ * Ignores relative paths, `node:`/`bun` built-ins, and the `context` global.
51
+ * Subpath imports are reduced to their package name.
52
+ */
53
+ export function parseBareImportSpecifiers(source: string): string[] {
54
+ const found = new Set<string>();
55
+
56
+ const patterns: RegExp[] = [
57
+ // import ... from "p" / export ... from "p" (incl. import type)
58
+ /\b(?:import|export)\b[^;\n]*?\bfrom\s*["']([^"']+)["']/g,
59
+ // bare side-effect import: import "p"
60
+ /\bimport\s*["']([^"']+)["']/g,
61
+ // dynamic import: import("p")
62
+ /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
63
+ // require: require("p")
64
+ /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
65
+ ];
66
+
67
+ for (const pattern of patterns) {
68
+ let match: RegExpExecArray | null = pattern.exec(source);
69
+ while (match !== null) {
70
+ const specifier = match[1];
71
+ if (!isIgnoredSpecifier(specifier)) {
72
+ found.add(packageNameFromSpecifier(specifier));
73
+ }
74
+ match = pattern.exec(source);
75
+ }
76
+ }
77
+
78
+ return [...found];
79
+ }
80
+
81
+ /**
82
+ * Given the specifiers currently in the buffer and the set already acquired,
83
+ * return only the NEW ones to fetch (order-stable). Pure planning step so the
84
+ * ATA loop never refetches an already-registered package.
85
+ */
86
+ export function planAcquisitions({
87
+ specifiers,
88
+ acquired,
89
+ }: {
90
+ specifiers: string[];
91
+ acquired: ReadonlySet<string>;
92
+ }): string[] {
93
+ const out: string[] = [];
94
+ const seen = new Set<string>();
95
+ for (const specifier of specifiers) {
96
+ if (acquired.has(specifier) || seen.has(specifier)) continue;
97
+ seen.add(specifier);
98
+ out.push(specifier);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * The cursor's position inside an import-string literal, as detected from the
105
+ * text on the current line up to (but not including) the cursor.
106
+ *
107
+ * `partial` is the specifier text already typed between the opening quote and
108
+ * the cursor; `replaceFromColumn` is the 1-based editor column of the FIRST
109
+ * specifier character (i.e. just after the opening quote), so a completion can
110
+ * replace the whole partial specifier without touching the quotes.
111
+ */
112
+ export interface ImportSpecifierCompletionContext {
113
+ partial: string;
114
+ /** 1-based column where the specifier text starts (just after the quote). */
115
+ replaceFromColumn: number;
116
+ }
117
+
118
+ // The import/require lead-ins that put a following string literal in
119
+ // module-specifier position. Each is matched at the END of the line-up-to-
120
+ // cursor, immediately followed by an OPEN (unclosed) quote + the partial text.
121
+ //
122
+ // ... from " -> from-clause (import/export)
123
+ // import " -> bare side-effect import
124
+ // import(" / require(" -> dynamic import / require call
125
+ //
126
+ // We deliberately match only an UNCLOSED string (no closing quote between the
127
+ // opening quote and the cursor) so completions never fire once the specifier
128
+ // string is already closed.
129
+ const IMPORT_STRING_LEAD_INS: RegExp[] = [
130
+ // `from "partial` (import ... from / export ... from)
131
+ /\bfrom\s*(["'])([^"']*)$/,
132
+ // `import "partial` (bare side-effect import) — `import` not followed by `(`
133
+ /\bimport\s*(["'])([^"']*)$/,
134
+ // `import("partial` (dynamic import)
135
+ /\bimport\s*\(\s*(["'])([^"']*)$/,
136
+ // `require("partial` (CJS require)
137
+ /\brequire\s*\(\s*(["'])([^"']*)$/,
138
+ ];
139
+
140
+ /**
141
+ * Detect whether the cursor sits inside an import/require module-specifier
142
+ * STRING, and if so return the partial specifier + where it starts. Returns
143
+ * null otherwise (so a completion provider can bail and not pollute normal
144
+ * positions).
145
+ *
146
+ * `lineUpToCursor` is the current line's text from column 1 up to the cursor
147
+ * (exclusive). Pure + unit-tested; no editor/DOM dependency.
148
+ */
149
+ export function importSpecifierCompletionContext(
150
+ lineUpToCursor: string,
151
+ ): ImportSpecifierCompletionContext | null {
152
+ for (const pattern of IMPORT_STRING_LEAD_INS) {
153
+ const match = pattern.exec(lineUpToCursor);
154
+ if (!match) continue;
155
+ const partial = match[2];
156
+ // The partial starts right after the opening quote. The quote's index is
157
+ // (matchEnd - partial.length - 1); the partial's first char is one past
158
+ // that. Editor columns are 1-based, so add 1 to the 0-based index.
159
+ const partialStartIndex = lineUpToCursor.length - partial.length;
160
+ return { partial, replaceFromColumn: partialStartIndex + 1 };
161
+ }
162
+ return null;
163
+ }
164
+
165
+ /**
166
+ * Filter a raw manifest package-name list down to the names a user can
167
+ * actually `import`: drop `@types/*` companions (you import `lodash`, never
168
+ * `@types/lodash`), dedupe, and sort. Pure + unit-tested.
169
+ */
170
+ export function importablePackageNames(names: readonly string[]): string[] {
171
+ const out = new Set<string>();
172
+ for (const name of names) {
173
+ if (name.startsWith("@types/")) continue;
174
+ out.add(name);
175
+ }
176
+ return [...out].toSorted((a, b) => a.localeCompare(b));
177
+ }
178
+
179
+ /**
180
+ * Extract the importable built-in module specifiers from bundled stdlib
181
+ * declaration text (`@types/node` + `bun-types`). Every importable built-in is
182
+ * declared as a top-level `declare module "<spec>"` (e.g. `node:fs`, bare `fs`,
183
+ * `bun`, `bun:test`), so the authoritative name set is exactly those names.
184
+ *
185
+ * Filter rules (documented):
186
+ * - Keep any `declare module "<spec>"` name that is a real specifier.
187
+ * - DROP names containing a star character — those are wildcard / asset-glob
188
+ * ambient shims (e.g. text/css asset globs or a "bun.lock" path glob), not
189
+ * importable runtime modules.
190
+ *
191
+ * Pure + unit-tested. Used at BUILD time by the stdlib-types generator to emit
192
+ * the static name list the editor ships.
193
+ */
194
+ export function extractBuiltinModuleSpecifiers(
195
+ declarationText: string,
196
+ ): string[] {
197
+ const out = new Set<string>();
198
+ // Top-level `declare module "<spec>"` (single or double quotes), anchored to
199
+ // a statement start (per line, via the `m` flag) so we don't match
200
+ // augmentation blocks nested deeper — though stdlib's importable modules are
201
+ // all declared at top level anyway.
202
+ const pattern = /^\s*declare\s+module\s+["']([^"']+)["']/gm;
203
+ let match: RegExpExecArray | null = pattern.exec(declarationText);
204
+ while (match !== null) {
205
+ const spec = match[1];
206
+ // Drop wildcard / asset-glob ambient shims (any name containing a star,
207
+ // e.g. an asset-glob like a ".txt" shim or a "bun.lock" path glob): those
208
+ // are ambient module shims, not importable runtime modules.
209
+ if (!spec.includes("*")) {
210
+ out.add(spec);
211
+ }
212
+ match = pattern.exec(declarationText);
213
+ }
214
+ return [...out].toSorted((a, b) => a.localeCompare(b));
215
+ }
216
+
217
+ /** A built-in import specifier plus how it should read in the completion list. */
218
+ export interface BuiltinModuleSpecifier {
219
+ name: string;
220
+ /** Completion `detail`, e.g. "Node.js" or "Bun built-in". */
221
+ detail: string;
222
+ }
223
+
224
+ /**
225
+ * Classify a built-in specifier for completion display: `bun` and any
226
+ * `bun:`-prefixed specifier are Bun built-ins; everything else (the
227
+ * `node:`-prefixed and bare node builtins) is Node.js. Pure.
228
+ */
229
+ export function classifyBuiltinModule(name: string): BuiltinModuleSpecifier {
230
+ const isBun = name === "bun" || name.startsWith("bun:");
231
+ return { name, detail: isBun ? "Bun built-in" : "Node.js" };
232
+ }
233
+
234
+ /** One entry in the merged import-name completion list. */
235
+ export interface ImportCompletionEntry {
236
+ name: string;
237
+ /** Completion `detail`, e.g. "installed package" / "Node.js" / "Bun built-in". */
238
+ detail: string;
239
+ }
240
+
241
+ /**
242
+ * Merge the always-available runtime built-ins with the injected installed
243
+ * package names into the final import-name completion list. Built-ins are
244
+ * always present (importable in the sandbox regardless of the allowlist);
245
+ * installed packages augment them. Deduped (installed names win their own
246
+ * `detail`) and sorted. Pure + unit-tested.
247
+ */
248
+ export function mergeImportCompletionEntries({
249
+ builtins,
250
+ installedPackages,
251
+ }: {
252
+ builtins: readonly string[];
253
+ installedPackages: readonly string[];
254
+ }): ImportCompletionEntry[] {
255
+ const byName = new Map<string, ImportCompletionEntry>();
256
+ for (const name of builtins) {
257
+ const { detail } = classifyBuiltinModule(name);
258
+ byName.set(name, { name, detail });
259
+ }
260
+ // Installed packages are already `@types/*`-free + deduped by the caller, but
261
+ // be defensive. An installed package name that collides with a built-in is
262
+ // re-labelled as an installed package (unlikely, but deterministic).
263
+ for (const name of installedPackages) {
264
+ byName.set(name, { name, detail: "installed package" });
265
+ }
266
+ return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
267
+ }
@@ -4,6 +4,9 @@ export {
4
4
  type CodeEditorLanguage,
5
5
  type TemplateProperty,
6
6
  type ShellEnvVar,
7
+ type EditorMarker,
8
+ type AcquireTypes,
9
+ type AcquiredTypeFile,
7
10
  } from "./CodeEditor";
8
11
 
9
12
  export {
@@ -12,7 +15,30 @@ export {
12
15
  } from "./generateTypeDefinitions";
13
16
 
14
17
  export {
18
+ customShellEnvVars,
15
19
  healthcheckScriptContext,
16
20
  integrationScriptContext,
17
21
  type ScriptEditorContext,
18
22
  } from "./scriptContext";
23
+
24
+ // Pure helper used by consumers (e.g. script-packages-frontend) to derive the
25
+ // importable package-name list for the editor's import-specifier completions.
26
+ export { importablePackageNames } from "./importSpecifiers";
27
+
28
+ // Headless script validator: type-check scripts against their generated
29
+ // `context` types without a mounted editor (drives the same standalone TS
30
+ // worker). Used by the automation editor to surface type errors on collapsed
31
+ // script-action cards.
32
+ export {
33
+ validateTypeScriptSources,
34
+ type ScriptValidationInput,
35
+ type ScriptDiagnostic,
36
+ } from "./validateScripts";
37
+
38
+ // Subscribe to / query the monaco-vscode "services ready" transition so a
39
+ // consumer (the automation editor's script validator + its hidden services
40
+ // booter) can react the moment the first editor initializes the services.
41
+ export {
42
+ onVscodeServicesReady,
43
+ areVscodeServicesReady,
44
+ } from "./monacoTsService";
@@ -0,0 +1,217 @@
1
+ // Shared standalone TypeScript / JavaScript language-service setup.
2
+ //
3
+ // Extracted from `TypefoxEditor` so it can be imported by BOTH the editor and
4
+ // the headless `validateScripts` validator. Both need the same singletons
5
+ // configured (compiler options, ambient stdlib types, the TS worker), and the
6
+ // validator must work even when NO editor is mounted (every script action card
7
+ // collapsed) - so the setup cannot live inside the editor component module's
8
+ // render path.
9
+ //
10
+ // The TS/JS language-service `defaults` are singletons, so the configuration
11
+ // here runs ONCE at module load. The worker factory is registered lazily (and
12
+ // idempotently) via `ensureStandaloneWorkerFactory()` because the editor also
13
+ // registers it during its own init - the guard makes a double-call a no-op.
14
+
15
+ // Side-effect import: registers the standalone language grammars. Imported here
16
+ // too (not only in TypefoxEditor) so the validator gets a fully-registered TS
17
+ // language environment regardless of import order.
18
+ import "@codingame/monaco-vscode-standalone-languages";
19
+ // The named imports below ALSO trigger this package's side-effect registration
20
+ // of the standalone TypeScript language features (defaults + ts.worker).
21
+ import {
22
+ typescriptDefaults,
23
+ javascriptDefaults,
24
+ ScriptTarget,
25
+ ModuleKind,
26
+ ModuleResolutionKind,
27
+ } from "@codingame/monaco-vscode-standalone-typescript-language-features";
28
+ // Worker entry URLs, bundled and resolved by Vite via the `?worker&url` suffix.
29
+ // Imported as URL STRINGS (not Worker constructors) because
30
+ // monaco-languageclient's worker factory consumes `loader().url.toString()`.
31
+ import editorWorkerUrl from "@codingame/monaco-vscode-editor-api/esm/vs/editor/editor.worker.js?worker&url";
32
+ import tsWorkerUrl from "@codingame/monaco-vscode-standalone-typescript-language-features/worker?worker&url";
33
+ import jsonWorkerUrl from "@codingame/monaco-vscode-standalone-json-language-features/worker?worker&url";
34
+ import {
35
+ // `useWorkerFactory` is a plain library registration function, not a React
36
+ // hook. We alias away the `use` prefix so the `react-hooks/rules-of-hooks`
37
+ // lint rule (which keys purely off the identifier name) does not misfire.
38
+ useWorkerFactory as registerWorkerFactory,
39
+ Worker,
40
+ type WorkerFactoryConfig,
41
+ type WorkerLoader,
42
+ } from "monaco-languageclient/workerFactory";
43
+
44
+ // Re-exported so consumers keep a single import surface for the TS services.
45
+ export {
46
+ typescriptDefaults,
47
+ javascriptDefaults,
48
+ ScriptTarget,
49
+ ModuleKind,
50
+ ModuleResolutionKind,
51
+ } from "@codingame/monaco-vscode-standalone-typescript-language-features";
52
+
53
+ // The logger type originates from `@codingame/monaco-vscode-log-service-override`,
54
+ // which is not a direct dependency of this package. We derive it from the
55
+ // `WorkerFactoryConfig` we already import so we never reach for a transitive
56
+ // specifier (and never need an `any`).
57
+ type WorkerFactoryLogger = WorkerFactoryConfig["logger"];
58
+
59
+ const editorWorkerLoader: WorkerLoader = () =>
60
+ new Worker(editorWorkerUrl, { type: "module" });
61
+
62
+ const tsWorkerLoader: WorkerLoader = () =>
63
+ new Worker(tsWorkerUrl, { type: "module" });
64
+
65
+ const jsonWorkerLoader: WorkerLoader = () =>
66
+ new Worker(jsonWorkerUrl, { type: "module" });
67
+
68
+ // The monaco-vscode API initializes globally exactly ONCE, and that init is
69
+ // owned by the editor wrapper (`MonacoEditorReactComp`) - it throws "Services
70
+ // are already initialized" if anything else inits first. So the headless
71
+ // validator must NOT touch the worker / models until an editor has brought the
72
+ // services up. The editor flips this flag from its `onEditorStartDone`; the
73
+ // validator checks it and otherwise no-ops. Net effect: scripts validate once
74
+ // any script editor has been opened this session (covering collapsed cards
75
+ // from then on); a never-opened, all-collapsed automation is left to the
76
+ // deferred backend typecheck.
77
+ let vscodeServicesReady = false;
78
+ const servicesReadyListeners = new Set<() => void>();
79
+
80
+ /** Called by the editor once the monaco-vscode services have initialized. */
81
+ export const markVscodeServicesReady = (): void => {
82
+ if (vscodeServicesReady) return;
83
+ vscodeServicesReady = true;
84
+ for (const listener of servicesReadyListeners) listener();
85
+ servicesReadyListeners.clear();
86
+ };
87
+
88
+ /** True once an editor has initialized the monaco-vscode services. */
89
+ export const areVscodeServicesReady = (): boolean => vscodeServicesReady;
90
+
91
+ /**
92
+ * Subscribe to the one-time "services ready" transition. Fires immediately if
93
+ * already ready. Returns an unsubscribe. Lets the headless validator re-run the
94
+ * moment the first editor brings the services up (otherwise a never-edited
95
+ * definition would not re-validate just because a card was opened).
96
+ */
97
+ export const onVscodeServicesReady = (listener: () => void): (() => void) => {
98
+ if (vscodeServicesReady) {
99
+ listener();
100
+ return () => {};
101
+ }
102
+ servicesReadyListeners.add(listener);
103
+ return () => servicesReadyListeners.delete(listener);
104
+ };
105
+
106
+ let workerFactoryRegistered = false;
107
+
108
+ /**
109
+ * Register the worker loaders for the standalone (classic) Monaco setup. We
110
+ * only need the generic editor worker plus the TypeScript worker (which also
111
+ * serves JavaScript) and the JSON worker. Mirrors the upstream
112
+ * `defineClassicWorkers` helper.
113
+ *
114
+ * Idempotent: the first caller registers, subsequent calls no-op. Both the
115
+ * editor (via its `monacoWorkerFactory` app-config hook) AND the headless
116
+ * validator call this, so the guard prevents a double registration. The
117
+ * editor's call may pass a `logger`; the first registration wins, so when the
118
+ * validator registers first the editor simply reuses it (logging is optional).
119
+ */
120
+ export const ensureStandaloneWorkerFactory = (
121
+ logger?: WorkerFactoryLogger,
122
+ ): void => {
123
+ if (workerFactoryRegistered) {
124
+ return;
125
+ }
126
+ workerFactoryRegistered = true;
127
+ registerWorkerFactory({
128
+ workerLoaders: {
129
+ editorWorkerService: editorWorkerLoader,
130
+ // Both must be defined or the worker factory errors (see upstream
131
+ // helper-classic.ts).
132
+ javascript: tsWorkerLoader,
133
+ typescript: tsWorkerLoader,
134
+ json: jsonWorkerLoader,
135
+ },
136
+ logger,
137
+ });
138
+ };
139
+
140
+ // Base compiler options for the standalone TS + JS services. `types` (node +
141
+ // bun-types) is added only once the stdlib bundle has loaded (see
142
+ // ensureStandaloneStdlib), so the service doesn't transiently error on a
143
+ // missing `node` type while the ~3 MB bundle is still fetching.
144
+ //
145
+ // `baseUrl: "file:///"` anchors NodeJs bare-import resolution at the virtual
146
+ // root so an `import "lodash"` walks `file:///node_modules/...` - the same
147
+ // virtual layout the stdlib bundle AND the lazy-ATA extra-libs are registered
148
+ // under. `typeRoots` lists BOTH the `@types` root (so a package with no own
149
+ // types, e.g. lodash, falls back to `@types/lodash`) and the bare
150
+ // `node_modules` root (so the bundled `bun-types`, which is NOT under
151
+ // `@types`, still resolves as an ambient `types` entry).
152
+ export const BASE_COMPILER_OPTIONS = {
153
+ target: ScriptTarget.ESNext,
154
+ module: ModuleKind.ESNext,
155
+ moduleResolution: ModuleResolutionKind.NodeJs,
156
+ lib: ["esnext"],
157
+ allowNonTsExtensions: false,
158
+ noEmit: true,
159
+ strict: true,
160
+ esModuleInterop: true,
161
+ baseUrl: "file:///",
162
+ typeRoots: ["file:///node_modules/@types", "file:///node_modules"],
163
+ };
164
+
165
+ /**
166
+ * Configure the standalone TS + JS language services ONCE at module load.
167
+ * `typescriptDefaults` / `javascriptDefaults` are singletons, so doing this at
168
+ * module scope (not per-mount) guarantees the first editor to mount cannot
169
+ * start the service with stale defaults - the timing race the legacy monaco
170
+ * editor hit.
171
+ */
172
+ const configureTypeScriptDefaults = (): void => {
173
+ for (const defaults of [typescriptDefaults, javascriptDefaults]) {
174
+ defaults.setCompilerOptions({ ...BASE_COMPILER_OPTIONS });
175
+ // 1108: a top-level `return` is valid because the runtime wraps scripts in
176
+ // an async IIFE (same suppression as the legacy editor).
177
+ defaults.setDiagnosticsOptions({ diagnosticCodesToIgnore: [1108] });
178
+ // Push models to the worker eagerly so diagnostics/completions are ready on
179
+ // the first keystroke.
180
+ defaults.setEagerModelSync(true);
181
+ }
182
+ };
183
+
184
+ configureTypeScriptDefaults();
185
+
186
+ /**
187
+ * Lazy-load the bundled `@types/node` + `bun-types` declarations into the
188
+ * standalone TS service so script editors have `console`, `fetch`, `process`,
189
+ * `Bun`, etc. typed. The ~3 MB bundle is code-split into its own chunk and
190
+ * fetched once. Runs at module load; this file is browser-only so the dynamic
191
+ * import is safe here. Returns the in-flight promise so callers (the headless
192
+ * validator) can await stdlib readiness before requesting diagnostics.
193
+ */
194
+ let stdlibLoad: Promise<void> | undefined;
195
+ export const ensureStandaloneStdlib = (): Promise<void> => {
196
+ if (stdlibLoad) {
197
+ return stdlibLoad;
198
+ }
199
+ stdlibLoad = (async () => {
200
+ const stdlibModule = await import("./generated/stdlib-types.json");
201
+ const bundle = stdlibModule.default;
202
+ for (const defaults of [typescriptDefaults, javascriptDefaults]) {
203
+ for (const [path, content] of Object.entries(bundle)) {
204
+ defaults.addExtraLib(content, `file:///${path}`);
205
+ }
206
+ // The @types/node + bun-types declarations now exist at their node_modules
207
+ // virtual paths, so include them ambiently.
208
+ defaults.setCompilerOptions({
209
+ ...BASE_COMPILER_OPTIONS,
210
+ types: ["node", "bun-types"],
211
+ });
212
+ }
213
+ })();
214
+ return stdlibLoad;
215
+ };
216
+
217
+ void ensureStandaloneStdlib();
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { popoutTitle } from "./popoutTitle";
3
+ import type { CodeEditorLanguage } from "./types";
4
+
5
+ describe("popoutTitle", () => {
6
+ it("derives a script title for TypeScript", () => {
7
+ expect(popoutTitle({ language: "typescript" })).toBe(
8
+ "Edit script - TypeScript",
9
+ );
10
+ });
11
+
12
+ it("derives a script title for JavaScript", () => {
13
+ expect(popoutTitle({ language: "javascript" })).toBe(
14
+ "Edit script - JavaScript",
15
+ );
16
+ });
17
+
18
+ it("derives a script title for Shell", () => {
19
+ expect(popoutTitle({ language: "shell" })).toBe("Edit script - Shell");
20
+ });
21
+
22
+ it("falls back to a generic title for markup/text languages", () => {
23
+ const markupLanguages: CodeEditorLanguage[] = [
24
+ "json",
25
+ "yaml",
26
+ "xml",
27
+ "markdown",
28
+ ];
29
+ for (const language of markupLanguages) {
30
+ expect(popoutTitle({ language })).toBe("Edit");
31
+ }
32
+ });
33
+
34
+ it("uses a normal hyphen, never an em-dash", () => {
35
+ expect(popoutTitle({ language: "typescript" })).not.toContain("—");
36
+ });
37
+ });
@@ -0,0 +1,31 @@
1
+ // Pure helper deriving the popout-dialog title from the editor language.
2
+ // Kept separate (and unit-tested) so the title logic isn't buried in UI glue.
3
+ import type { CodeEditorLanguage } from "./types";
4
+
5
+ // Human-readable display name for the languages that get a script-flavoured
6
+ // title. Markup/text languages fall through to the generic "Edit" title since
7
+ // "script" wouldn't read correctly for them.
8
+ const SCRIPT_LANGUAGE_LABELS: Partial<Record<CodeEditorLanguage, string>> = {
9
+ typescript: "TypeScript",
10
+ javascript: "JavaScript",
11
+ shell: "Shell",
12
+ };
13
+
14
+ /**
15
+ * Derive the overlay dialog title from the editor language.
16
+ *
17
+ * - Script languages (`typescript` / `javascript` / `shell`) read as
18
+ * `Edit script - <Language>`.
19
+ * - Everything else (markup/text: json / yaml / xml / markdown) falls back to
20
+ * the generic `Edit`.
21
+ *
22
+ * Uses a normal hyphen (not an em-dash) per the project content style.
23
+ */
24
+ export const popoutTitle = ({
25
+ language,
26
+ }: {
27
+ language: CodeEditorLanguage;
28
+ }): string => {
29
+ const scriptLabel = SCRIPT_LANGUAGE_LABELS[language];
30
+ return scriptLabel ? `Edit script - ${scriptLabel}` : "Edit";
31
+ };
@@ -52,6 +52,15 @@ describe("healthcheckScriptContext", () => {
52
52
  expect(ctx.typeDefinitions).toContain("HealthCheckScriptContext");
53
53
  });
54
54
 
55
+ it("exposes `context.check` and `context.system` run-context metadata", () => {
56
+ // The runner injects check/system metadata alongside config, so the
57
+ // editor must type them or `context.system.name` would error.
58
+ const ctx = healthcheckScriptContext({});
59
+ expect(ctx.typeDefinitions).toContain("readonly check: {");
60
+ expect(ctx.typeDefinitions).toContain("readonly system: {");
61
+ expect(ctx.typeDefinitions).toContain("readonly intervalSeconds: number");
62
+ });
63
+
55
64
  it("types the `defineHealthCheck` callback parameter from the schema (not `unknown`)", () => {
56
65
  // Regression guard: the previous version had `(ctx: unknown) => …`,
57
66
  // so `ctx.config.host` produced "'ctx' is of type 'unknown'". The
@@ -95,10 +104,42 @@ describe("healthcheckScriptContext", () => {
95
104
  expect(names).toContain("PATH");
96
105
  expect(names).toContain("HOME");
97
106
  expect(names).toContain("TZ");
107
+ // Run-context vars the shell collector injects are suggested too.
108
+ expect(names).toContain("CHECKSTACK_CHECK_NAME");
109
+ expect(names).toContain("CHECKSTACK_SYSTEM_NAME");
110
+ expect(names).toContain("CHECKSTACK_CHECK_INTERVAL_SECONDS");
98
111
  // Integration-only vars must NOT leak into the healthcheck context.
99
112
  expect(names).not.toContain("EVENT_ID");
100
113
  expect(names).not.toContain("PAYLOAD_TITLE");
101
114
  });
115
+
116
+ it("surfaces the user's custom Env (JSON) keys as shell completions", () => {
117
+ const ctx = healthcheckScriptContext({
118
+ customEnv: { API_TOKEN: "secret", "not-an-ident": "x" },
119
+ });
120
+ const names = ctx.shellEnvVars.map((v) => v.name);
121
+ // Valid shell identifier from the user's env is suggested...
122
+ expect(names).toContain("API_TOKEN");
123
+ // ...alongside the whitelist + reserved run-context vars.
124
+ expect(names).toContain("PATH");
125
+ expect(names).toContain("CHECKSTACK_SYSTEM_NAME");
126
+ // Keys that aren't valid `$NAME` identifiers are dropped.
127
+ expect(names).not.toContain("not-an-ident");
128
+ // The user's own var must be ordered ahead of the whitelist + run-context
129
+ // vars so it's not buried at the bottom of the suggest list.
130
+ expect(names.indexOf("API_TOKEN")).toBeLessThan(names.indexOf("PATH"));
131
+ expect(names.indexOf("API_TOKEN")).toBeLessThan(
132
+ names.indexOf("CHECKSTACK_SYSTEM_NAME"),
133
+ );
134
+ });
135
+
136
+ it("ignores a non-object customEnv without throwing", () => {
137
+ const names = healthcheckScriptContext({
138
+ customEnv: "not an object",
139
+ }).shellEnvVars.map((v) => v.name);
140
+ expect(names).toContain("PATH");
141
+ expect(names).toContain("CHECKSTACK_CHECK_ID");
142
+ });
102
143
  });
103
144
 
104
145
  describe("integrationScriptContext", () => {