@glubean/runner 0.2.2 → 0.2.4

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @module bootstrap
3
+ *
4
+ * Project-level plugin bootstrap. Any SDK consumer that depends on plugin
5
+ * registration state (matchers, protocol adapters) must call `bootstrap()`
6
+ * at the start of its process — before scanning, running tests, handling
7
+ * MCP requests, or emitting metadata.
8
+ *
9
+ * The bootstrap contract is the single point where "which plugins does this
10
+ * project use" gets resolved. It locates a `glubean.setup.(ts|js|mjs)` file
11
+ * via walk-up from the given start directory and dynamically imports it.
12
+ * That file is expected to call `installPlugin(...)` at module top level.
13
+ *
14
+ * Idempotent by design — calling `bootstrap()` multiple times (across
15
+ * entry points, across sub-scans within one process) is safe and cheap.
16
+ *
17
+ * **TypeScript setup files**: Loading a `.ts` setup file requires the
18
+ * calling process to have a TypeScript module resolver active (tsx,
19
+ * ts-node, etc.). All first-party Glubean entry points (runner, scanner,
20
+ * CLI, MCP server, VSCode extension) run under tsx already, so this is
21
+ * transparent. Third-party embeds that cannot load `.ts` should ship a
22
+ * `glubean.setup.js` or `glubean.setup.mjs` instead.
23
+ *
24
+ * @see {@link installPlugin} in `./install-plugin.js`
25
+ */
26
+ /**
27
+ * Walk up from `startDir` searching for a Glubean setup file. Returns the
28
+ * absolute path of the first match found, or `undefined` if no setup file
29
+ * exists anywhere between `startDir` and the filesystem root (or `stopDir`).
30
+ *
31
+ * Setup files checked per directory, in priority order:
32
+ * `glubean.setup.ts` → `glubean.setup.js` → `glubean.setup.mjs`.
33
+ *
34
+ * @param startDir Directory to begin searching from (absolute or relative to cwd).
35
+ * @param stopDir Optional upper bound for the walk (defaults to filesystem root).
36
+ */
37
+ export declare function discoverSetupFile(startDir: string, stopDir?: string): string | undefined;
38
+ /**
39
+ * Locate and import the project's `glubean.setup` file, triggering the
40
+ * `installPlugin(...)` calls inside it.
41
+ *
42
+ * This function is the **bootstrap contract** for SDK consumers: every
43
+ * entry point that observes plugin-registered state (scanner for
44
+ * `.contract.ts` extraction, runner for test execution, MCP server for
45
+ * metadata, VSCode for scan) **must** await `bootstrap()` before doing
46
+ * its own work. Failing to do so causes a silent split where scan-time
47
+ * sees an empty adapter registry while runtime sees the full one.
48
+ *
49
+ * **Behavior:**
50
+ * - No setup file found → no-op (projects without plugins are fine, silent).
51
+ * - Setup file found and not yet imported this process → import it and
52
+ * await the returned promise (the setup file may call `await installPlugin(...)`).
53
+ * - Setup file found and already imported successfully → no-op (idempotent).
54
+ * - Setup file found but `import()` throws → the error is recorded **and**
55
+ * re-thrown. Every subsequent `bootstrap()` call that resolves to the same
56
+ * file re-throws the remembered error rather than silently succeeding.
57
+ * This is consistent with `installPlugin`'s "setup failure is
58
+ * process-unrecoverable" contract: later scanner / runner / MCP callers
59
+ * MUST see the failure, not a false success.
60
+ *
61
+ * @param startDir Starting directory for the walk-up search. Typically the
62
+ * project root or the process cwd. Caller decides — the SDK
63
+ * does not assume `process.cwd()`.
64
+ * @param stopDir Optional upper bound for the walk (defaults to filesystem root).
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * // runner startup
69
+ * import { bootstrap } from "@glubean/sdk";
70
+ * await bootstrap(projectRoot);
71
+ * // ... now safe to import test files / .contract.ts files
72
+ * ```
73
+ */
74
+ export declare function bootstrap(startDir: string, stopDir?: string): Promise<void>;
75
+ /**
76
+ * Test-only: clear the "already bootstrapped" cache so a subsequent
77
+ * `bootstrap()` call will re-import the setup file. Does **not** reset any
78
+ * plugin state on the globals — combine with
79
+ * `__resetInstalledPluginsForTesting()` if you need a full reset.
80
+ *
81
+ * @internal
82
+ */
83
+ export declare function __resetBootstrapForTesting(): void;
84
+ //# sourceMappingURL=bootstrap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.d.ts","sourceRoot":"","sources":["../src/bootstrap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AA8CH;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,GAAG,SAAS,CAiCpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAsB,SAAS,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,IAAI,IAAI,CAEjD"}
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @module bootstrap
3
+ *
4
+ * Project-level plugin bootstrap. Any SDK consumer that depends on plugin
5
+ * registration state (matchers, protocol adapters) must call `bootstrap()`
6
+ * at the start of its process — before scanning, running tests, handling
7
+ * MCP requests, or emitting metadata.
8
+ *
9
+ * The bootstrap contract is the single point where "which plugins does this
10
+ * project use" gets resolved. It locates a `glubean.setup.(ts|js|mjs)` file
11
+ * via walk-up from the given start directory and dynamically imports it.
12
+ * That file is expected to call `installPlugin(...)` at module top level.
13
+ *
14
+ * Idempotent by design — calling `bootstrap()` multiple times (across
15
+ * entry points, across sub-scans within one process) is safe and cheap.
16
+ *
17
+ * **TypeScript setup files**: Loading a `.ts` setup file requires the
18
+ * calling process to have a TypeScript module resolver active (tsx,
19
+ * ts-node, etc.). All first-party Glubean entry points (runner, scanner,
20
+ * CLI, MCP server, VSCode extension) run under tsx already, so this is
21
+ * transparent. Third-party embeds that cannot load `.ts` should ship a
22
+ * `glubean.setup.js` or `glubean.setup.mjs` instead.
23
+ *
24
+ * @see {@link installPlugin} in `./install-plugin.js`
25
+ */
26
+ import { dirname, isAbsolute, parse, relative, resolve } from "node:path";
27
+ import { existsSync } from "node:fs";
28
+ import { pathToFileURL } from "node:url";
29
+ /**
30
+ * Setup file names checked in priority order during walk-up. A directory
31
+ * containing any of these files is the project root for plugin purposes.
32
+ * The first hit in each directory wins.
33
+ */
34
+ const SETUP_FILE_NAMES = [
35
+ "glubean.setup.ts",
36
+ "glubean.setup.js",
37
+ "glubean.setup.mjs",
38
+ ];
39
+ const loadState = new Map();
40
+ /**
41
+ * Return true iff `descendant` is the same as or nested inside `ancestor`.
42
+ * Both inputs must be absolute, normalized paths.
43
+ * @internal
44
+ */
45
+ function isAncestorOrSame(ancestor, descendant) {
46
+ const rel = relative(ancestor, descendant);
47
+ // Empty string → equal. Relative path that is not ".." prefixed and not
48
+ // absolute → descendant is inside ancestor. Anything else → outside.
49
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
50
+ }
51
+ /**
52
+ * Walk up from `startDir` searching for a Glubean setup file. Returns the
53
+ * absolute path of the first match found, or `undefined` if no setup file
54
+ * exists anywhere between `startDir` and the filesystem root (or `stopDir`).
55
+ *
56
+ * Setup files checked per directory, in priority order:
57
+ * `glubean.setup.ts` → `glubean.setup.js` → `glubean.setup.mjs`.
58
+ *
59
+ * @param startDir Directory to begin searching from (absolute or relative to cwd).
60
+ * @param stopDir Optional upper bound for the walk (defaults to filesystem root).
61
+ */
62
+ export function discoverSetupFile(startDir, stopDir) {
63
+ const startAbs = resolve(startDir);
64
+ let root;
65
+ if (stopDir !== undefined) {
66
+ const stopAbs = resolve(stopDir);
67
+ // Reject a stopDir that is not an ancestor of (or equal to) startDir.
68
+ // Silently walking past a non-ancestor stopDir could pick up an unrelated
69
+ // setup file above it — reviewer-flagged bug. Fail loud instead.
70
+ if (!isAncestorOrSame(stopAbs, startAbs)) {
71
+ throw new Error(`discoverSetupFile: stopDir "${stopAbs}" is not an ancestor of startDir "${startAbs}". ` +
72
+ "stopDir must be equal to or above startDir in the directory tree.");
73
+ }
74
+ root = stopAbs;
75
+ }
76
+ else {
77
+ root = parse(startAbs).root;
78
+ }
79
+ let dir = startAbs;
80
+ while (true) {
81
+ for (const name of SETUP_FILE_NAMES) {
82
+ const candidate = resolve(dir, name);
83
+ if (existsSync(candidate)) {
84
+ return candidate;
85
+ }
86
+ }
87
+ if (dir === root)
88
+ break;
89
+ const parent = dirname(dir);
90
+ if (parent === dir)
91
+ break;
92
+ dir = parent;
93
+ }
94
+ return undefined;
95
+ }
96
+ /**
97
+ * Locate and import the project's `glubean.setup` file, triggering the
98
+ * `installPlugin(...)` calls inside it.
99
+ *
100
+ * This function is the **bootstrap contract** for SDK consumers: every
101
+ * entry point that observes plugin-registered state (scanner for
102
+ * `.contract.ts` extraction, runner for test execution, MCP server for
103
+ * metadata, VSCode for scan) **must** await `bootstrap()` before doing
104
+ * its own work. Failing to do so causes a silent split where scan-time
105
+ * sees an empty adapter registry while runtime sees the full one.
106
+ *
107
+ * **Behavior:**
108
+ * - No setup file found → no-op (projects without plugins are fine, silent).
109
+ * - Setup file found and not yet imported this process → import it and
110
+ * await the returned promise (the setup file may call `await installPlugin(...)`).
111
+ * - Setup file found and already imported successfully → no-op (idempotent).
112
+ * - Setup file found but `import()` throws → the error is recorded **and**
113
+ * re-thrown. Every subsequent `bootstrap()` call that resolves to the same
114
+ * file re-throws the remembered error rather than silently succeeding.
115
+ * This is consistent with `installPlugin`'s "setup failure is
116
+ * process-unrecoverable" contract: later scanner / runner / MCP callers
117
+ * MUST see the failure, not a false success.
118
+ *
119
+ * @param startDir Starting directory for the walk-up search. Typically the
120
+ * project root or the process cwd. Caller decides — the SDK
121
+ * does not assume `process.cwd()`.
122
+ * @param stopDir Optional upper bound for the walk (defaults to filesystem root).
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * // runner startup
127
+ * import { bootstrap } from "@glubean/sdk";
128
+ * await bootstrap(projectRoot);
129
+ * // ... now safe to import test files / .contract.ts files
130
+ * ```
131
+ */
132
+ export async function bootstrap(startDir, stopDir) {
133
+ const setupFile = discoverSetupFile(startDir, stopDir);
134
+ if (!setupFile)
135
+ return;
136
+ const state = loadState.get(setupFile);
137
+ if (state?.status === "ok") {
138
+ // Fast path: already loaded successfully.
139
+ return;
140
+ }
141
+ if (state?.status === "failed") {
142
+ // Previous attempt threw. Re-throw the remembered error so downstream
143
+ // callers (scanner / runner / MCP) don't silently proceed with a
144
+ // half-initialized plugin registry.
145
+ throw state.error;
146
+ }
147
+ const url = pathToFileURL(setupFile).href;
148
+ try {
149
+ await import(url);
150
+ loadState.set(setupFile, { status: "ok" });
151
+ }
152
+ catch (err) {
153
+ const error = err instanceof Error ? err : new Error(String(err));
154
+ loadState.set(setupFile, { status: "failed", error });
155
+ throw error;
156
+ }
157
+ }
158
+ /**
159
+ * Test-only: clear the "already bootstrapped" cache so a subsequent
160
+ * `bootstrap()` call will re-import the setup file. Does **not** reset any
161
+ * plugin state on the globals — combine with
162
+ * `__resetInstalledPluginsForTesting()` if you need a full reset.
163
+ *
164
+ * @internal
165
+ */
166
+ export function __resetBootstrapForTesting() {
167
+ loadState.clear();
168
+ }
169
+ //# sourceMappingURL=bootstrap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../src/bootstrap.ts"],"names":[],"mappings":"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"}
package/dist/env.d.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @module env
3
+ *
4
+ * Canonical project-env loader for all Glubean entry points (CLI `run`,
5
+ * MCP tool handlers, VSCode extension, any future consumer).
6
+ *
7
+ * Historical background: CLI and MCP used to each have their own env loader
8
+ * (CLI via `loadEnvFile` without expansion, MCP via a handwritten
9
+ * `parseEnvContent`). Both dropped `${NAME}` expansion from the production
10
+ * path despite `expandVars` being implemented in CLI's shared lib — a silent
11
+ * regression from the original design. This module is the single place all
12
+ * entry points load env files from now on, with full expansion semantics.
13
+ *
14
+ * This lives in `@glubean/runner` because it's tool-level runtime
15
+ * infrastructure (same category as `bootstrap()`), not design-time SDK API.
16
+ */
17
+ /**
18
+ * Load a single `.env`-style file and return its parsed key-value pairs.
19
+ *
20
+ * - File missing (ENOENT) → returns `{}` silently (consumers decide whether
21
+ * missing is an error)
22
+ * - File unreadable (other IO error) → returns `{}` with a warning to stderr
23
+ * - Content is parsed with the standard `dotenv` package (no expansion)
24
+ *
25
+ * This is the low-level primitive. Most callers want
26
+ * {@link loadProjectEnv} instead.
27
+ */
28
+ export declare function loadEnvFile(envPath: string): Promise<Record<string, string>>;
29
+ /**
30
+ * Expand `${NAME}` references in env values.
31
+ *
32
+ * Lookup order per reference:
33
+ * 1. Already-resolved values from this same expansion pass (supports
34
+ * forward references — keys defined earlier in insertion order).
35
+ * 2. `process.env[NAME]` (host environment variables).
36
+ * 3. Empty string fallback.
37
+ *
38
+ * Iteration is insertion order. This means:
39
+ * - Within a single file, a later key can reference earlier keys.
40
+ * - When called on a merged `{ ...vars, ...secrets }` object, secrets-only
41
+ * keys can reference any key from `vars` (because vars keys are inserted
42
+ * first). Vars keys referencing secrets-only keys **will not resolve**
43
+ * in a single pass — they'd need a multi-pass resolver.
44
+ *
45
+ * The multi-pass limitation is accepted — callers who need full
46
+ * topological expansion should use the SDK's `{{NAME}}` template at
47
+ * runtime via `resolveTemplate`, which resolves lazily in test execution
48
+ * context and can pull from vars / secrets / session dynamically.
49
+ */
50
+ export declare function expandVars(vars: Record<string, string>): Record<string, string>;
51
+ /**
52
+ * Result of loading a project's env + secrets, with `${NAME}` references
53
+ * fully expanded.
54
+ *
55
+ * `vars` and `secrets` are kept as separate objects (never merged) so
56
+ * downstream layers can apply redaction / masking to secrets without
57
+ * affecting vars.
58
+ *
59
+ * On key collision between `.env` and `.env.secrets`, the secret wins and
60
+ * the key appears **only** in `secrets`, not in `vars` (no duplication).
61
+ */
62
+ export interface ProjectEnv {
63
+ vars: Record<string, string>;
64
+ secrets: Record<string, string>;
65
+ }
66
+ /**
67
+ * Load a project's `.env` + `.env.secrets` with full `${NAME}` expansion.
68
+ *
69
+ * This is the canonical entry point for loading project env in any Glubean
70
+ * tool. CLI, MCP, and future consumers should all go through this function.
71
+ *
72
+ * ### Behavior
73
+ *
74
+ * 1. Reads `<rootDir>/<envFileName>` (default `.env`) and
75
+ * `<rootDir>/<envFileName>.secrets` (`.env.secrets` by default). Missing
76
+ * files are silently treated as empty.
77
+ * 2. Merges them temporarily (secrets override vars on key collision) and
78
+ * runs {@link expandVars} over the merged set so `${NAME}` references
79
+ * can cross between vars and secrets in either direction (subject to
80
+ * insertion-order single-pass limitation — see `expandVars` docs).
81
+ * 3. Splits the expanded merged map back into `vars` and `secrets`,
82
+ * preserving the invariant: a key on collision appears only in
83
+ * `secrets`, never duplicated into `vars`.
84
+ *
85
+ * ### Naming convention
86
+ *
87
+ * `.env` → `.env.secrets`
88
+ * `.env.staging` → `.env.staging.secrets`
89
+ * `.env.ci` → `.env.ci.secrets`
90
+ *
91
+ * The secrets path is always `<envFileName>.secrets` in the same directory.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * import { loadProjectEnv } from "@glubean/runner";
96
+ *
97
+ * const { vars, secrets } = await loadProjectEnv(projectRoot);
98
+ * const { vars: stagingVars, secrets: stagingSecrets } =
99
+ * await loadProjectEnv(projectRoot, ".env.staging");
100
+ * ```
101
+ */
102
+ export declare function loadProjectEnv(rootDir: string, envFileName?: string): Promise<ProjectEnv>;
103
+ //# sourceMappingURL=env.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAWjC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC3B,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQxB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,MAAM,EACf,WAAW,SAAS,GACnB,OAAO,CAAC,UAAU,CAAC,CA2BrB"}
package/dist/env.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @module env
3
+ *
4
+ * Canonical project-env loader for all Glubean entry points (CLI `run`,
5
+ * MCP tool handlers, VSCode extension, any future consumer).
6
+ *
7
+ * Historical background: CLI and MCP used to each have their own env loader
8
+ * (CLI via `loadEnvFile` without expansion, MCP via a handwritten
9
+ * `parseEnvContent`). Both dropped `${NAME}` expansion from the production
10
+ * path despite `expandVars` being implemented in CLI's shared lib — a silent
11
+ * regression from the original design. This module is the single place all
12
+ * entry points load env files from now on, with full expansion semantics.
13
+ *
14
+ * This lives in `@glubean/runner` because it's tool-level runtime
15
+ * infrastructure (same category as `bootstrap()`), not design-time SDK API.
16
+ */
17
+ import { readFile } from "node:fs/promises";
18
+ import { resolve } from "node:path";
19
+ import { parse as parseDotenv } from "dotenv";
20
+ /**
21
+ * Load a single `.env`-style file and return its parsed key-value pairs.
22
+ *
23
+ * - File missing (ENOENT) → returns `{}` silently (consumers decide whether
24
+ * missing is an error)
25
+ * - File unreadable (other IO error) → returns `{}` with a warning to stderr
26
+ * - Content is parsed with the standard `dotenv` package (no expansion)
27
+ *
28
+ * This is the low-level primitive. Most callers want
29
+ * {@link loadProjectEnv} instead.
30
+ */
31
+ export async function loadEnvFile(envPath) {
32
+ try {
33
+ const content = await readFile(envPath, "utf-8");
34
+ return parseDotenv(content);
35
+ }
36
+ catch (error) {
37
+ if (error.code === "ENOENT") {
38
+ return {};
39
+ }
40
+ console.warn(`Warning: Could not read env file ${envPath}: ${error.message}`);
41
+ return {};
42
+ }
43
+ }
44
+ /**
45
+ * Expand `${NAME}` references in env values.
46
+ *
47
+ * Lookup order per reference:
48
+ * 1. Already-resolved values from this same expansion pass (supports
49
+ * forward references — keys defined earlier in insertion order).
50
+ * 2. `process.env[NAME]` (host environment variables).
51
+ * 3. Empty string fallback.
52
+ *
53
+ * Iteration is insertion order. This means:
54
+ * - Within a single file, a later key can reference earlier keys.
55
+ * - When called on a merged `{ ...vars, ...secrets }` object, secrets-only
56
+ * keys can reference any key from `vars` (because vars keys are inserted
57
+ * first). Vars keys referencing secrets-only keys **will not resolve**
58
+ * in a single pass — they'd need a multi-pass resolver.
59
+ *
60
+ * The multi-pass limitation is accepted — callers who need full
61
+ * topological expansion should use the SDK's `{{NAME}}` template at
62
+ * runtime via `resolveTemplate`, which resolves lazily in test execution
63
+ * context and can pull from vars / secrets / session dynamically.
64
+ */
65
+ export function expandVars(vars) {
66
+ const result = {};
67
+ for (const [key, value] of Object.entries(vars)) {
68
+ result[key] = value.replace(/\$\{(\w+)\}/g, (_, name) => {
69
+ return result[name] ?? process.env[name] ?? "";
70
+ });
71
+ }
72
+ return result;
73
+ }
74
+ /**
75
+ * Load a project's `.env` + `.env.secrets` with full `${NAME}` expansion.
76
+ *
77
+ * This is the canonical entry point for loading project env in any Glubean
78
+ * tool. CLI, MCP, and future consumers should all go through this function.
79
+ *
80
+ * ### Behavior
81
+ *
82
+ * 1. Reads `<rootDir>/<envFileName>` (default `.env`) and
83
+ * `<rootDir>/<envFileName>.secrets` (`.env.secrets` by default). Missing
84
+ * files are silently treated as empty.
85
+ * 2. Merges them temporarily (secrets override vars on key collision) and
86
+ * runs {@link expandVars} over the merged set so `${NAME}` references
87
+ * can cross between vars and secrets in either direction (subject to
88
+ * insertion-order single-pass limitation — see `expandVars` docs).
89
+ * 3. Splits the expanded merged map back into `vars` and `secrets`,
90
+ * preserving the invariant: a key on collision appears only in
91
+ * `secrets`, never duplicated into `vars`.
92
+ *
93
+ * ### Naming convention
94
+ *
95
+ * `.env` → `.env.secrets`
96
+ * `.env.staging` → `.env.staging.secrets`
97
+ * `.env.ci` → `.env.ci.secrets`
98
+ *
99
+ * The secrets path is always `<envFileName>.secrets` in the same directory.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * import { loadProjectEnv } from "@glubean/runner";
104
+ *
105
+ * const { vars, secrets } = await loadProjectEnv(projectRoot);
106
+ * const { vars: stagingVars, secrets: stagingSecrets } =
107
+ * await loadProjectEnv(projectRoot, ".env.staging");
108
+ * ```
109
+ */
110
+ export async function loadProjectEnv(rootDir, envFileName = ".env") {
111
+ const envPath = resolve(rootDir, envFileName);
112
+ const secretsPath = resolve(rootDir, `${envFileName}.secrets`);
113
+ const rawVars = await loadEnvFile(envPath);
114
+ const rawSecrets = await loadEnvFile(secretsPath);
115
+ // Merge for expansion so `${NAME}` can cross-reference both files.
116
+ // `{ ...rawVars, ...rawSecrets }` keeps insertion order — vars keys first,
117
+ // then secrets-only keys — so secrets can reference vars in a single pass.
118
+ const merged = { ...rawVars, ...rawSecrets };
119
+ const expanded = expandVars(merged);
120
+ // Split back. Keys that exist in both files → secret wins, appears only
121
+ // in `secrets` (not duplicated into `vars`).
122
+ const vars = {};
123
+ for (const key of Object.keys(rawVars)) {
124
+ if (!(key in rawSecrets)) {
125
+ vars[key] = expanded[key];
126
+ }
127
+ }
128
+ const secrets = {};
129
+ for (const key of Object.keys(rawSecrets)) {
130
+ secrets[key] = expanded[key];
131
+ }
132
+ return { vars, secrets };
133
+ }
134
+ //# sourceMappingURL=env.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.js","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,KAAK,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AAE9C;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,OAAe;IAEf,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACjD,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;IAC9B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACvD,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,oCAAoC,OAAO,KAAM,KAAe,CAAC,OAAO,EAAE,CAAC,CAAC;QACzF,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,UAAU,CACxB,IAA4B;IAE5B,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC,EAAE,IAAY,EAAE,EAAE;YAC9D,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACjD,CAAC,CAAC,CAAC;IACL,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAkBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,OAAe,EACf,WAAW,GAAG,MAAM;IAEpB,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC;IAC9C,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,EAAE,GAAG,WAAW,UAAU,CAAC,CAAC;IAE/D,MAAM,OAAO,GAAG,MAAM,WAAW,CAAC,OAAO,CAAC,CAAC;IAC3C,MAAM,UAAU,GAAG,MAAM,WAAW,CAAC,WAAW,CAAC,CAAC;IAElD,mEAAmE;IACnE,2EAA2E;IAC3E,2EAA2E;IAC3E,MAAM,MAAM,GAAG,EAAE,GAAG,OAAO,EAAE,GAAG,UAAU,EAAE,CAAC;IAC7C,MAAM,QAAQ,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC;IAEpC,wEAAwE;IACxE,6CAA6C;IAC7C,MAAM,IAAI,GAA2B,EAAE,CAAC;IACxC,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QACvC,IAAI,CAAC,CAAC,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC;YACzB,IAAI,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;QAC5B,CAAC;IACH,CAAC;IACD,MAAM,OAAO,GAA2B,EAAE,CAAC;IAC3C,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;QAC1C,OAAO,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;IAC/B,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAC3B,CAAC"}
package/dist/harness.d.ts CHANGED
@@ -5,18 +5,5 @@
5
5
  * Usage:
