@mccaskie-labs/workbench-config 0.0.0-stage → 0.1.1

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,193 @@
1
+ import { z } from "zod";
2
+ export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "access", "r2", "kv", "github"];
3
+ export type Component = (typeof COMPONENTS)[number];
4
+ export declare const allowedSchemas: {
5
+ gateway: z.ZodObject<{
6
+ scopes: z.ZodArray<z.ZodEnum<{
7
+ ai: "ai";
8
+ email: "email";
9
+ images: "images";
10
+ search: "search";
11
+ }>>;
12
+ key_environments: z.ZodArray<z.ZodObject<{
13
+ environment: z.ZodEnum<{
14
+ development: "development";
15
+ production: "production";
16
+ staging: "staging";
17
+ }>;
18
+ prefix: z.ZodString;
19
+ }, z.core.$strict>>;
20
+ request_headers: z.ZodArray<z.ZodObject<{
21
+ name: z.ZodString;
22
+ required: z.ZodBoolean;
23
+ pattern: z.ZodOptional<z.ZodString>;
24
+ description: z.ZodString;
25
+ }, z.core.$strict>>;
26
+ response_headers: z.ZodArray<z.ZodString>;
27
+ server_timing_metrics: z.ZodArray<z.ZodString>;
28
+ }, z.core.$strict>;
29
+ ai: z.ZodObject<{
30
+ aliases: z.ZodArray<z.ZodObject<{
31
+ alias: z.ZodString;
32
+ provider: z.ZodEnum<{
33
+ anthropic: "anthropic";
34
+ google: "google";
35
+ openai: "openai";
36
+ }>;
37
+ default_model: z.ZodString;
38
+ }, z.core.$strict>>;
39
+ explicit_models: z.ZodArray<z.ZodString>;
40
+ server_tools: z.ZodArray<z.ZodString>;
41
+ required_headers: z.ZodArray<z.ZodString>;
42
+ }, z.core.$strict>;
43
+ config: z.ZodObject<{
44
+ environments: z.ZodArray<z.ZodEnum<{
45
+ development: "development";
46
+ production: "production";
47
+ staging: "staging";
48
+ }>>;
49
+ }, z.core.$strict>;
50
+ };
51
+ /** One file in `capabilities/`. Parsing checks shape only; cross-references are checked by `checkManifests`. */
52
+ export declare const capabilitySchema: z.ZodObject<{
53
+ $schema: z.ZodLiteral<"./schema.json">;
54
+ format: z.ZodLiteral<1>;
55
+ component: z.ZodEnum<{
56
+ access: "access";
57
+ ai: "ai";
58
+ analytics: "analytics";
59
+ config: "config";
60
+ database: "database";
61
+ dns: "dns";
62
+ domains: "domains";
63
+ email: "email";
64
+ "email-inbound": "email-inbound";
65
+ "email-sending": "email-sending";
66
+ gateway: "gateway";
67
+ github: "github";
68
+ "image-search": "image-search";
69
+ images: "images";
70
+ kv: "kv";
71
+ r2: "r2";
72
+ workers: "workers";
73
+ }>;
74
+ title: z.ZodString;
75
+ summary: z.ZodString;
76
+ status: z.ZodEnum<{
77
+ built: "built";
78
+ deprecated: "deprecated";
79
+ planned: "planned";
80
+ }>;
81
+ surfaces: z.ZodObject<{
82
+ gateway: z.ZodOptional<z.ZodArray<z.ZodString>>;
83
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
84
+ package: z.ZodOptional<z.ZodArray<z.ZodString>>;
85
+ }, z.core.$strict>;
86
+ config: z.ZodOptional<z.ZodArray<z.ZodObject<{
87
+ name: z.ZodString;
88
+ required: z.ZodBoolean;
89
+ secret: z.ZodBoolean;
90
+ default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
91
+ environments: z.ZodArray<z.ZodEnum<{
92
+ development: "development";
93
+ production: "production";
94
+ staging: "staging";
95
+ }>>;
96
+ description: z.ZodString;
97
+ }, z.core.$strict>>>;
98
+ allowed: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
99
+ limits: z.ZodOptional<z.ZodArray<z.ZodObject<{
100
+ id: z.ZodString;
101
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
102
+ unit: z.ZodString;
103
+ per: z.ZodString;
104
+ enforced_by: z.ZodEnum<{
105
+ control: "control";
106
+ database: "database";
107
+ gateway: "gateway";
108
+ types: "types";
109
+ }>;
110
+ refusal: z.ZodOptional<z.ZodString>;
111
+ }, z.core.$strict>>>;
112
+ actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
113
+ tool: z.ZodString;
114
+ gate: z.ZodEnum<{
115
+ free: "free";
116
+ two_step: "two_step";
117
+ two_step_on_production: "two_step_on_production";
118
+ two_step_when: "two_step_when";
119
+ }>;
120
+ when: z.ZodOptional<z.ZodString>;
121
+ ships: z.ZodEnum<{
122
+ 3: "3";
123
+ 4: "4";
124
+ later: "later";
125
+ }>;
126
+ }, z.core.$strict>>>;
127
+ errors: z.ZodOptional<z.ZodArray<z.ZodObject<{
128
+ code: z.ZodString;
129
+ http: z.ZodNumber;
130
+ retryable: z.ZodBoolean;
131
+ meaning: z.ZodString;
132
+ }, z.core.$strict>>>;
133
+ rules: z.ZodArray<z.ZodObject<{
134
+ id: z.ZodString;
135
+ kind: z.ZodEnum<{
136
+ must: "must";
137
+ must_not: "must_not";
138
+ }>;
139
+ text: z.ZodString;
140
+ enforced_by: z.ZodEnum<{
141
+ control: "control";
142
+ database: "database";
143
+ gateway: "gateway";
144
+ review: "review";
145
+ types: "types";
146
+ }>;
147
+ probe: z.ZodString;
148
+ }, z.core.$strict>>;
149
+ examples: z.ZodOptional<z.ZodArray<z.ZodObject<{
150
+ id: z.ZodString;
151
+ title: z.ZodString;
152
+ lang: z.ZodEnum<{
153
+ js: "js";
154
+ json: "json";
155
+ ps1: "ps1";
156
+ sh: "sh";
157
+ sql: "sql";
158
+ ts: "ts";
159
+ }>;
160
+ code: z.ZodString;
161
+ probe: z.ZodString;
162
+ }, z.core.$strict>>>;
163
+ probes: z.ZodArray<z.ZodObject<{
164
+ id: z.ZodString;
165
+ half: z.ZodEnum<{
166
+ drill: "drill";
167
+ runtime: "runtime";
168
+ }>;
169
+ expect: z.ZodEnum<{
170
+ pass: "pass";
171
+ refused_as_expected: "refused_as_expected";
172
+ }>;
173
+ refusal: z.ZodOptional<z.ZodString>;
174
+ smoke: z.ZodOptional<z.ZodBoolean>;
175
+ cost_hint_usd: z.ZodOptional<z.ZodNumber>;
176
+ two_step: z.ZodOptional<z.ZodBoolean>;
177
+ note: z.ZodOptional<z.ZodString>;
178
+ }, z.core.$strict>>;
179
+ }, z.core.$strict>;
180
+ export type Capability = z.infer<typeof capabilitySchema>;
181
+ export interface ManifestProblem {
182
+ file: string;
183
+ check: "schema" | "proof" | "ids" | "surfaces" | "errors" | "generated";
184
+ message: string;
185
+ }
186
+ /**
187
+ * The checks that need only the manifests themselves (§5: schema, proof, ids).
188
+ * `files` maps a file name (e.g. `ai.json`) to its parsed JSON.
189
+ */
190
+ export declare function checkManifests(files: Record<string, unknown>): {
191
+ manifests: Capability[];
192
+ problems: ManifestProblem[];
193
+ };
@@ -0,0 +1,217 @@
1
+ // The capabilities manifest schema (docs/capabilities-manifest.md §3). One
2
+ // parser shared by the packages, the Gateway, Control and the checks.
3
+ // capabilities/schema.json is generated from it by `npm run capabilities:build`.
4
+ import { z } from "zod";
5
+ export const COMPONENTS = [
6
+ "gateway",
7
+ "ai",
8
+ "images",
9
+ "image-search",
10
+ "config",
11
+ "email",
12
+ "email-inbound",
13
+ "email-sending",
14
+ "database",
15
+ "workers",
16
+ "domains",
17
+ "dns",
18
+ "analytics",
19
+ "access",
20
+ "r2",
21
+ "kv",
22
+ "github",
23
+ ];
24
+ const id = z.string().regex(/^[a-z0-9][a-z0-9.-]*$/, "lower case, digits, dots and hyphens");
25
+ const code = z.string().regex(/^[a-z][a-z0-9_]*$/, "a snake_case error code");
26
+ const envName = z.enum(["development", "staging", "production"]);
27
+ const enforcedBy = z.enum(["types", "gateway", "control", "database", "review"]);
28
+ const configVariable = z.strictObject({
29
+ name: z.string().regex(/^[A-Z][A-Z0-9_]*$/, "an UPPER_SNAKE variable name"),
30
+ required: z.boolean(),
31
+ secret: z.boolean(),
32
+ default: z.union([z.string(), z.number(), z.boolean()]).optional(),
33
+ environments: z.array(envName).min(1),
34
+ description: z.string().min(1),
35
+ });
36
+ const limit = z.strictObject({
37
+ id,
38
+ value: z.union([z.string(), z.number()]),
39
+ unit: z.string().min(1),
40
+ per: z.string().min(1),
41
+ enforced_by: enforcedBy.exclude(["review"]),
42
+ refusal: code.optional(),
43
+ });
44
+ const action = z.strictObject({
45
+ tool: z.string().regex(/^[a-z]+_[a-z0-9_]+$/),
46
+ gate: z.enum(["free", "two_step", "two_step_on_production", "two_step_when"]),
47
+ when: z.string().min(1).optional(),
48
+ ships: z.enum(["3", "4", "later"]),
49
+ });
50
+ const errorEntry = z.strictObject({
51
+ code,
52
+ http: z.number().int().min(400).max(599),
53
+ retryable: z.boolean(),
54
+ meaning: z.string().min(1),
55
+ });
56
+ const rule = z.strictObject({
57
+ id,
58
+ kind: z.enum(["must", "must_not"]),
59
+ text: z.string().min(1),
60
+ enforced_by: enforcedBy,
61
+ probe: id,
62
+ });
63
+ const example = z.strictObject({
64
+ id,
65
+ title: z.string().min(1),
66
+ lang: z.enum(["ts", "js", "sh", "ps1", "json", "sql"]),
67
+ code: z.string().min(1),
68
+ probe: id,
69
+ });
70
+ const probe = z.strictObject({
71
+ id,
72
+ half: z.enum(["runtime", "drill"]),
73
+ expect: z.enum(["pass", "refused_as_expected"]),
74
+ refusal: code.optional(),
75
+ smoke: z.boolean().optional(),
76
+ cost_hint_usd: z.number().min(0).optional(),
77
+ two_step: z.boolean().optional(),
78
+ note: z.string().min(1).optional(),
79
+ });
80
+ // `allowed` is typed per component (§3, §4). A component with no schema here
81
+ // can't carry an `allowed` section until one is added.
82
+ const aliasEntry = z.strictObject({
83
+ alias: z.string().regex(/^[a-z][a-z0-9-]*$/),
84
+ provider: z.enum(["anthropic", "google", "openai"]),
85
+ default_model: z.string().min(1),
86
+ });
87
+ export const allowedSchemas = {
88
+ gateway: z.strictObject({
89
+ scopes: z.array(z.enum(["ai", "images", "search", "email"])).min(1),
90
+ key_environments: z.array(z.strictObject({ environment: envName, prefix: z.string().regex(/^wbk_[a-z]{3}_$/) })).min(1),
91
+ request_headers: z
92
+ .array(z.strictObject({ name: z.string().min(1), required: z.boolean(), pattern: z.string().optional(), description: z.string().min(1) }))
93
+ .min(1),
94
+ response_headers: z.array(z.string().min(1)).min(1),
95
+ server_timing_metrics: z.array(z.string().regex(/^[a-z_]+$/)).min(1),
96
+ }),
97
+ ai: z.strictObject({
98
+ aliases: z.array(aliasEntry).min(1),
99
+ explicit_models: z.array(z.string().min(1)),
100
+ server_tools: z.array(z.string().min(1)),
101
+ required_headers: z.array(z.string().min(1)),
102
+ }),
103
+ config: z.strictObject({
104
+ environments: z.array(envName).min(1),
105
+ }),
106
+ };
107
+ const baseShape = {
108
+ $schema: z.literal("./schema.json"),
109
+ format: z.literal(1),
110
+ component: z.enum(COMPONENTS),
111
+ title: z.string().min(1),
112
+ summary: z.string().min(1),
113
+ status: z.enum(["planned", "built", "deprecated"]),
114
+ surfaces: z
115
+ .strictObject({
116
+ gateway: z.array(z.string().regex(/^(GET|POST|PUT|PATCH|DELETE) \/\S*$/)).min(1).optional(),
117
+ tools: z.array(z.string().regex(/^[a-z]+_[a-z0-9_]+$/)).min(1).optional(),
118
+ package: z.array(z.string().min(1)).min(1).optional(),
119
+ })
120
+ .refine((s) => s.gateway || s.tools || s.package, "name at least one surface"),
121
+ config: z.array(configVariable).min(1).optional(),
122
+ allowed: z.record(z.string(), z.unknown()).optional(),
123
+ limits: z.array(limit).min(1).optional(),
124
+ actions: z.array(action).min(1).optional(),
125
+ errors: z.array(errorEntry).min(1).optional(),
126
+ rules: z.array(rule).min(1),
127
+ examples: z.array(example).min(1).optional(),
128
+ probes: z.array(probe).min(1),
129
+ };
130
+ /** One file in `capabilities/`. Parsing checks shape only; cross-references are checked by `checkManifests`. */
131
+ export const capabilitySchema = z.strictObject(baseShape).superRefine((m, ctx) => {
132
+ if (m.allowed === undefined)
133
+ return;
134
+ const schema = allowedSchemas[m.component];
135
+ if (!schema) {
136
+ ctx.addIssue({ code: "custom", path: ["allowed"], message: `component ${m.component} has no allowed schema yet` });
137
+ return;
138
+ }
139
+ const result = schema.safeParse(m.allowed);
140
+ if (!result.success) {
141
+ for (const issue of result.error.issues) {
142
+ ctx.addIssue({ code: "custom", path: ["allowed", ...issue.path.map((p) => (typeof p === "symbol" ? String(p) : p))], message: issue.message });
143
+ }
144
+ }
145
+ });
146
+ /**
147
+ * The checks that need only the manifests themselves (§5: schema, proof, ids).
148
+ * `files` maps a file name (e.g. `ai.json`) to its parsed JSON.
149
+ */
150
+ export function checkManifests(files) {
151
+ const problems = [];
152
+ const manifests = [];
153
+ const globalIds = new Map();
154
+ for (const [file, json] of Object.entries(files)) {
155
+ const parsed = capabilitySchema.safeParse(json);
156
+ if (!parsed.success) {
157
+ for (const issue of parsed.error.issues) {
158
+ problems.push({ file, check: "schema", message: `${issue.path.join(".") || "(root)"}: ${issue.message}` });
159
+ }
160
+ continue;
161
+ }
162
+ const m = parsed.data;
163
+ manifests.push(m);
164
+ if (file !== `${m.component}.json`) {
165
+ problems.push({ file, check: "schema", message: `file name must be ${m.component}.json` });
166
+ }
167
+ const probeIds = new Set();
168
+ const seen = (kind, value) => {
169
+ const prior = globalIds.get(value);
170
+ if (prior)
171
+ problems.push({ file, check: "ids", message: `${kind} id ${value} is already used (${prior})` });
172
+ else
173
+ globalIds.set(value, `${kind} in ${file}`);
174
+ };
175
+ for (const p of m.probes) {
176
+ seen("probe", p.id);
177
+ probeIds.add(p.id);
178
+ if (!p.id.startsWith(`${m.component}.`)) {
179
+ problems.push({ file, check: "ids", message: `probe ${p.id} must start with ${m.component}.` });
180
+ }
181
+ if (p.expect === "refused_as_expected" && !p.refusal) {
182
+ problems.push({ file, check: "proof", message: `probe ${p.id} expects a refusal but names no refusal code` });
183
+ }
184
+ }
185
+ for (const r of m.rules)
186
+ seen("rule", r.id);
187
+ for (const l of m.limits ?? [])
188
+ seen("limit", l.id);
189
+ for (const e of m.examples ?? [])
190
+ seen("example", e.id);
191
+ for (const r of m.rules) {
192
+ if (!probeIds.has(r.probe))
193
+ problems.push({ file, check: "proof", message: `rule ${r.id} names probe ${r.probe}, which isn't in this file` });
194
+ }
195
+ for (const e of m.examples ?? []) {
196
+ if (!probeIds.has(e.probe))
197
+ problems.push({ file, check: "proof", message: `example ${e.id} names probe ${e.probe}, which isn't in this file` });
198
+ }
199
+ const errorCodes = new Set();
200
+ for (const e of m.errors ?? []) {
201
+ if (errorCodes.has(e.code))
202
+ problems.push({ file, check: "ids", message: `error code ${e.code} is listed twice` });
203
+ errorCodes.add(e.code);
204
+ }
205
+ const names = new Set();
206
+ for (const v of m.config ?? []) {
207
+ if (names.has(v.name))
208
+ problems.push({ file, check: "ids", message: `config variable ${v.name} is listed twice` });
209
+ names.add(v.name);
210
+ }
211
+ for (const a of m.actions ?? []) {
212
+ if (a.gate === "two_step_when" && !a.when)
213
+ problems.push({ file, check: "schema", message: `action ${a.tool} is two_step_when but has no when` });
214
+ }
215
+ }
216
+ return { manifests, problems };
217
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // `npx workbench-config check [dir]`: compares the variable names in
3
+ // <dir>/.dev.vars with <dir>/.dev.vars.example. Prints names only.
4
+ import { readFileSync, existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { compareDevVars } from "./dev-vars.js";
7
+ function main(argv) {
8
+ const [command, dir = "."] = argv;
9
+ if (command !== "check") {
10
+ console.error("Usage: workbench-config check [dir]");
11
+ return 2;
12
+ }
13
+ const examplePath = join(dir, ".dev.vars.example");
14
+ const devVarsPath = join(dir, ".dev.vars");
15
+ if (!existsSync(examplePath)) {
16
+ console.error(`No .dev.vars.example in ${dir}`);
17
+ return 1;
18
+ }
19
+ if (!existsSync(devVarsPath)) {
20
+ console.error(`No .dev.vars in ${dir}. Copy .dev.vars.example to .dev.vars and fill it in.`);
21
+ return 1;
22
+ }
23
+ const result = compareDevVars(readFileSync(devVarsPath, "utf8"), readFileSync(examplePath, "utf8"));
24
+ if (result.ok) {
25
+ console.log(".dev.vars has every variable in .dev.vars.example, and no others.");
26
+ return 0;
27
+ }
28
+ if (result.missing.length)
29
+ console.error(`Missing from .dev.vars: ${result.missing.join(", ")}`);
30
+ if (result.extra.length)
31
+ console.error(`In .dev.vars but not in .dev.vars.example: ${result.extra.join(", ")}`);
32
+ return 1;
33
+ }
34
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ export interface Config<S extends z.ZodRawShape> {
3
+ /** The variable names this config reads. */
4
+ readonly variables: readonly (keyof S & string)[];
5
+ /**
6
+ * Validates the variables named in the shape and returns them typed.
7
+ * Throws `WorkbenchConfigError` naming every bad variable at once. The
8
+ * message never includes a value, and zod's own messages (which can) are
9
+ * not used.
10
+ */
11
+ parse(env: Record<string, unknown>): z.infer<z.ZodObject<S>>;
12
+ }
13
+ /**
14
+ * Declares the variables an app needs, as a zod shape:
15
+ *
16
+ * const config = defineConfig({ WORKBENCH_GATEWAY_KEY: z.string(), SITE_NAME: z.string().default('Probe') })
17
+ * const { WORKBENCH_GATEWAY_KEY } = config.parse(env)
18
+ *
19
+ * An empty string counts as missing, so a blank `.dev.vars` line fails by name.
20
+ */
21
+ export declare function defineConfig<S extends z.ZodRawShape>(shape: S): Config<S>;
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ import { WorkbenchConfigError } from "./errors.js";
3
+ /**
4
+ * Declares the variables an app needs, as a zod shape:
5
+ *
6
+ * const config = defineConfig({ WORKBENCH_GATEWAY_KEY: z.string(), SITE_NAME: z.string().default('Probe') })
7
+ * const { WORKBENCH_GATEWAY_KEY } = config.parse(env)
8
+ *
9
+ * An empty string counts as missing, so a blank `.dev.vars` line fails by name.
10
+ */
11
+ export function defineConfig(shape) {
12
+ const schema = z.object(shape);
13
+ const variables = Object.keys(shape);
14
+ return {
15
+ variables,
16
+ parse(env) {
17
+ const input = {};
18
+ for (const name of variables) {
19
+ const value = env[name];
20
+ if (value !== undefined && value !== null && value !== "")
21
+ input[name] = value;
22
+ }
23
+ const result = schema.safeParse(input);
24
+ if (result.success)
25
+ return result.data;
26
+ const problems = new Map();
27
+ for (const issue of result.error.issues) {
28
+ const variable = String(issue.path[0] ?? "(config)");
29
+ if (problems.has(variable))
30
+ continue;
31
+ const absent = !(variable in input);
32
+ problems.set(variable, absent ? { variable, problem: "missing" } : { variable, problem: "invalid", detail: detailFor(issue) });
33
+ }
34
+ const list = [...problems.values()];
35
+ const code = list.every((p) => p.problem === "missing") ? "missing_variable" : "invalid_variable";
36
+ throw new WorkbenchConfigError(code, list);
37
+ },
38
+ };
39
+ }
40
+ // A description of the issue built only from its kind and the schema's
41
+ // expectations, never from the input.
42
+ function detailFor(issue) {
43
+ switch (issue.code) {
44
+ case "invalid_type":
45
+ return `expected ${issue.expected}`;
46
+ case "invalid_value":
47
+ return "not an allowed value";
48
+ case "invalid_format":
49
+ return `expected format ${issue.format}`;
50
+ case "too_small":
51
+ return "too short or too small";
52
+ case "too_big":
53
+ return "too long or too large";
54
+ default:
55
+ return issue.code.replace(/_/g, " ");
56
+ }
57
+ }
@@ -0,0 +1,10 @@
1
+ /** The variable names in a dotenv-style file, in order, without values. */
2
+ export declare function parseDevVarNames(text: string): string[];
3
+ export interface DevVarsComparison {
4
+ /** In the example but not in `.dev.vars`. */
5
+ missing: string[];
6
+ /** In `.dev.vars` but not in the example (undocumented). */
7
+ extra: string[];
8
+ ok: boolean;
9
+ }
10
+ export declare function compareDevVars(devVars: string, example: string): DevVarsComparison;
@@ -0,0 +1,23 @@
1
+ // Compares the variable names in `.dev.vars` with `.dev.vars.example`
2
+ // (audit F6). Works on text so it runs anywhere; the CLI reads the files.
3
+ // Only names are ever returned or printed, never values.
4
+ /** The variable names in a dotenv-style file, in order, without values. */
5
+ export function parseDevVarNames(text) {
6
+ const names = [];
7
+ for (const raw of text.split(/\r?\n/)) {
8
+ const line = raw.trim();
9
+ if (line === "" || line.startsWith("#"))
10
+ continue;
11
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
12
+ if (match?.[1] && !names.includes(match[1]))
13
+ names.push(match[1]);
14
+ }
15
+ return names;
16
+ }
17
+ export function compareDevVars(devVars, example) {
18
+ const have = new Set(parseDevVarNames(devVars));
19
+ const want = new Set(parseDevVarNames(example));
20
+ const missing = [...want].filter((n) => !have.has(n));
21
+ const extra = [...have].filter((n) => !want.has(n));
22
+ return { missing, extra, ok: missing.length === 0 && extra.length === 0 };
23
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export declare const WORKBENCH_ENVS: readonly ["development", "staging", "production"];
2
+ export type WorkbenchEnv = (typeof WORKBENCH_ENVS)[number];
3
+ export declare const DEFAULT_GATEWAY_URL = "https://gateway.mccaskielabs.dev";
4
+ /**
5
+ * Reads `WORKBENCH_ENV`. Throws if it is missing or not one of the three
6
+ * environments. Never falls back to `NODE_ENV` or a default (audit F9, F18).
7
+ */
8
+ export declare function workbenchEnv(env: Record<string, unknown>): WorkbenchEnv;
package/dist/env.js ADDED
@@ -0,0 +1,16 @@
1
+ import { invalid, missing } from "./errors.js";
2
+ export const WORKBENCH_ENVS = ["development", "staging", "production"];
3
+ export const DEFAULT_GATEWAY_URL = "https://gateway.mccaskielabs.dev";
4
+ /**
5
+ * Reads `WORKBENCH_ENV`. Throws if it is missing or not one of the three
6
+ * environments. Never falls back to `NODE_ENV` or a default (audit F9, F18).
7
+ */
8
+ export function workbenchEnv(env) {
9
+ const value = env["WORKBENCH_ENV"];
10
+ if (value === undefined || value === null || value === "")
11
+ throw missing("WORKBENCH_ENV");
12
+ if (typeof value !== "string" || !WORKBENCH_ENVS.includes(value)) {
13
+ throw invalid("WORKBENCH_ENV", `expected one of ${WORKBENCH_ENVS.join(", ")}`);
14
+ }
15
+ return value;
16
+ }
@@ -0,0 +1,17 @@
1
+ export type ConfigErrorCode = "missing_variable" | "invalid_variable" | "invalid_config";
2
+ export interface ConfigProblem {
3
+ /** The variable's name, e.g. `WORKBENCH_GATEWAY_KEY`. */
4
+ variable: string;
5
+ problem: "missing" | "invalid";
6
+ /** What was wrong, in words that never include the value (e.g. "expected one of development, staging, production"). */
7
+ detail?: string;
8
+ }
9
+ export declare class WorkbenchConfigError extends Error {
10
+ readonly code: ConfigErrorCode;
11
+ readonly problems: readonly ConfigProblem[];
12
+ constructor(code: ConfigErrorCode, problems: readonly ConfigProblem[]);
13
+ /** The names of every variable that was missing or invalid. */
14
+ get variables(): string[];
15
+ }
16
+ export declare function missing(variable: string, detail?: string): WorkbenchConfigError;
17
+ export declare function invalid(variable: string, detail?: string): WorkbenchConfigError;
package/dist/errors.js ADDED
@@ -0,0 +1,26 @@
1
+ // Errors from workbench-config name the variable and the problem, never the value
2
+ // (docs/spec.md §3, audit F6). Nothing here keeps the input as a cause.
3
+ export class WorkbenchConfigError extends Error {
4
+ code;
5
+ problems;
6
+ constructor(code, problems) {
7
+ super(describe(problems));
8
+ this.name = "WorkbenchConfigError";
9
+ this.code = code;
10
+ this.problems = problems;
11
+ }
12
+ /** The names of every variable that was missing or invalid. */
13
+ get variables() {
14
+ return this.problems.map((p) => p.variable);
15
+ }
16
+ }
17
+ function describe(problems) {
18
+ const parts = problems.map((p) => `${p.variable} is ${p.problem}${p.detail ? ` (${p.detail})` : ""}`);
19
+ return `Workbench config: ${parts.join("; ")}`;
20
+ }
21
+ export function missing(variable, detail) {
22
+ return new WorkbenchConfigError("missing_variable", [{ variable, problem: "missing", ...(detail ? { detail } : {}) }]);
23
+ }
24
+ export function invalid(variable, detail) {
25
+ return new WorkbenchConfigError("invalid_variable", [{ variable, problem: "invalid", ...(detail ? { detail } : {}) }]);
26
+ }
@@ -0,0 +1,7 @@
1
+ export { WORKBENCH_ENVS, DEFAULT_GATEWAY_URL, workbenchEnv, type WorkbenchEnv } from "./env.ts";
2
+ export { defineConfig, type Config } from "./define-config.ts";
3
+ export { sharedSecret, workerSecret, redact, redactAll, type SecretsStoreSecretLike } from "./secrets.ts";
4
+ export { WorkbenchConfigError, type ConfigErrorCode, type ConfigProblem } from "./errors.ts";
5
+ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capability, type Component, type ManifestProblem, } from "./capability-schema.ts";
6
+ export { capabilities, MANIFEST_VERSION, type Alias, type GatewayScope } from "./capabilities.gen.ts";
7
+ export { compareDevVars, parseDevVarNames } from "./dev-vars.ts";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { WORKBENCH_ENVS, DEFAULT_GATEWAY_URL, workbenchEnv } from "./env.js";
2
+ export { defineConfig } from "./define-config.js";
3
+ export { sharedSecret, workerSecret, redact, redactAll } from "./secrets.js";
4
+ export { WorkbenchConfigError } from "./errors.js";
5
+ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, } from "./capability-schema.js";
6
+ export { capabilities, MANIFEST_VERSION } from "./capabilities.gen.js";
7
+ export { compareDevVars, parseDevVarNames } from "./dev-vars.js";
@@ -0,0 +1,19 @@
1
+ /** The part of a Secrets Store binding that this package uses. */
2
+ export interface SecretsStoreSecretLike {
3
+ get(): Promise<string>;
4
+ }
5
+ /**
6
+ * Reads a Secrets Store binding (a secret shared between Workers). Throws if
7
+ * the value is empty. `name` is only used to name the binding in the error.
8
+ */
9
+ export declare function sharedSecret(binding: SecretsStoreSecretLike, name?: string): Promise<string>;
10
+ /** Reads a Worker secret or variable by name. Throws, naming it, if it is missing or empty. */
11
+ export declare function workerSecret(env: Record<string, unknown>, name: string): string;
12
+ /**
13
+ * Replaces a secret for logs. Always `[redacted]`, whatever the value: no
14
+ * prefix, suffix or length is kept, so a log line can never carry part of it.
15
+ * `redactAll` does the same for every occurrence inside a longer string.
16
+ */
17
+ export declare function redact(_value: string): string;
18
+ /** Replaces every occurrence of each secret in `text` with `[redacted]`. Empty secrets are ignored. */
19
+ export declare function redactAll(text: string, secrets: readonly string[]): string;