@checkstack/ui 1.11.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 (45) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +181 -0
  3. package/package.json +4 -4
  4. package/scripts/generate-stdlib-types.ts +23 -0
  5. package/src/components/ActionCard.tsx +96 -8
  6. package/src/components/CodeEditor/CodeEditor.tsx +95 -14
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +279 -123
  8. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  9. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  10. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  11. package/src/components/CodeEditor/index.ts +24 -0
  12. package/src/components/CodeEditor/monacoTsService.ts +217 -0
  13. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  14. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  15. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  16. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  17. package/src/components/CodeEditor/types.ts +59 -0
  18. package/src/components/CodeEditor/validateScripts.ts +132 -0
  19. package/src/components/Dialog.tsx +32 -11
  20. package/src/components/DurationInput.tsx +121 -0
  21. package/src/components/DynamicForm/DynamicForm.tsx +25 -1
  22. package/src/components/DynamicForm/FormField.tsx +109 -1
  23. package/src/components/DynamicForm/MultiTypeEditorField.tsx +67 -2
  24. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  25. package/src/components/DynamicForm/index.ts +6 -0
  26. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  27. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  28. package/src/components/DynamicForm/types.ts +72 -1
  29. package/src/components/DynamicForm/utils.ts +32 -0
  30. package/src/components/Popover.tsx +6 -1
  31. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  32. package/src/components/ScriptTestPanel.logic.ts +137 -0
  33. package/src/components/ScriptTestPanel.tsx +394 -0
  34. package/src/components/Sheet.tsx +21 -6
  35. package/src/components/TimeOfDayInput.tsx +116 -0
  36. package/src/components/comboboxInteraction.ts +39 -0
  37. package/src/components/portalContainer.ts +24 -0
  38. package/src/index.ts +4 -0
  39. package/stories/ActionCard.stories.tsx +60 -0
  40. package/stories/CodeEditor.stories.tsx +47 -2
  41. package/stories/DurationInput.stories.tsx +59 -0
  42. package/stories/ScriptTestPanel.stories.tsx +106 -0
  43. package/stories/SecretEnvEditor.stories.tsx +80 -0
  44. package/stories/TimeOfDayInput.stories.tsx +34 -0
  45. package/tsconfig.json +1 -0