6
6
  * tsx harness.ts --testUrl=<url> --testId=<id>
7
7
  */
8
- declare global {
9
- var __glubeanRuntime: {
10
- vars: Record<string, string>;
11
- secrets: Record<string, string>;
12
- session: Record<string, unknown>;
13
- http: Record<string, unknown>;
14
- test: Record<string, unknown>;
15
- trace: (t: import("@glubean/sdk").Trace) => void;
16
- action: (a: import("@glubean/sdk").GlubeanAction) => void;
17
- event: (ev: import("@glubean/sdk").GlubeanEvent) => void;
18
- log: (message: string, data?: unknown) => void;
19
- };
20
- }
21
8
  export {};
22
9
  //# sourceMappingURL=harness.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"harness.d.ts","sourceRoot":"","sources":["../src/harness.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,OAAO,CAAC,MAAM,CAAC;IACb,IAAI,gBAAgB,EAAE;QACpB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAChC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACjC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC9B,KAAK,EAAE,CAAC,CAAC,EAAE,OAAO,cAAc,EAAE,KAAK,KAAK,IAAI,CAAC;QACjD,MAAM,EAAE,CAAC,CAAC,EAAE,OAAO,cAAc,EAAE,aAAa,KAAK,IAAI,CAAC;QAC1D,KAAK,EAAE,CAAC,EAAE,EAAE,OAAO,cAAc,EAAE,YAAY,KAAK,IAAI,CAAC;QACzD,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;KAChD,CAAC;CACH"}
