@specbook/vite 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,81 +1,185 @@
1
1
  // src/index.ts
2
2
  import { existsSync as existsSync3, readdirSync } from "fs";
3
- import { mkdir as mkdir2, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
3
+ import { mkdir as mkdir2, readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
4
4
  import { createRequire } from "module";
5
- import { basename, dirname, join as join4, relative as relative4, resolve as resolve2, sep as sep4 } from "path";
6
- import fg4 from "fast-glob";
5
+ import { basename, dirname, join as join5, relative as relative4, resolve as resolve2, sep as sep4 } from "path";
7
6
 
8
- // src/docs.ts
9
- import { readFile } from "fs/promises";
10
- import { relative, sep } from "path";
11
- import fg from "fast-glob";
12
- import { marked } from "marked";
13
- function frontmatter(src) {
14
- const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
15
- if (!m) return { meta: {}, body: src };
16
- const meta = {};
17
- for (const line of m[1].split(/\r?\n/)) {
18
- const i = line.indexOf(":");
19
- if (i > 0)
20
- meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
7
+ // ../core/src/faker.ts
8
+ var KEY = "__specbookFaker";
9
+ var store = globalThis;
10
+ var faker = new Proxy({}, {
11
+ get(_t, key) {
12
+ const current = store[KEY];
13
+ if (!current) {
14
+ throw new Error(
15
+ 'faker is not initialised. Install "@faker-js/faker" in your project; specbook sets it up before each run.'
16
+ );
17
+ }
18
+ return current[key];
21
19
  }
22
- return { meta, body: src.slice(m[0].length) };
20
+ });
21
+
22
+ // ../core/src/template.ts
23
+ var WHOLE = /^\s*\{\{\s*([\w.$-]+)\s*\}\}\s*$/;
24
+ var EMBED = /\{\{\s*([\w.$-]+)\s*\}\}/g;
25
+ var DEFERRED_NAMESPACES = ["secrets", "session"];
26
+ var TemplateError = class extends Error {
27
+ constructor(variable) {
28
+ super(`Missing variable "{{${variable}}}"`);
29
+ this.variable = variable;
30
+ this.name = "TemplateError";
31
+ }
32
+ variable;
33
+ };
34
+ function isDeferred(name) {
35
+ const ns = name.split(".")[0];
36
+ return DEFERRED_NAMESPACES.includes(ns) && name.includes(".");
23
37
  }
24
- var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
25
- var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
26
- var NOT_AN_OPERATION = /^(readme|index)$/i;
27
- function colocated(file) {
28
- if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
29
- if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
30
- const folder = OP_FOLDER.exec(file);
31
- if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
32
- const flat = OP_FLAT.exec(file);
33
- if (flat && !NOT_AN_OPERATION.test(flat[2]))
34
- return { operation: `${flat[1]}/${flat[2]}` };
35
- return void 0;
38
+ var isFakerName = (name) => name.startsWith("faker.");
39
+ function fromFaker(path) {
40
+ let parent = faker;
41
+ let cur = faker;
42
+ for (const part of path.split(".").slice(1)) {
43
+ parent = cur;
44
+ cur = cur?.[part];
45
+ if (cur === void 0) return { found: false };
46
+ }
47
+ return { found: true, value: typeof cur === "function" ? cur.call(parent) : cur };
36
48
  }
37
- async function scanDocs(root) {
38
- const files = await fg("**/*.md", {
39
- cwd: root,
40
- absolute: true,
41
- ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
42
- });
43
- const pages = [];
44
- const claimed = /* @__PURE__ */ new Map();
45
- for (const abs of files.sort()) {
46
- const { meta, body } = frontmatter(await readFile(abs, "utf8"));
47
- const file = relative(root, abs).split(sep).join("/");
48
- const near = colocated(file);
49
- if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
50
- const operation = meta.operation ?? near?.operation;
51
- if (operation) {
52
- const first = claimed.get(operation);
53
- if (first) {
54
- console.warn(
55
- `[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
56
- );
57
- continue;
58
- }
59
- claimed.set(operation, file);
49
+ function lookup(scope, path) {
50
+ if (isFakerName(path)) return fromFaker(path);
51
+ let cur = scope;
52
+ for (const part of path.split(".")) {
53
+ if (cur !== null && typeof cur === "object" && part in cur) {
54
+ cur = cur[part];
55
+ } else {
56
+ return { found: false };
60
57
  }
61
- pages.push({
62
- file,
63
- operation,
64
- sequence: meta.sequence,
65
- sequenceFile: near?.sequenceFile,
66
- requestFile: near?.requestFile,
67
- replace: meta.replace === "true" || void 0,
68
- title: meta.title ?? meta.page,
69
- html: marked.parse(body, { async: false })
70
- });
71
58
  }
72
- return pages;
59
+ return { found: true, value: cur };
60
+ }
61
+ function renderString(s, scope) {
62
+ const whole = WHOLE.exec(s);
63
+ if (whole) {
64
+ const name = whole[1];
65
+ if (isDeferred(name)) return s;
66
+ const r = lookup(scope, name);
67
+ if (!r.found) throw new TemplateError(name);
68
+ return r.value;
69
+ }
70
+ return s.replace(EMBED, (m, name) => {
71
+ if (isDeferred(name)) return m;
72
+ const r = lookup(scope, name);
73
+ if (!r.found) throw new TemplateError(name);
74
+ const v = r.value;
75
+ return typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
76
+ });
77
+ }
78
+ function collectPlaceholders(value, into = /* @__PURE__ */ new Set()) {
79
+ if (typeof value === "string") {
80
+ for (const m of value.matchAll(EMBED)) into.add(m[1]);
81
+ } else if (Array.isArray(value)) {
82
+ for (const v of value) collectPlaceholders(v, into);
83
+ } else if (value !== null && typeof value === "object") {
84
+ for (const v of Object.values(value)) collectPlaceholders(v, into);
85
+ }
86
+ return into;
73
87
  }
74
88
 
75
- // src/environments.ts
76
- import { existsSync } from "fs";
77
- import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
78
- import { join } from "path";
89
+ // ../core/src/environment.ts
90
+ var AddressError = class extends Error {
91
+ constructor(message, variable) {
92
+ super(message);
93
+ this.variable = variable;
94
+ this.name = "AddressError";
95
+ }
96
+ variable;
97
+ };
98
+ var OAUTH_ADDRESS_FIELDS = [
99
+ "authorizationUrl",
100
+ "tokenUrl",
101
+ "openIdConnectUrl",
102
+ "clientId"
103
+ ];
104
+ function environmentScope(cfg, env, fileVars = {}, extra = {}) {
105
+ return {
106
+ ...cfg.vars,
107
+ ...cfg.environments?.[env]?.vars,
108
+ ...fileVars,
109
+ ...extra
110
+ };
111
+ }
112
+ function renderAddress(template, scope, what) {
113
+ for (const name of collectPlaceholders(template)) {
114
+ if (isDeferred(name) || isFakerName(name))
115
+ throw new AddressError(
116
+ `${what} cannot use {{${name}}}: only environment variables are available`
117
+ );
118
+ }
119
+ let value;
120
+ try {
121
+ value = renderString(template, scope);
122
+ } catch (e) {
123
+ if (e instanceof TemplateError) throw new AddressError(`${what}: set ${e.variable}`, e.variable);
124
+ throw e;
125
+ }
126
+ if (value !== null && typeof value === "object")
127
+ throw new AddressError(`${what} is not a text value`);
128
+ const out = value === void 0 || value === null ? "" : String(value).trim();
129
+ if (/\{\{/.test(out)) throw new AddressError(`${what} still contains a placeholder: ${out}`);
130
+ return out;
131
+ }
132
+ function baseUrlFor(cfg, service, env, scope) {
133
+ const base = cfg.services[service]?.baseUrl;
134
+ const template = typeof base === "string" ? base : base?.[env];
135
+ if (!template) return void 0;
136
+ const what = `No base URL for ${service} in ${env}`;
137
+ const url = /\{\{/.test(template) ? renderAddress(template, scope, what) : template.trim();
138
+ return url ? url.replace(/\/+$/, "") : void 0;
139
+ }
140
+ function oauthFor(oauth, scope, profile = "sign-in") {
141
+ const out = { ...oauth };
142
+ for (const field of OAUTH_ADDRESS_FIELDS) {
143
+ const v = out[field];
144
+ if (typeof v !== "string" || !/\{\{/.test(v)) continue;
145
+ const rendered = renderAddress(v, scope, `${profile} ${field}`);
146
+ if (rendered) out[field] = rendered;
147
+ else delete out[field];
148
+ }
149
+ return out;
150
+ }
151
+ function environmentNames(cfg, fileEnvs = [], extra = []) {
152
+ const names = /* @__PURE__ */ new Set(["mock"]);
153
+ const services = Object.values(cfg.services ?? {});
154
+ if (services.some((s) => typeof s.baseUrl === "string")) names.add("live");
155
+ for (const s of services) {
156
+ if (s.baseUrl && typeof s.baseUrl === "object")
157
+ for (const k of Object.keys(s.baseUrl)) names.add(k);
158
+ }
159
+ for (const k of Object.keys(cfg.environments ?? {})) names.add(k);
160
+ for (const k of fileEnvs) names.add(k);
161
+ for (const k of extra) names.add(k);
162
+ return [...names];
163
+ }
164
+
165
+ // ../core/src/oauth.ts
166
+ var OAuthError = class extends Error {
167
+ constructor(message) {
168
+ super(message);
169
+ this.name = "OAuthError";
170
+ }
171
+ };
172
+ function parseDiscovery(json3) {
173
+ const d = json3 && typeof json3 === "object" ? json3 : {};
174
+ const url = (v) => typeof v === "string" && /^https?:\/\//i.test(v) ? v : void 0;
175
+ const authorizationUrl = url(d.authorization_endpoint);
176
+ const tokenUrl = url(d.token_endpoint);
177
+ if (!authorizationUrl || !tokenUrl)
178
+ throw new OAuthError("The provider\u2019s discovery document has no authorization or token endpoint");
179
+ const supported = Array.isArray(d.scopes_supported) ? d.scopes_supported.filter((x) => typeof x === "string") : [];
180
+ const names = ["openid", ...supported.filter((x) => x !== "openid")];
181
+ return { authorizationUrl, tokenUrl, scopes: Object.fromEntries(names.map((n) => [n, ""])) };
182
+ }
79
183
 
80
184
  // ../core/src/proxy/policy.ts
81
185
  var DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
@@ -371,7 +475,178 @@ function parseDotenv(text) {
371
475
  return out;
372
476
  }
373
477
 
478
+ // src/index.ts
479
+ import fg4 from "fast-glob";
480
+
481
+ // src/addresses.ts
482
+ var NO_ADDRESSES = {
483
+ services: {},
484
+ tokenOrigins: {},
485
+ discoveryOrigins: {}
486
+ };
487
+ async function addressesFor(cfg, env, files, tokenOriginOf) {
488
+ const services = cfg.services ?? {};
489
+ const known = environmentNames({ services, environments: cfg.environments }, files.environments);
490
+ if (!known.includes(env)) return NO_ADDRESSES;
491
+ const scope = environmentScope(cfg, env, await files.vars());
492
+ const out = { services: {}, tokenOrigins: {}, discoveryOrigins: {} };
493
+ for (const [name, svc] of Object.entries(services)) {
494
+ if (env === "mock" && typeof svc.baseUrl === "string") continue;
495
+ try {
496
+ out.services[name] = baseUrlFor({ services }, name, env, scope);
497
+ } catch {
498
+ }
499
+ }
500
+ for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
501
+ try {
502
+ if (!profile.oauth) continue;
503
+ const oauth = oauthFor(profile.oauth, scope, name);
504
+ if ("tokenUrl" in oauth && oauth.tokenUrl)
505
+ out.tokenOrigins[name] = new URL(oauth.tokenUrl).origin;
506
+ else if ("openIdConnectUrl" in oauth && oauth.openIdConnectUrl) {
507
+ out.discoveryOrigins[name] = new URL(oauth.openIdConnectUrl).origin;
508
+ const token = await tokenOriginOf(oauth.openIdConnectUrl);
509
+ if (token) out.tokenOrigins[name] = token;
510
+ }
511
+ } catch {
512
+ }
513
+ }
514
+ return out;
515
+ }
516
+
517
+ // src/definitions.ts
518
+ function definitionRefs(text) {
519
+ const names = [];
520
+ for (const m of text.matchAll(/"\$ref":"(?:specbook:\/\/[^"#]*)?#\/\$defs\/([^"]+)"/g))
521
+ names.push(m[1].replace(/~1/g, "/").replace(/~0/g, "~"));
522
+ return names;
523
+ }
524
+ function pruneDefinitions(defs, roots) {
525
+ const all = defs.$defs ?? {};
526
+ const keep = /* @__PURE__ */ new Set();
527
+ const queue = [...roots ?? []];
528
+ while (queue.length) {
529
+ const name = queue.pop();
530
+ if (keep.has(name) || !(name in all)) continue;
531
+ keep.add(name);
532
+ queue.push(...definitionRefs(JSON.stringify(all[name])));
533
+ }
534
+ return { ...defs, $defs: Object.fromEntries(Object.entries(all).filter(([k]) => keep.has(k))) };
535
+ }
536
+
537
+ // src/discovery.ts
538
+ var FETCH_TIMEOUT_MS = 5e3;
539
+ function createDiscovery(opts = {}) {
540
+ const f = opts.fetch ?? fetch;
541
+ const now = opts.now ?? Date.now;
542
+ const retryAfter = opts.retryAfterMs ?? 3e4;
543
+ const found = /* @__PURE__ */ new Map();
544
+ const failedAt = /* @__PURE__ */ new Map();
545
+ const inFlight = /* @__PURE__ */ new Map();
546
+ async function look(url) {
547
+ try {
548
+ const res = await f(url, {
549
+ headers: { accept: "application/json" },
550
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
551
+ });
552
+ if (!res.ok) throw new Error(`answered ${res.status}`);
553
+ const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin;
554
+ found.set(url, origin);
555
+ failedAt.delete(url);
556
+ return origin;
557
+ } catch (e) {
558
+ failedAt.set(url, now());
559
+ opts.warn?.(
560
+ `Could not read the OpenID Connect discovery document at ${url} (${e instanceof Error ? e.message : String(e)}). Sign in is unavailable until it can; pasting a token still works.`
561
+ );
562
+ return void 0;
563
+ }
564
+ }
565
+ return function tokenOrigin(url) {
566
+ const known = found.get(url);
567
+ if (known) return Promise.resolve(known);
568
+ const failed = failedAt.get(url);
569
+ if (failed !== void 0 && now() - failed < retryAfter) return Promise.resolve(void 0);
570
+ let pending = inFlight.get(url);
571
+ if (!pending) {
572
+ pending = look(url).finally(() => inFlight.delete(url));
573
+ inFlight.set(url, pending);
574
+ }
575
+ return pending;
576
+ };
577
+ }
578
+
579
+ // src/docs.ts
580
+ import { readFile } from "fs/promises";
581
+ import { relative, sep } from "path";
582
+ import fg from "fast-glob";
583
+ import { marked } from "marked";
584
+ function frontmatter(src) {
585
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
586
+ if (!m) return { meta: {}, body: src };
587
+ const meta = {};
588
+ for (const line of m[1].split(/\r?\n/)) {
589
+ const i = line.indexOf(":");
590
+ if (i > 0)
591
+ meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
592
+ }
593
+ return { meta, body: src.slice(m[0].length) };
594
+ }
595
+ var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
596
+ var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
597
+ var NOT_AN_OPERATION = /^(readme|index)$/i;
598
+ function colocated(file) {
599
+ if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
600
+ if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
601
+ const folder = OP_FOLDER.exec(file);
602
+ if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
603
+ const flat = OP_FLAT.exec(file);
604
+ if (flat && !NOT_AN_OPERATION.test(flat[2]))
605
+ return { operation: `${flat[1]}/${flat[2]}` };
606
+ return void 0;
607
+ }
608
+ async function scanDocs(root) {
609
+ const files = await fg("**/*.md", {
610
+ cwd: root,
611
+ absolute: true,
612
+ ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
613
+ });
614
+ const pages = [];
615
+ const claimed = /* @__PURE__ */ new Map();
616
+ for (const abs of files.sort()) {
617
+ const { meta, body } = frontmatter(await readFile(abs, "utf8"));
618
+ const file = relative(root, abs).split(sep).join("/");
619
+ const near = colocated(file);
620
+ if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
621
+ const operation = meta.operation ?? near?.operation;
622
+ if (operation) {
623
+ const first = claimed.get(operation);
624
+ if (first) {
625
+ console.warn(
626
+ `[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
627
+ );
628
+ continue;
629
+ }
630
+ claimed.set(operation, file);
631
+ }
632
+ pages.push({
633
+ file,
634
+ operation,
635
+ sequence: meta.sequence,
636
+ sequenceFile: near?.sequenceFile,
637
+ requestFile: near?.requestFile,
638
+ replace: meta.replace === "true" || void 0,
639
+ title: meta.title ?? meta.page,
640
+ html: marked.parse(body, { async: false })
641
+ });
642
+ }
643
+ return pages;
644
+ }
645
+
374
646
  // src/environments.ts
647
+ import { existsSync } from "fs";
648
+ import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
649
+ import { join } from "path";
375
650
  var ENV_NAME = /^[A-Za-z0-9_-]+$/;
376
651
  var VAR_NAME = /^[A-Za-z_][\w.-]*$/;
377
652
  var environmentsDir = (root) => join(root, ".specbook", "environments");
@@ -468,6 +743,7 @@ async function scanExamples(root, outDir, operations) {
468
743
  const gen = `${root}/${outDir}`;
469
744
  for (const abs of await fg2("*/examples/*/*.example.json", { cwd: gen, absolute: true })) {
470
745
  const [svc, , id, file] = relative2(gen, abs).split(sep2);
746
+ if (!operations.has(`${svc}/${id}`)) continue;
471
747
  add(`${svc}/${id}`, {
472
748
  name: file.replace(NAME, ""),
473
749
  abs,
@@ -541,6 +817,15 @@ function requestsDirFor(root) {
541
817
  }
542
818
 
543
819
  // src/proxy.ts
820
+ var AUTH_SERVICE_PREFIX = "auth:";
821
+ var DISCOVERY_SERVICE_PREFIX = "discovery:";
822
+ var decode = (s) => {
823
+ try {
824
+ return decodeURIComponent(s);
825
+ } catch {
826
+ return s;
827
+ }
828
+ };
544
829
  var MOUNT = "/__specbook/proxy";
545
830
  function readBody(req) {
546
831
  return new Promise((res, rej) => {
@@ -554,6 +839,15 @@ async function handleProxy2(req, res, deps) {
554
839
  const url = req.url ?? "/";
555
840
  if (!url.startsWith(`${MOUNT}/`)) return false;
556
841
  const config = await deps.getConfig();
842
+ const addressesByEnv = /* @__PURE__ */ new Map();
843
+ const addressesFor2 = (env) => {
844
+ let a = addressesByEnv.get(env);
845
+ if (!a) {
846
+ a = config.resolve(env);
847
+ addressesByEnv.set(env, a);
848
+ }
849
+ return a;
850
+ };
557
851
  const secretsByEnv = /* @__PURE__ */ new Map();
558
852
  const method = req.method ?? "GET";
559
853
  const headers = new Headers();
@@ -568,7 +862,14 @@ async function handleProxy2(req, res, deps) {
568
862
  const response = await handleProxy(request, {
569
863
  mount: MOUNT,
570
864
  policy: {
571
- upstream: (env, service) => config.services[service]?.baseUrl[env],
865
+ upstream: async (env, service) => {
866
+ const a = await addressesFor2(env);
867
+ if (service.startsWith(AUTH_SERVICE_PREFIX))
868
+ return a.tokenOrigins[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
869
+ if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
870
+ return a.discoveryOrigins[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
871
+ return a.services[service];
872
+ },
572
873
  // a developer's own machine: their local services are the point
573
874
  allowPrivateHosts: true
574
875
  },
@@ -597,9 +898,47 @@ async function handleProxy2(req, res, deps) {
597
898
  return true;
598
899
  }
599
900
 
600
- // src/sequences.ts
901
+ // src/requests.ts
601
902
  import { readFile as readFile4 } from "fs/promises";
602
903
  import { join as join3 } from "path";
904
+ function parseRequestTarget(src, services) {
905
+ const local = /* @__PURE__ */ new Map();
906
+ for (const m of src.matchAll(/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
907
+ const service = m[2].split("/").pop();
908
+ if (!services.has(service) || /^import\s*type/.test(m[0])) continue;
909
+ for (const part of m[1].split(",")) {
910
+ const spec = part.trim();
911
+ if (!spec || spec.startsWith("type ")) continue;
912
+ const [imported, as] = spec.split(/\s+as\s+/);
913
+ local.set(as ?? imported, `${service}/${imported}`);
914
+ }
915
+ }
916
+ const used = /* @__PURE__ */ new Set();
917
+ for (const m of src.matchAll(/\b([A-Za-z_$][\w$]*)\s*\.\s*request\s*\(/g)) {
918
+ const key = local.get(m[1]);
919
+ if (key) used.add(key);
920
+ }
921
+ return used.size === 1 ? [...used][0] : void 0;
922
+ }
923
+ async function scanRequests(root, files, services) {
924
+ const meta = {};
925
+ const unresolved = [];
926
+ await Promise.all(
927
+ files.map(async (file) => {
928
+ const key = parseRequestTarget(
929
+ await readFile4(join3(root, file), "utf8").catch(() => ""),
930
+ services
931
+ );
932
+ if (key) meta[file] = key;
933
+ else unresolved.push(file);
934
+ })
935
+ );
936
+ return { meta, unresolved: unresolved.sort() };
937
+ }
938
+
939
+ // src/sequences.ts
940
+ import { readFile as readFile5 } from "fs/promises";
941
+ import { join as join4 } from "path";
603
942
  var KEYS = ["id", "title", "description", "group", "tags"];
604
943
  function readString(src, i) {
605
944
  const q = src[i];
@@ -697,7 +1036,7 @@ async function scanSequences(root, files) {
697
1036
  const unresolved = [];
698
1037
  await Promise.all(
699
1038
  files.map(async (file) => {
700
- const parsed = parseSequenceMeta(await readFile4(join3(root, file), "utf8").catch(() => ""));
1039
+ const parsed = parseSequenceMeta(await readFile5(join4(root, file), "utf8").catch(() => ""));
701
1040
  if (parsed) meta[parsed.id] = { ...parsed, file };
702
1041
  else unresolved.push(file);
703
1042
  })
@@ -714,14 +1053,14 @@ var VIRTUAL_FAKER = "virtual:specbook/faker";
714
1053
  var RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`;
715
1054
  var VIRTUAL_FAKER_LOCALE = "virtual:specbook/faker-locale/";
716
1055
  var RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`;
717
- var VIRTUAL_MANIFESTS = "virtual:specbook/manifests";
718
- var RESOLVED_MANIFESTS = `\0${VIRTUAL_MANIFESTS}`;
1056
+ var VIRTUAL_MANIFEST = "virtual:specbook/manifest/";
1057
+ var RESOLVED_MANIFEST = `\0${VIRTUAL_MANIFEST}`;
719
1058
  var VIRTUAL_OPERATIONS = "virtual:specbook/operations";
720
1059
  var RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`;
721
1060
  var VIRTUAL_DOC = "virtual:specbook/doc/";
722
1061
  var RESOLVED_DOC = `\0${VIRTUAL_DOC}`;
723
1062
  function findConfig(root) {
724
- return [join4(root, ".specbook/specbook.config.ts"), join4(root, "specbook.config.ts")].find(
1063
+ return [join5(root, ".specbook/specbook.config.ts"), join5(root, "specbook.config.ts")].find(
725
1064
  existsSync3
726
1065
  );
727
1066
  }
@@ -742,7 +1081,7 @@ async function readBody2(req) {
742
1081
  }
743
1082
  function fakerLocales(entry) {
744
1083
  const names = /* @__PURE__ */ new Set(["en", "base"]);
745
- const dir = join4(dirname(entry), "locale");
1084
+ const dir = join5(dirname(entry), "locale");
746
1085
  if (existsSync3(dir))
747
1086
  for (const f of readdirSync(dir)) {
748
1087
  const m = /^([A-Za-z0-9_]+)\.js$/.exec(f);
@@ -765,13 +1104,13 @@ function slim(manifests) {
765
1104
  }
766
1105
  async function collectTypes(root, outDir) {
767
1106
  const libs = {};
768
- const req = createRequire(join4(root, "package.json"));
1107
+ const req = createRequire(join5(root, "package.json"));
769
1108
  try {
770
1109
  const entry = req.resolve("@specbook/core");
771
1110
  const srcDir = dirname(entry);
772
1111
  const files = await fg4("**/*.ts", { cwd: srcDir, ignore: ["**/*.test.ts"] });
773
1112
  for (const f of files) {
774
- libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile5(join4(srcDir, f), "utf8");
1113
+ libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile6(join5(srcDir, f), "utf8");
775
1114
  }
776
1115
  libs["file:///node_modules/@specbook/core/package.json"] = JSON.stringify({
777
1116
  name: "@specbook/core",
@@ -779,24 +1118,27 @@ async function collectTypes(root, outDir) {
779
1118
  });
780
1119
  } catch {
781
1120
  }
782
- const genDir = join4(root, outDir);
1121
+ const genDir = join5(root, outDir);
783
1122
  for (const f of await fg4("**/*.ts", { cwd: genDir })) {
784
- libs[`file:///${outDir}/${f}`] = await readFile5(join4(genDir, f), "utf8");
1123
+ libs[`file:///${outDir}/${f}`] = await readFile6(join5(genDir, f), "utf8");
785
1124
  }
786
1125
  const project = await scanProject(root);
787
1126
  const sources = {};
788
1127
  for (const f of project.sources.filter((s) => s.endsWith(".ts"))) {
789
- sources[f] = await readFile5(join4(root, f), "utf8");
1128
+ sources[f] = await readFile6(join5(root, f), "utf8");
790
1129
  }
791
1130
  return { libs, sources };
792
1131
  }
793
1132
  function specbook(options) {
794
1133
  const root = resolve2(options.projectRoot);
795
1134
  const outDir = options.outDir ?? ".generated";
1135
+ const only = options.operations;
1136
+ const keep = (service, op) => !only || only.has(`${service}/${op}`);
1137
+ const neededDefs = /* @__PURE__ */ new Map();
796
1138
  let server;
797
1139
  let isBuild = false;
798
1140
  let docHtml = [];
799
- let manifestsCode = "export default {}";
1141
+ let manifestCode = /* @__PURE__ */ new Map();
800
1142
  let operationsCode = "export const operations = {}";
801
1143
  let bundlePlan = /* @__PURE__ */ new Map();
802
1144
  let definitionFiles = /* @__PURE__ */ new Map();
@@ -805,16 +1147,29 @@ function specbook(options) {
805
1147
  let notifyRegistryChanged = () => {
806
1148
  };
807
1149
  const memorySecrets = {};
1150
+ const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) });
808
1151
  async function registryCode() {
809
1152
  const files = await scanProject(root);
810
1153
  const manifests = {};
811
- for (const m of await fg4("*/manifest.json", { cwd: join4(root, outDir), absolute: true })) {
812
- const parsed = JSON.parse(await readFile5(m, "utf8"));
1154
+ for (const m of await fg4("*/manifest.json", { cwd: join5(root, outDir), absolute: true })) {
1155
+ const parsed = JSON.parse(await readFile6(m, "utf8"));
813
1156
  manifests[parsed.service] = parsed;
814
1157
  }
815
- const examples = await fg4("*/examples/*/*.response.json", {
816
- cwd: join4(root, outDir),
1158
+ if (only) {
1159
+ for (const [svc, m] of Object.entries(manifests)) {
1160
+ const mm = m;
1161
+ mm.operations = mm.operations.filter((o) => keep(svc, o.id));
1162
+ if (mm.operations.length === 0) delete manifests[svc];
1163
+ else mm.tags = [...new Set(mm.operations.flatMap((o) => o.tags ?? []))];
1164
+ }
1165
+ }
1166
+ const inScope = (svc, op) => svc in manifests && keep(svc, op);
1167
+ const examples = (await fg4("*/examples/*/*.response.json", {
1168
+ cwd: join5(root, outDir),
817
1169
  absolute: true
1170
+ })).filter((abs) => {
1171
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
1172
+ return inScope(rel[0], rel[2]);
818
1173
  });
819
1174
  const imp = (abs) => {
820
1175
  if (isBuild) return `() => import(${JSON.stringify(abs)})`;
@@ -823,50 +1178,45 @@ function specbook(options) {
823
1178
  return `(v) => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}`)} + '?t=' + v)`;
824
1179
  };
825
1180
  const raw = (abs) => `() => import(${JSON.stringify(`${abs}?raw`)}).then((m) => m.default)`;
826
- const entry = (file) => `{ file: ${JSON.stringify(file)}, load: ${imp(join4(root, file))}${isBuild ? `, source: ${raw(join4(root, file))}` : ""} }`;
1181
+ const entry = (file) => `{ file: ${JSON.stringify(file)}, load: ${imp(join5(root, file))}${isBuild ? `, source: ${raw(join5(root, file))}` : ""} }`;
827
1182
  const cfg = findConfig(root);
828
1183
  const requestsDir = requestsDirFor(root);
829
- existsSync3(join4(root, "apis")) && !existsSync3(join4(root, "requests")) ? "apis" : "requests";
1184
+ existsSync3(join5(root, "apis")) && !existsSync3(join5(root, "requests")) ? "apis" : "requests";
830
1185
  let fakerEntry;
831
1186
  try {
832
- fakerEntry = createRequire(join4(root, "package.json")).resolve("@faker-js/faker");
1187
+ fakerEntry = createRequire(join5(root, "package.json")).resolve("@faker-js/faker");
833
1188
  } catch {
834
1189
  }
835
1190
  const operationEntries = Object.values(manifests).flatMap((m) => {
836
1191
  const mm = m;
837
1192
  return mm.operations.map(
838
- (o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join4(root, outDir, mm.service, "operations", `${o.id}.ts`))}`
1193
+ (o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join5(root, outDir, mm.service, "operations", `${o.id}.ts`))}`
839
1194
  );
840
1195
  });
841
- const schemaFiles = await fg4("*/schemas/*.request.schema.json", {
842
- cwd: join4(root, outDir),
843
- absolute: true
844
- });
845
- const responseFiles = await fg4("*/schemas/*.responses.schema.json", {
846
- cwd: join4(root, outDir),
847
- absolute: true
848
- });
1196
+ const opOf = (abs, suffix) => {
1197
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
1198
+ return [rel[0], rel[2].replace(suffix, "")];
1199
+ };
1200
+ const schemaFiles = (await fg4("*/schemas/*.request.schema.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.request\.schema\.json$/)));
1201
+ const responseFiles = (await fg4("*/schemas/*.responses.schema.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.responses\.schema\.json$/)));
849
1202
  const responseEntries = responseFiles.map((abs) => {
850
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1203
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
851
1204
  const op = rel[2].replace(/\.responses\.schema\.json$/, "");
852
1205
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
853
1206
  });
854
- const defFiles = await fg4("*/schemas/definitions.schema.json", {
855
- cwd: join4(root, outDir),
856
- absolute: true
857
- });
1207
+ const defFiles = (await fg4("*/schemas/definitions.schema.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => relative4(join5(root, outDir), abs).split(sep4)[0] in manifests);
858
1208
  const schemaEntries = schemaFiles.map((abs) => {
859
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1209
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
860
1210
  const op = rel[2].replace(/\.request\.schema\.json$/, "");
861
1211
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
862
1212
  });
863
- const docFiles = await fg4("*/docs/*.json", { cwd: join4(root, outDir), absolute: true });
1213
+ const docFiles = (await fg4("*/docs/*.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.json$/)));
864
1214
  const operationDocEntries = docFiles.map((abs) => {
865
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1215
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
866
1216
  return ` ${JSON.stringify(`${rel[0]}/${rel[2].replace(/\.json$/, "")}`)}: ${imp(abs)}`;
867
1217
  });
868
1218
  const defEntries = defFiles.map((abs) => {
869
- const service = relative4(join4(root, outDir), abs).split(sep4)[0];
1219
+ const service = relative4(join5(root, outDir), abs).split(sep4)[0];
870
1220
  return ` ${JSON.stringify(service)}: ${imp(abs)}`;
871
1221
  });
872
1222
  const known = new Set(
@@ -876,11 +1226,15 @@ function specbook(options) {
876
1226
  })
877
1227
  );
878
1228
  const sequenceScan = await scanSequences(root, files.sequences);
1229
+ const requestScan = await scanRequests(root, files.requests, new Set(Object.keys(manifests)));
879
1230
  for (const f of files.sequences) {
880
- const src = await readFile5(join4(root, f), "utf8").catch(() => "");
881
- seqHeaders.set(join4(root, f), JSON.stringify(parseSequenceMeta(src)));
1231
+ const src = await readFile6(join5(root, f), "utf8").catch(() => "");
1232
+ seqHeaders.set(join5(root, f), JSON.stringify(parseSequenceMeta(src)));
882
1233
  }
883
1234
  const scanned = await scanExamples(root, outDir, known);
1235
+ const keepExampleKey = (key) => !only || only.has(key);
1236
+ for (const key of Object.keys(scanned.examples))
1237
+ if (!keepExampleKey(key)) delete scanned.examples[key];
884
1238
  const url = (abs) => isBuild ? `() => import(${JSON.stringify(`${abs}?url`)}).then((m) => m.default)` : `() => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}?url`)}).then((m) => m.default)`;
885
1239
  const requestExampleEntries = Object.entries(scanned.examples).map(
886
1240
  ([key, list]) => ` ${JSON.stringify(key)}: [${list.map(
@@ -902,23 +1256,28 @@ function specbook(options) {
902
1256
  const assetEntries = Object.entries(scanned.assets).map(
903
1257
  ([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`
904
1258
  );
905
- const pages = await scanDocs(root);
1259
+ const pages = (await scanDocs(root)).filter(
1260
+ (p) => !only || !p.operation || only.has(p.operation)
1261
+ );
906
1262
  docHtml = pages.map((p) => p.html);
907
1263
  const docEntries = pages.map(
908
1264
  (p, i) => `{ file: ${JSON.stringify(p.file)}, operation: ${JSON.stringify(p.operation)}, sequence: ${JSON.stringify(p.sequence)}, sequenceFile: ${JSON.stringify(p.sequenceFile)}, requestFile: ${JSON.stringify(p.requestFile)}, replace: ${JSON.stringify(p.replace)}, title: ${JSON.stringify(p.title)}, load: () => import(${JSON.stringify(`${VIRTUAL_DOC}${i}`)}).then((m) => m.default) }`
909
1265
  );
910
1266
  const exampleEntries = examples.map((abs) => {
911
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1267
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
912
1268
  const status = rel[3].replace(/\.response\.json$/, "");
913
1269
  const key = `${rel[0]}/${rel[2]}.${status}`;
914
1270
  return ` ${JSON.stringify(key)}: ${imp(abs)}`;
915
1271
  });
916
1272
  if (isBuild) {
917
- manifestsCode = `export default ${JSON.stringify(slim(manifests))}`;
1273
+ const slimmed = slim(manifests);
1274
+ manifestCode = new Map(
1275
+ Object.entries(slimmed).map(([svc, m]) => [svc, `export default ${JSON.stringify(m)}`])
1276
+ );
918
1277
  operationsCode = `export const operations = {
919
1278
  ${operationEntries.join(",\n")}
920
1279
  }`;
921
- const gen = join4(root, outDir);
1280
+ const gen = join5(root, outDir);
922
1281
  bundlePlan = /* @__PURE__ */ new Map();
923
1282
  const slot = (key) => {
924
1283
  const have = bundlePlan.get(key);
@@ -958,7 +1317,7 @@ ${operationEntries.join(",\n")}
958
1317
  const n = counts.get(tag) ?? 0;
959
1318
  counts.set(tag, n + 1);
960
1319
  operationChunks.set(
961
- join4(gen, m.service, "operations", `${o.id}.ts`),
1320
+ join5(gen, m.service, "operations", `${o.id}.ts`),
962
1321
  `ops-${m.service}-${tag}-${Math.floor(n / 40)}`
963
1322
  );
964
1323
  }
@@ -972,8 +1331,8 @@ ${operationEntries.join(",\n")}
972
1331
  projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`);
973
1332
  }
974
1333
  const services = Object.keys(manifests);
975
- return `import manifests from ${JSON.stringify(VIRTUAL_MANIFESTS)}
976
- export const mode = "static"
1334
+ return `export const mode = "static"
1335
+ export const minimal = ${JSON.stringify(!!only)}
977
1336
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
978
1337
  // where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
979
1338
  const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
@@ -987,8 +1346,15 @@ const bundle = (key) => {
987
1346
  }
988
1347
  const part = (pick) =>
989
1348
  new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
990
- export { manifests }
1349
+ export const manifests = {}
1350
+ const manifestLoaders = {${services.map((svc) => `
1351
+ ${JSON.stringify(svc)}: () => import(${JSON.stringify(`${VIRTUAL_MANIFEST}${svc}`)})`).join(",")}
1352
+ }
1353
+ export const loadManifests = (names = Object.keys(manifestLoaders)) =>
1354
+ Promise.all(names.map((svc) => manifests[svc] ? undefined : manifestLoaders[svc]?.().then((m) => { manifests[svc] = m.default }))).then(() => undefined)
991
1355
  export const requests = [${files.requests.map(entry).join(",\n")}]
1356
+ export const requestMeta = ${JSON.stringify(requestScan.meta)}
1357
+ export const unresolvedRequests = ${JSON.stringify(requestScan.unresolved)}
992
1358
  export const sequences = [${files.sequences.map(entry).join(",\n")}]
993
1359
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
994
1360
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -1044,9 +1410,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1044
1410
  `;
1045
1411
  }
1046
1412
  return `export const mode = ${JSON.stringify(isBuild ? "static" : "dev")}
1413
+ export const minimal = ${JSON.stringify(!!only)}
1047
1414
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
1048
1415
  export const manifests = ${JSON.stringify(slim(manifests))}
1416
+ export const loadManifests = () => Promise.resolve()
1049
1417
  export const requests = [${files.requests.map(entry).join(",\n")}]
1418
+ export const requestMeta = ${JSON.stringify(requestScan.meta)}
1419
+ export const unresolvedRequests = ${JSON.stringify(requestScan.unresolved)}
1050
1420
  export const sequences = [${files.sequences.map(entry).join(",\n")}]
1051
1421
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
1052
1422
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -1083,17 +1453,24 @@ export const loadFaker = ${fakerEntry ? `() => import('virtual:specbook/faker')`
1083
1453
  export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: { project: "specbook", services: {} } })'}
1084
1454
  `;
1085
1455
  }
1086
- async function getProxyConfig() {
1456
+ async function loadProjectConfig() {
1087
1457
  const cfgFile = findConfig(root);
1088
- if (!server || !cfgFile) return { services: {}, secretNames: {} };
1089
- const mod = await server.ssrLoadModule(cfgFile);
1090
- const cfg = mod.default ?? {};
1091
- return {
1092
- services: Object.fromEntries(
1093
- Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }])
1094
- ),
1095
- secretNames: cfg.secrets ?? {}
1096
- };
1458
+ if (!server || !cfgFile) return void 0;
1459
+ return (await server.ssrLoadModule(cfgFile)).default ?? {};
1460
+ }
1461
+ async function getProxyConfig() {
1462
+ const cfg = await loadProjectConfig();
1463
+ if (!cfg) return { secretNames: {}, resolve: async () => NO_ADDRESSES };
1464
+ const resolve3 = async (env) => isEnvName(env) ? addressesFor(
1465
+ cfg,
1466
+ env,
1467
+ {
1468
+ environments: await listEnvironmentFiles(root),
1469
+ vars: async () => (await readEnvironment(root, env)).vars
1470
+ },
1471
+ tokenOriginOf
1472
+ ) : NO_ADDRESSES;
1473
+ return { secretNames: cfg.secrets ?? {}, resolve: resolve3 };
1097
1474
  }
1098
1475
  return {
1099
1476
  name: "specbook",
@@ -1101,7 +1478,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1101
1478
  isBuild = c.command === "build";
1102
1479
  },
1103
1480
  async generateBundle() {
1104
- const read = async (abs) => JSON.parse(await readFile5(abs, "utf8"));
1481
+ const read = async (abs) => JSON.parse(await readFile6(abs, "utf8"));
1105
1482
  for (const [key, p] of bundlePlan) {
1106
1483
  const out = {};
1107
1484
  if (p.schema) out.schema = await read(p.schema);
@@ -1122,6 +1499,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1122
1499
  await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n])]))
1123
1500
  );
1124
1501
  }
1502
+ if (only) {
1503
+ const svc = key.slice(0, key.indexOf("/"));
1504
+ const set = neededDefs.get(svc) ?? /* @__PURE__ */ new Set();
1505
+ for (const name of definitionRefs(JSON.stringify([out.schema, out.responses])))
1506
+ set.add(name);
1507
+ neededDefs.set(svc, set);
1508
+ }
1125
1509
  this.emitFile({
1126
1510
  type: "asset",
1127
1511
  fileName: `data/${key}.json`,
@@ -1132,7 +1516,9 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1132
1516
  this.emitFile({
1133
1517
  type: "asset",
1134
1518
  fileName: `data/${svc}/_definitions.json`,
1135
- source: await readFile5(abs)
1519
+ source: only ? JSON.stringify(
1520
+ pruneDefinitions(JSON.parse(await readFile6(abs, "utf8")), neededDefs.get(svc))
1521
+ ) : await readFile6(abs)
1136
1522
  });
1137
1523
  },
1138
1524
  config(_, env) {
@@ -1144,7 +1530,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1144
1530
  // manifests are their own chunk (preloaded with the app, so no extra round trip), and
1145
1531
  // operation modules are grouped by tag instead of one chunk per operation
1146
1532
  manualChunks(id) {
1147
- if (id === RESOLVED_MANIFESTS) return "manifests";
1533
+ if (id.startsWith(RESOLVED_MANIFEST))
1534
+ return `manifest-${id.slice(RESOLVED_MANIFEST.length)}`;
1148
1535
  return operationChunks.get(id);
1149
1536
  }
1150
1537
  }
@@ -1156,7 +1543,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1156
1543
  if (id === VIRTUAL) return RESOLVED;
1157
1544
  if (id === VIRTUAL_TYPES) return RESOLVED_TYPES;
1158
1545
  if (id === VIRTUAL_FAKER) return RESOLVED_FAKER;
1159
- if (id === VIRTUAL_MANIFESTS) return RESOLVED_MANIFESTS;
1546
+ if (id.startsWith(VIRTUAL_MANIFEST)) return `\0${id}`;
1160
1547
  if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS;
1161
1548
  if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`;
1162
1549
  if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`;
@@ -1164,10 +1551,11 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1164
1551
  },
1165
1552
  async load(id) {
1166
1553
  if (id === RESOLVED) return registryCode();
1167
- if (id === RESOLVED_MANIFESTS) return manifestsCode;
1554
+ if (id.startsWith(RESOLVED_MANIFEST))
1555
+ return manifestCode.get(id.slice(RESOLVED_MANIFEST.length)) ?? "export default {}";
1168
1556
  if (id === RESOLVED_OPERATIONS) return operationsCode;
1169
1557
  if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
1170
- const r = await this.resolve("@faker-js/faker", join4(root, "package.json"), {
1558
+ const r = await this.resolve("@faker-js/faker", join5(root, "package.json"), {
1171
1559
  skipSelf: true
1172
1560
  });
1173
1561
  if (!r) return "export {}";
@@ -1176,7 +1564,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1176
1564
  return `export { ${id.slice(RESOLVED_FAKER_LOCALE.length)} as default } from ${pkg}`;
1177
1565
  let entry = r.id;
1178
1566
  try {
1179
- entry = createRequire(join4(root, "package.json")).resolve("@faker-js/faker");
1567
+ entry = createRequire(join5(root, "package.json")).resolve("@faker-js/faker");
1180
1568
  } catch {
1181
1569
  }
1182
1570
  const names = fakerLocales(entry);
@@ -1203,7 +1591,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1203
1591
  basename(outDir)
1204
1592
  ]);
1205
1593
  for (const entry of readdirSync(root, { withFileTypes: true }))
1206
- if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join4(root, entry.name));
1594
+ if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join5(root, entry.name));
1207
1595
  const relevant = (file) => {
1208
1596
  const rel = relative4(root, file);
1209
1597
  if (rel.startsWith("..") || /node_modules/.test(rel)) return false;
@@ -1227,7 +1615,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1227
1615
  if (file.endsWith(".md")) return refresh(file);
1228
1616
  if (!file.endsWith(".seq.ts")) return;
1229
1617
  const header = JSON.stringify(
1230
- parseSequenceMeta(await readFile5(file, "utf8").catch(() => ""))
1618
+ parseSequenceMeta(await readFile6(file, "utf8").catch(() => ""))
1231
1619
  );
1232
1620
  if (seqHeaders.get(file) !== header) {
1233
1621
  seqHeaders.set(file, header);
@@ -1252,7 +1640,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1252
1640
  const abs = safeProjectPath(root, url.searchParams.get("path") ?? "");
1253
1641
  if (!abs || !existsSync3(abs)) return json2(res, 404, { error: "not found" });
1254
1642
  res.setHeader("content-type", "text/plain; charset=utf-8");
1255
- return void res.end(await readFile5(abs, "utf8"));
1643
+ return void res.end(await readFile6(abs, "utf8"));
1256
1644
  }
1257
1645
  if (url.pathname === "/__specbook/types")
1258
1646
  return json2(res, 200, await collectTypes(root, outDir));
@@ -1271,6 +1659,35 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1271
1659
  );
1272
1660
  return json2(res, 200, { env, vars: values.vars, secrets, warnings });
1273
1661
  }
1662
+ if (url.pathname === "/__specbook/environment" && req.method === "POST") {
1663
+ const origin = req.headers.origin;
1664
+ if (origin && new URL(origin).host !== req.headers.host)
1665
+ return json2(res, 403, { error: "cross-origin request refused" });
1666
+ const { name, from } = JSON.parse(await readBody2(req));
1667
+ if (!isEnvName(name) || name === "mock" || !isEnvName(from))
1668
+ return json2(res, 400, { error: "bad environment name" });
1669
+ const conf = await loadProjectConfig();
1670
+ const known = environmentNames(
1671
+ { services: conf?.services ?? {}, environments: conf?.environments },
1672
+ await listEnvironmentFiles(root)
1673
+ );
1674
+ if (known.includes(name)) return json2(res, 409, { error: `"${name}" already exists` });
1675
+ if (!known.includes(from))
1676
+ return json2(res, 400, { error: `unknown environment "${from}"` });
1677
+ const own = {
1678
+ ...conf?.environments?.[from]?.vars,
1679
+ ...(await readEnvironment(root, from)).vars
1680
+ };
1681
+ await ensureEnvironmentsDir(root);
1682
+ const file = varsFile(root, name);
1683
+ await writeFile2(file, `# variables for ${name}, copied from ${from}
1684
+ `);
1685
+ for (const [k, v] of Object.entries(own)) {
1686
+ if (v === null || typeof v === "object" || !isVarName(k)) continue;
1687
+ await writeEnvKey(file, k, String(v));
1688
+ }
1689
+ return json2(res, 200, { ok: true, name });
1690
+ }
1274
1691
  if ((url.pathname === "/__specbook/secret" || url.pathname === "/__specbook/var") && req.method === "POST") {
1275
1692
  const origin = req.headers.origin;
1276
1693
  if (origin && new URL(origin).host !== req.headers.host)
@@ -1337,6 +1754,7 @@ export {
1337
1754
  listEnvironmentFiles,
1338
1755
  readAllVars,
1339
1756
  readEnvironment,
1757
+ requestsDirFor,
1340
1758
  safeProjectPath,
1341
1759
  scanProject,
1342
1760
  secretsFileFor,