@@ -0,0 +1,286 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ classifyBuiltinModule,
4
+ extractBuiltinModuleSpecifiers,
5
+ importablePackageNames,
6
+ importSpecifierCompletionContext,
7
+ mergeImportCompletionEntries,
8
+ packageNameFromSpecifier,
9
+ parseBareImportSpecifiers,
10
+ planAcquisitions,
11
+ } from "./importSpecifiers";
12
+
13
+ describe("packageNameFromSpecifier", () => {
14
+ test("plain", () => {
15
+ expect(packageNameFromSpecifier("lodash")).toBe("lodash");
16
+ });
17
+ test("subpath", () => {
18
+ expect(packageNameFromSpecifier("lodash/fp")).toBe("lodash");
19
+ });
20
+ test("scoped", () => {
21
+ expect(packageNameFromSpecifier("@babel/core")).toBe("@babel/core");
22
+ });
23
+ test("scoped subpath", () => {
24
+ expect(packageNameFromSpecifier("@scope/pkg/sub/deep")).toBe("@scope/pkg");
25
+ });
26
+ });
27
+
28
+ describe("parseBareImportSpecifiers", () => {
29
+ test("default, named, namespace, side-effect, type", () => {
30
+ const src = [
31
+ `import _ from "lodash";`,
32
+ `import { z } from "zod";`,
33
+ `import * as React from "react";`,
34
+ `import "side-effect-pkg";`,
35
+ `import type { Dayjs } from "dayjs";`,
36
+ ].join("\n");
37
+ const specs = parseBareImportSpecifiers(src);
38
+ expect(specs.sort()).toEqual(
39
+ ["dayjs", "lodash", "react", "side-effect-pkg", "zod"].sort(),
40
+ );
41
+ });
42
+
43
+ test("export-from, dynamic import, require", () => {
44
+ const src = [
45
+ `export { x } from "pkg-a";`,
46
+ `export * from "pkg-b";`,
47
+ `const p = await import("pkg-c");`,
48
+ `const q = require("pkg-d");`,
49
+ ].join("\n");
50
+ expect(parseBareImportSpecifiers(src).sort()).toEqual(
51
+ ["pkg-a", "pkg-b", "pkg-c", "pkg-d"].sort(),
52
+ );
53
+ });
54
+
55
+ test("ignores relative, node:, bun, and context", () => {
56
+ const src = [
57
+ `import a from "./local";`,
58
+ `import b from "../up";`,
59
+ `import { readFile } from "node:fs";`,
60
+ `import { Glob } from "bun";`,
61
+ `const c = context.trigger;`,
62
+ `import "context";`,
63
+ ].join("\n");
64
+ expect(parseBareImportSpecifiers(src)).toEqual([]);
65
+ });
66
+
67
+ test("scoped + subpath reduce to package name, deduped", () => {
68
+ const src = [
69
+ `import { debounce } from "lodash/debounce";`,
70
+ `import _ from "lodash";`,
71
+ `import { transform } from "@babel/core/lib/x";`,
72
+ ].join("\n");
73
+ expect(parseBareImportSpecifiers(src).sort()).toEqual(
74
+ ["@babel/core", "lodash"].sort(),
75
+ );
76
+ });
77
+
78
+ test("the lodash named-import case (acceptance)", () => {
79
+ expect(parseBareImportSpecifiers(`import { debounce } from "lodash";`)).toEqual(
80
+ ["lodash"],
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("planAcquisitions", () => {
86
+ test("returns only new specifiers, order-stable, deduped", () => {
87
+ const plan = planAcquisitions({
88
+ specifiers: ["lodash", "zod", "lodash", "react"],
89
+ acquired: new Set(["zod"]),
90
+ });
91
+ expect(plan).toEqual(["lodash", "react"]);
92
+ });
93
+ test("empty when all acquired", () => {
94
+ expect(
95
+ planAcquisitions({
96
+ specifiers: ["a", "b"],
97
+ acquired: new Set(["a", "b"]),
98
+ }),
99
+ ).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe("importSpecifierCompletionContext", () => {
104
+ // The `partial` text starts just after the opening quote; the
105
+ // `replaceFromColumn` is its 1-based start column. For a line like
106
+ // `import {} from "lod` the partial is `lod` starting at column 17.
107
+ test("from-clause double quote", () => {
108
+ const line = `import {} from "lod`;
109
+ const ctx = importSpecifierCompletionContext(line);
110
+ expect(ctx).toEqual({ partial: "lod", replaceFromColumn: 17 });
111
+ });
112
+
113
+ test("from-clause single quote", () => {
114
+ const line = `import x from 'lo`;
115
+ expect(importSpecifierCompletionContext(line)).toEqual({
116
+ partial: "lo",
117
+ replaceFromColumn: 16,
118
+ });
119
+ });
120
+
121
+ test("namespace import (import * as x from)", () => {
122
+ const line = `import * as _ from "lod`;
123
+ expect(importSpecifierCompletionContext(line)?.partial).toBe("lod");
124
+ });
125
+
126
+ test("export ... from", () => {
127
+ const line = `export { x } from "lod`;
128
+ expect(importSpecifierCompletionContext(line)?.partial).toBe("lod");
129
+ });
130
+
131
+ test("bare side-effect import", () => {
132
+ const line = `import "lo`;
133
+ expect(importSpecifierCompletionContext(line)).toEqual({
134
+ partial: "lo",
135
+ replaceFromColumn: 9,
136
+ });
137
+ });
138
+
139
+ test("dynamic import()", () => {
140
+ const line = `const m = await import("lo`;
141
+ expect(importSpecifierCompletionContext(line)?.partial).toBe("lo");
142
+ });
143
+
144
+ test("require()", () => {
145
+ const line = `const m = require("lo`;
146
+ expect(importSpecifierCompletionContext(line)?.partial).toBe("lo");
147
+ });
148
+
149
+ test("scoped partial keeps the slash", () => {
150
+ const line = `import {} from "@scope/`;
151
+ expect(importSpecifierCompletionContext(line)?.partial).toBe("@scope/");
152
+ });
153
+
154
+ test("empty partial right after the opening quote", () => {
155
+ // `import {} from "` is 16 chars; the cursor sits at column 17 where the
156
+ // (empty) specifier begins.
157
+ const line = `import {} from "`;
158
+ expect(importSpecifierCompletionContext(line)).toEqual({
159
+ partial: "",
160
+ replaceFromColumn: 17,
161
+ });
162
+ });
163
+
164
+ test("returns null when not in an import string", () => {
165
+ expect(importSpecifierCompletionContext(`const x = 1;`)).toBeNull();
166
+ expect(importSpecifierCompletionContext(`const s = "hello`)).toBeNull();
167
+ expect(importSpecifierCompletionContext(`foo("bar`)).toBeNull();
168
+ });
169
+
170
+ test("returns null once the import string is already closed", () => {
171
+ expect(
172
+ importSpecifierCompletionContext(`import {} from "lodash"`),
173
+ ).toBeNull();
174
+ expect(importSpecifierCompletionContext(`import "lodash";`)).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe("importablePackageNames", () => {
179
+ test("excludes @types/* companions", () => {
180
+ expect(
181
+ importablePackageNames(["lodash", "@types/lodash", "zod"]),
182
+ ).toEqual(["lodash", "zod"]);
183
+ });
184
+ test("dedupes and sorts", () => {
185
+ expect(
186
+ importablePackageNames(["zod", "lodash", "lodash", "@babel/core"]),
187
+ ).toEqual(["@babel/core", "lodash", "zod"]);
188
+ });
189
+ test("scoped non-@types names are kept", () => {
190
+ expect(importablePackageNames(["@babel/core", "@types/babel__core"])).toEqual(
191
+ ["@babel/core"],
192
+ );
193
+ });
194
+ });
195
+
196
+ describe("extractBuiltinModuleSpecifiers", () => {
197
+ // A small fixture mirroring how @types/node + bun-types declare importable
198
+ // modules (top-level `declare module "<spec>"`) alongside wildcard / asset
199
+ // shims that must be filtered out.
200
+ const fixture = [
201
+ `declare module "node:fs" { export function readFile(): void; }`,
202
+ `declare module "fs" { export function readFile(): void; }`,
203
+ `declare module 'node:path' { export const sep: string; }`,
204
+ `declare module "bun" { export const version: string; }`,
205
+ `declare module "bun:test" { export function test(): void; }`,
206
+ // Noise that must be dropped:
207
+ `declare module "*" { const c: unknown; export default c; }`,
208
+ `declare module "*.css" { const c: string; export default c; }`,
209
+ `declare module "*.txt" { const c: string; export default c; }`,
210
+ ].join("\n");
211
+
212
+ test("keeps real importable specifiers, drops wildcard/asset shims", () => {
213
+ expect(extractBuiltinModuleSpecifiers(fixture)).toEqual([
214
+ "bun",
215
+ "bun:test",
216
+ "fs",
217
+ "node:fs",
218
+ "node:path",
219
+ ]);
220
+ });
221
+
222
+ test("dedupes a repeated declaration", () => {
223
+ const src = `declare module "fs" {}\ndeclare module "fs" {}`;
224
+ expect(extractBuiltinModuleSpecifiers(src)).toEqual(["fs"]);
225
+ });
226
+
227
+ test("does not match an augmentation indented as a non-statement", () => {
228
+ // A `declare module` nested under another block won't be at line start;
229
+ // our anchor (`^\s*`) still allows leading whitespace, which is correct
230
+ // for real top-level declarations. This asserts the simple line-start
231
+ // behavior: a name on its own line is captured.
232
+ const src = ` declare module "node:os" {}`;
233
+ expect(extractBuiltinModuleSpecifiers(src)).toEqual(["node:os"]);
234
+ });
235
+
236
+ test("empty input yields empty list", () => {
237
+ expect(extractBuiltinModuleSpecifiers("")).toEqual([]);
238
+ });
239
+ });
240
+
241
+ describe("classifyBuiltinModule", () => {
242
+ test("bun and bun: specifiers are Bun built-ins", () => {
243
+ expect(classifyBuiltinModule("bun").detail).toBe("Bun built-in");
244
+ expect(classifyBuiltinModule("bun:test").detail).toBe("Bun built-in");
245
+ });
246
+ test("node and bare builtins are Node.js", () => {
247
+ expect(classifyBuiltinModule("node:fs").detail).toBe("Node.js");
248
+ expect(classifyBuiltinModule("fs").detail).toBe("Node.js");
249
+ });
250
+ });
251
+
252
+ describe("mergeImportCompletionEntries", () => {
253
+ test("built-ins always present; installed packages merged; sorted", () => {
254
+ const merged = mergeImportCompletionEntries({
255
+ builtins: ["node:fs", "bun"],
256
+ installedPackages: ["lodash", "zod"],
257
+ });
258
+ expect(merged.map((e) => e.name)).toEqual([
259
+ "bun",
260
+ "lodash",
261
+ "node:fs",
262
+ "zod",
263
+ ]);
264
+ expect(merged.find((e) => e.name === "bun")?.detail).toBe("Bun built-in");
265
+ expect(merged.find((e) => e.name === "node:fs")?.detail).toBe("Node.js");
266
+ expect(merged.find((e) => e.name === "lodash")?.detail).toBe(
267
+ "installed package",
268
+ );
269
+ });
270
+
271
+ test("built-ins present even with an empty allowlist", () => {
272
+ const merged = mergeImportCompletionEntries({
273
+ builtins: ["node:fs", "bun"],
274
+ installedPackages: [],
275
+ });
276
+ expect(merged.map((e) => e.name)).toEqual(["bun", "node:fs"]);
277
+ });
278
+
279
+ test("an installed package colliding with a built-in is labelled installed", () => {
280
+ const merged = mergeImportCompletionEntries({
281
+ builtins: ["fs"],
282
+ installedPackages: ["fs"],
283
+ });
284
+ expect(merged).toEqual([{ name: "fs", detail: "installed package" }]);
285
+ });
286
+ });
@@ -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
+ }
@@ -5,6 +5,8 @@ export {
5
5
  type TemplateProperty,
6
6
  type ShellEnvVar,
7
7
  type EditorMarker,
8
+ type AcquireTypes,
9
+ type AcquiredTypeFile,
8
10
  } from "./CodeEditor";
9
11
 
10
12
  export {
@@ -18,3 +20,25 @@ export {
18
20
  integrationScriptContext,
19
21
  type ScriptEditorContext,
20
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";