1
+ {"version":3,"file":"harness.d.ts","sourceRoot":"","sources":["../src/harness.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
package/dist/harness.js CHANGED
@@ -8,7 +8,8 @@
8
8
  import { parseArgs } from "node:util";
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
10
  import { inferJsonSchema, truncateDeep } from "./schema_inference.js";
11
- /* eslint-enable no-var */
11
+ import { bootstrap } from "./bootstrap.js";
12
+ import { setRuntime } from "@glubean/sdk/internal";
12
13
  import ky from "ky";
13
14
  import { Expectation } from "@glubean/sdk/expect";
14
15
  // Global error handlers for async errors that escape try/catch
@@ -848,7 +849,30 @@ function normalizeOptions(options) {
848
849
  return normalized;
849
850
  }
850
851
  /**
851
- * Run pre-request schema validations (query params, request body).
852
+ * Normalize a HeadersInit value (Headers instance, plain object, or array of
853
+ * tuples) into a plain Record<string, string> suitable for schema validation.
854
+ */
855
+ function normalizeHeadersForValidation(headers) {
856
+ if (headers == null)
857
+ return {};
858
+ if (typeof Headers !== "undefined" && headers instanceof Headers) {
859
+ return Object.fromEntries(headers.entries());
860
+ }
861
+ if (Array.isArray(headers)) {
862
+ return Object.fromEntries(headers.filter((p) => Array.isArray(p) && p.length === 2));
863
+ }
864
+ if (typeof headers === "object") {
865
+ const out = {};
866
+ for (const [k, v] of Object.entries(headers)) {
867
+ if (v != null)
868
+ out[k] = String(v);
869
+ }
870
+ return out;
871
+ }
872
+ return {};
873
+ }
874
+ /**
875
+ * Run pre-request schema validations (query params, request body, request headers).
852
876
  * Extracts schema option from the options object.
853
877
  */
854
878
  function runPreRequestSchemaValidation(options) {
@@ -865,6 +889,12 @@ function runPreRequestSchemaValidation(options) {
865
889
  const { schema, severity } = resolveSchemaEntry(schemaOpts.request);
866
890
  runSchemaValidation(options.json, schema, "request body", severity);
867
891
  }
892
+ // Validate request headers (per-call only; client-level defaults not included)
893
+ if (schemaOpts.requestHeaders && options?.headers != null) {
894
+ const { schema, severity } = resolveSchemaEntry(schemaOpts.requestHeaders);
895
+ const headers = normalizeHeadersForValidation(options.headers);
896
+ runSchemaValidation(headers, schema, "request headers", severity);
897
+ }
868
898
  }
869
899
  /**
870
900
  * Wrap a ky response promise to run post-response schema validation.
@@ -888,7 +918,7 @@ function wrapKy(instance, label = "base") {
888
918
  const methods = ["get", "post", "put", "patch", "delete", "head"];
889
919
  function callWithSchema(kyFn, input, options) {
890
920
  const normalized = normalizeOptions(options);
891
- // Run pre-request validations (query, request body)
921
+ // Run pre-request validations (query, request body, request headers)
892
922
  runPreRequestSchemaValidation(normalized);
893
923
  // Strip schema option before passing to ky (ky doesn't know about it)
894
924
  let kyOptions;
@@ -899,6 +929,25 @@ function wrapKy(instance, label = "base") {
899
929
  else {
900
930
  kyOptions = normalized;
901
931
  }
932
+ // Inject afterResponse hook for response headers validation (fires once per final response)
933
+ const responseHeadersSchema = normalized?.schema?.responseHeaders;
934
+ if (responseHeadersSchema) {
935
+ const { schema, severity } = resolveSchemaEntry(responseHeadersSchema);
936
+ const headersHook = (_req, _opts, response) => {
937
+ const headersObj = normalizeHeadersForValidation(response.headers);
938
+ runSchemaValidation(headersObj, schema, "response headers", severity);
939
+ };
940
+ kyOptions = {
941
+ ...kyOptions,
942
+ hooks: {
943
+ ...kyOptions?.hooks,
944
+ afterResponse: [
945
+ ...(kyOptions?.hooks?.afterResponse ?? []),
946
+ headersHook,
947
+ ],
948
+ },
949
+ };
950
+ }
902
951
  if (glubeanDebug) {
903
952
  process.stderr.write(`[glubean:debug] ky.call [${label}] url=${String(input)} per-call-options=${JSON.stringify(kyOptions ?? null)}\n`);
904
953
  }
@@ -944,7 +993,7 @@ function withEnvFallback(explicit) {
944
993
  },
945
994
  });
946
995
  }
947
- globalThis.__glubeanRuntime = {
996
+ setRuntime({
948
997
  vars: withEnvFallback(rawVars),
949
998
  secrets: withEnvFallback(rawSecrets),
950
999
  session: sessionData,
@@ -963,8 +1012,14 @@ globalThis.__glubeanRuntime = {
963
1012
  action: ctx.action,
964
1013
  event: ctx.event,
965
1014
  log: ctx.log,
966
- };
1015
+ });
967
1016
  try {
1017
+ // Bootstrap project plugins before loading user code. Without this, any
1018
+ // test module that uses plugin-registered primitives (custom matchers,
1019
+ // `contract.graphql(...)`, `contract.grpc(...)`, ...) would either fail
1020
+ // on first access or silently fall through to the default behavior — see
1021
+ // plugin-manifest-proposal.md D2.
1022
+ await bootstrap(process.cwd());
968
1023
  // Dynamic import - LOAD phase
969
1024
  console.log(JSON.stringify({
970
1025
  type: "log",
@@ -1222,7 +1277,8 @@ async function executeNewTest(test) {
1222
1277
  // downstream code (ctx.assert, ky hooks, globalFetch) can access
1223
1278
  // the per-test state via currentTestCtx().
1224
1279
  await testContext.run(trc, async () => {
1225
- // Runtime metadata is now served via ALS getter on __glubeanRuntime.test
1280
+ // Runtime metadata is served via the `test` getter on the carrier runtime,
1281
+ // which reads from TestRunContext ALS (see setRuntime() call above).
1226
1282
  emitEvent({
1227
1283
  type: "start",
1228
1284
  id: test.meta.id,