@specbook/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +271 -94
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -4,4 +4,4 @@ Specbook command line: init, add, generate, dev, build, run, check, secrets and
4
4
 
5
5
  Part of [Specbook](https://github.com/specbook-dev/specbook), Storybook for APIs. See the repository for documentation.
6
6
 
7
- Source-available under the [Functional Source License (FSL-1.1-MIT)](LICENSE): free for internal and personal use, not for a competing commercial product, and each version becomes MIT two years after its release.
7
+ Source-available under the [Functional Source License (FSL-1.1-MIT)](LICENSE). Free to use, including at work and in commercial projects; the only restriction is offering it, or something substantially similar built from it, to others as a competing commercial product or service. Each version becomes MIT two years after its release.
package/dist/bin.js CHANGED
@@ -3192,8 +3192,32 @@ function createFetchTransport(opts) {
3192
3192
 
3193
3193
  // ../core/src/spec-config.ts
3194
3194
  var isUrl = (s) => /^https?:\/\//i.test(s);
3195
+ function discovery(s, base) {
3196
+ if (typeof s.openIdConnectUrl !== "string" || s.openIdConnectUrl === "") return void 0;
3197
+ try {
3198
+ return { openIdConnectUrl: new URL(s.openIdConnectUrl, base).toString() };
3199
+ } catch {
3200
+ return void 0;
3201
+ }
3202
+ }
3203
+ function codeFlow(s, base) {
3204
+ const flow = s.flows?.authorizationCode ?? (s.flow === "accessCode" ? s : void 0);
3205
+ if (!flow?.authorizationUrl || !flow?.tokenUrl) return void 0;
3206
+ const abs = (u) => {
3207
+ try {
3208
+ return new URL(u, base).toString();
3209
+ } catch {
3210
+ return u;
3211
+ }
3212
+ };
3213
+ return {
3214
+ authorizationUrl: abs(flow.authorizationUrl),
3215
+ tokenUrl: abs(flow.tokenUrl),
3216
+ ...flow.scopes && Object.keys(flow.scopes).length ? { scopes: flow.scopes } : {}
3217
+ };
3218
+ }
3195
3219
  var secretName = (scheme) => scheme.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase() || "API_KEY";
3196
- function authFromSpec(api) {
3220
+ function authFromSpec(api, source) {
3197
3221
  const schemes = api.components?.securitySchemes ?? api.securityDefinitions ?? {};
3198
3222
  const out = { auth: {}, secrets: {} };
3199
3223
  for (const [name, s] of Object.entries(schemes)) {
@@ -3201,6 +3225,7 @@ function authFromSpec(api) {
3201
3225
  const ref = `{{secrets.${secret}}}`;
3202
3226
  let apply;
3203
3227
  let title = name;
3228
+ let oauth;
3204
3229
  if (s.type === "apiKey") {
3205
3230
  const key = s.name;
3206
3231
  if (s.in === "header") apply = { headers: { [key]: ref } };
@@ -3218,10 +3243,12 @@ function authFromSpec(api) {
3218
3243
  title = `${name} (basic auth, paste base64 of user:password)`;
3219
3244
  } else if (s.type === "oauth2" || s.type === "openIdConnect") {
3220
3245
  apply = { headers: { authorization: `Bearer ${ref}` } };
3221
- title = `${name} (OAuth, paste an access token)`;
3246
+ const base = source && isUrl(source) ? source : void 0;
3247
+ oauth = s.type === "oauth2" ? codeFlow(s, base) : discovery(s, base);
3248
+ title = `${name} (OAuth, ${oauth ? "sign in or " : ""}paste an access token)`;
3222
3249
  }
3223
3250
  if (!apply) continue;
3224
- out.auth[name] = { title, apply };
3251
+ out.auth[name] = { title, apply, ...oauth ? { oauth } : {} };
3225
3252
  out.secrets[secret] = {};
3226
3253
  }
3227
3254
  const top = api.security?.flatMap(
@@ -3230,18 +3257,70 @@ function authFromSpec(api) {
3230
3257
  out.defaultProfile = top?.find((n) => out.auth[n]) ?? Object.keys(out.auth)[0];
3231
3258
  return out;
3232
3259
  }
3233
- function liveBaseUrl(api, source) {
3234
- const server = api.servers?.[0]?.url;
3235
- if (server) {
3236
- if (isUrl(server)) return server.replace(/\/$/, "");
3237
- if (isUrl(source)) return new URL(server, source).toString().replace(/\/$/, "");
3238
- return void 0;
3260
+ var MAX_SERVERS = 20;
3261
+ var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24).replace(/-+$/, "");
3262
+ function resolveServer(entry, source) {
3263
+ if (typeof entry?.url !== "string") return void 0;
3264
+ const url = entry.url.replace(/\{([^}]+)\}/g, (whole, key) => {
3265
+ const d = entry.variables?.[key]?.default;
3266
+ return d === void 0 || d === null ? whole : String(d);
3267
+ }).trim();
3268
+ if (/\{[^}]*\}/.test(url)) return void 0;
3269
+ if (isUrl(url)) return url.replace(/\/+$/, "");
3270
+ if (source) {
3271
+ try {
3272
+ return new URL(url, source).toString().replace(/\/+$/, "");
3273
+ } catch {
3274
+ }
3239
3275
  }
3240
- if (api.host)
3241
- return `${api.schemes?.[0] ?? "https"}://${api.host}${api.basePath ?? ""}`.replace(/\/$/, "");
3242
- if (isUrl(source)) return new URL(source).origin;
3243
3276
  return void 0;
3244
3277
  }
3278
+ function openApiServers(api, source) {
3279
+ const src = isUrl(source) ? source : void 0;
3280
+ const listed = Array.isArray(api.servers) ? api.servers : [];
3281
+ const servers = [];
3282
+ const describe = (e) => typeof e?.description === "string" && e.description.trim() !== "" ? { description: e.description.trim() } : {};
3283
+ if (listed.length === 0) {
3284
+ if (api.host)
3285
+ servers.push({
3286
+ name: "live",
3287
+ url: `${api.schemes?.[0] ?? "https"}://${api.host}${api.basePath ?? ""}`.replace(
3288
+ /\/+$/,
3289
+ ""
3290
+ )
3291
+ });
3292
+ else if (src) servers.push({ name: "live", url: new URL(src).origin });
3293
+ return { servers, needsBaseUrl: false, omitted: 0 };
3294
+ }
3295
+ const first = resolveServer(listed[0], src);
3296
+ if (first) servers.push({ name: "live", url: first, ...describe(listed[0]) });
3297
+ const needsBaseUrl = !first && typeof listed[0]?.url === "string" && !/^https?:\/\//i.test(listed[0].url);
3298
+ const taken = /* @__PURE__ */ new Set(["mock", "live"]);
3299
+ const seen = new Set(servers.map((x2) => x2.url));
3300
+ let omitted = 0;
3301
+ for (const entry of listed.slice(1)) {
3302
+ const url = resolveServer(entry, src);
3303
+ if (!url || seen.has(url)) continue;
3304
+ if (servers.length >= MAX_SERVERS) {
3305
+ omitted++;
3306
+ continue;
3307
+ }
3308
+ seen.add(url);
3309
+ const host = (() => {
3310
+ try {
3311
+ return new URL(url).host;
3312
+ } catch {
3313
+ return "";
3314
+ }
3315
+ })();
3316
+ const base = slug2(describe(entry).description ?? "") || slug2(host) || "server";
3317
+ let name = base;
3318
+ for (let n = 2; taken.has(name); n++) name = `${base}-${n}`;
3319
+ taken.add(name);
3320
+ servers.push({ name, url, ...describe(entry) });
3321
+ }
3322
+ return { servers, needsBaseUrl, omitted };
3323
+ }
3245
3324
 
3246
3325
  // ../vite/dist/index.js
3247
3326
  import { existsSync as existsSync3, readdirSync } from "fs";
@@ -4602,66 +4681,22 @@ import { join as join22, relative as relative32, resolve as resolve3, sep as sep
4602
4681
  import fg3 from "fast-glob";
4603
4682
  import { readFile as readFile4 } from "fs/promises";
4604
4683
  import { join as join32 } from "path";
4605
- function frontmatter(src) {
4606
- const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
4607
- if (!m) return { meta: {}, body: src };
4608
- const meta = {};
4609
- for (const line2 of m[1].split(/\r?\n/)) {
4610
- const i = line2.indexOf(":");
4611
- if (i > 0)
4612
- meta[line2.slice(0, i).trim()] = line2.slice(i + 1).trim().replace(/^["']|["']$/g, "");
4684
+ var OAuthError = class extends Error {
4685
+ constructor(message) {
4686
+ super(message);
4687
+ this.name = "OAuthError";
4613
4688
  }
4614
- return { meta, body: src.slice(m[0].length) };
4615
- }
4616
- var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
4617
- var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
4618
- var NOT_AN_OPERATION = /^(readme|index)$/i;
4619
- function colocated(file) {
4620
- if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
4621
- if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
4622
- const folder = OP_FOLDER.exec(file);
4623
- if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
4624
- const flat = OP_FLAT.exec(file);
4625
- if (flat && !NOT_AN_OPERATION.test(flat[2]))
4626
- return { operation: `${flat[1]}/${flat[2]}` };
4627
- return void 0;
4628
- }
4629
- async function scanDocs(root) {
4630
- const files = await fg("**/*.md", {
4631
- cwd: root,
4632
- absolute: true,
4633
- ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
4634
- });
4635
- const pages = [];
4636
- const claimed = /* @__PURE__ */ new Map();
4637
- for (const abs of files.sort()) {
4638
- const { meta, body } = frontmatter(await readFile3(abs, "utf8"));
4639
- const file = relative3(root, abs).split(sep2).join("/");
4640
- const near = colocated(file);
4641
- if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
4642
- const operation = meta.operation ?? near?.operation;
4643
- if (operation) {
4644
- const first = claimed.get(operation);
4645
- if (first) {
4646
- console.warn(
4647
- `[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
4648
- );
4649
- continue;
4650
- }
4651
- claimed.set(operation, file);
4652
- }
4653
- pages.push({
4654
- file,
4655
- operation,
4656
- sequence: meta.sequence,
4657
- sequenceFile: near?.sequenceFile,
4658
- requestFile: near?.requestFile,
4659
- replace: meta.replace === "true" || void 0,
4660
- title: meta.title ?? meta.page,
4661
- html: f.parse(body, { async: false })
4662
- });
4663
- }
4664
- return pages;
4689
+ };
4690
+ function parseDiscovery(json3) {
4691
+ const d = json3 && typeof json3 === "object" ? json3 : {};
4692
+ const url = (v2) => typeof v2 === "string" && /^https?:\/\//i.test(v2) ? v2 : void 0;
4693
+ const authorizationUrl = url(d.authorization_endpoint);
4694
+ const tokenUrl = url(d.token_endpoint);
4695
+ if (!authorizationUrl || !tokenUrl)
4696
+ throw new OAuthError("The provider\u2019s discovery document has no authorization or token endpoint");
4697
+ const supported = Array.isArray(d.scopes_supported) ? d.scopes_supported.filter((x2) => typeof x2 === "string") : [];
4698
+ const names = ["openid", ...supported.filter((x2) => x2 !== "openid")];
4699
+ return { authorizationUrl, tokenUrl, scopes: Object.fromEntries(names.map((n) => [n, ""])) };
4665
4700
  }
4666
4701
  var DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
4667
4702
  var DEFAULT_MAX_BODY = 25 * 1024 * 1024;
@@ -4949,6 +4984,107 @@ function parseDotenv2(text) {
4949
4984
  }
4950
4985
  return out;
4951
4986
  }
4987
+ var FETCH_TIMEOUT_MS = 5e3;
4988
+ function createDiscovery(opts = {}) {
4989
+ const f2 = opts.fetch ?? fetch;
4990
+ const now = opts.now ?? Date.now;
4991
+ const retryAfter = opts.retryAfterMs ?? 3e4;
4992
+ const found = /* @__PURE__ */ new Map();
4993
+ const failedAt = /* @__PURE__ */ new Map();
4994
+ const inFlight = /* @__PURE__ */ new Map();
4995
+ async function look(url) {
4996
+ try {
4997
+ const res = await f2(url, {
4998
+ headers: { accept: "application/json" },
4999
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
5000
+ });
5001
+ if (!res.ok) throw new Error(`answered ${res.status}`);
5002
+ const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin;
5003
+ found.set(url, origin);
5004
+ failedAt.delete(url);
5005
+ return origin;
5006
+ } catch (e) {
5007
+ failedAt.set(url, now());
5008
+ opts.warn?.(
5009
+ `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.`
5010
+ );
5011
+ return void 0;
5012
+ }
5013
+ }
5014
+ return function tokenOrigin(url) {
5015
+ const known = found.get(url);
5016
+ if (known) return Promise.resolve(known);
5017
+ const failed = failedAt.get(url);
5018
+ if (failed !== void 0 && now() - failed < retryAfter) return Promise.resolve(void 0);
5019
+ let pending = inFlight.get(url);
5020
+ if (!pending) {
5021
+ pending = look(url).finally(() => inFlight.delete(url));
5022
+ inFlight.set(url, pending);
5023
+ }
5024
+ return pending;
5025
+ };
5026
+ }
5027
+ function frontmatter(src) {
5028
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
5029
+ if (!m) return { meta: {}, body: src };
5030
+ const meta = {};
5031
+ for (const line2 of m[1].split(/\r?\n/)) {
5032
+ const i = line2.indexOf(":");
5033
+ if (i > 0)
5034
+ meta[line2.slice(0, i).trim()] = line2.slice(i + 1).trim().replace(/^["']|["']$/g, "");
5035
+ }
5036
+ return { meta, body: src.slice(m[0].length) };
5037
+ }
5038
+ var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
5039
+ var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
5040
+ var NOT_AN_OPERATION = /^(readme|index)$/i;
5041
+ function colocated(file) {
5042
+ if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
5043
+ if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
5044
+ const folder = OP_FOLDER.exec(file);
5045
+ if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
5046
+ const flat = OP_FLAT.exec(file);
5047
+ if (flat && !NOT_AN_OPERATION.test(flat[2]))
5048
+ return { operation: `${flat[1]}/${flat[2]}` };
5049
+ return void 0;
5050
+ }
5051
+ async function scanDocs(root) {
5052
+ const files = await fg("**/*.md", {
5053
+ cwd: root,
5054
+ absolute: true,
5055
+ ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
5056
+ });
5057
+ const pages = [];
5058
+ const claimed = /* @__PURE__ */ new Map();
5059
+ for (const abs of files.sort()) {
5060
+ const { meta, body } = frontmatter(await readFile3(abs, "utf8"));
5061
+ const file = relative3(root, abs).split(sep2).join("/");
5062
+ const near = colocated(file);
5063
+ if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
5064
+ const operation = meta.operation ?? near?.operation;
5065
+ if (operation) {
5066
+ const first = claimed.get(operation);
5067
+ if (first) {
5068
+ console.warn(
5069
+ `[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
5070
+ );
5071
+ continue;
5072
+ }
5073
+ claimed.set(operation, file);
5074
+ }
5075
+ pages.push({
5076
+ file,
5077
+ operation,
5078
+ sequence: meta.sequence,
5079
+ sequenceFile: near?.sequenceFile,
5080
+ requestFile: near?.requestFile,
5081
+ replace: meta.replace === "true" || void 0,
5082
+ title: meta.title ?? meta.page,
5083
+ html: f.parse(body, { async: false })
5084
+ });
5085
+ }
5086
+ return pages;
5087
+ }
4952
5088
  var ENV_NAME = /^[A-Za-z0-9_-]+$/;
4953
5089
  var VAR_NAME = /^[A-Za-z_][\w.-]*$/;
4954
5090
  var environmentsDir = (root) => join3(root, ".specbook", "environments");
@@ -5106,6 +5242,15 @@ function safeProjectPath(root, relPath) {
5106
5242
  function requestsDirFor(root) {
5107
5243
  return existsSync2(join22(root, "apis")) && !existsSync2(join22(root, "requests")) ? "apis" : "requests";
5108
5244
  }
5245
+ var AUTH_SERVICE_PREFIX = "auth:";
5246
+ var DISCOVERY_SERVICE_PREFIX = "discovery:";
5247
+ var decode = (s) => {
5248
+ try {
5249
+ return decodeURIComponent(s);
5250
+ } catch {
5251
+ return s;
5252
+ }
5253
+ };
5109
5254
  var MOUNT = "/__specbook/proxy";
5110
5255
  function readBody(req) {
5111
5256
  return new Promise((res, rej) => {
@@ -5133,7 +5278,13 @@ async function handleProxy2(req, res, deps) {
5133
5278
  const response = await handleProxy(request, {
5134
5279
  mount: MOUNT,
5135
5280
  policy: {
5136
- upstream: (env, service) => config.services[service]?.baseUrl[env],
5281
+ upstream: (env, service) => {
5282
+ if (service.startsWith(AUTH_SERVICE_PREFIX))
5283
+ return config.tokenOrigins?.[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
5284
+ if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
5285
+ return config.discoveryOrigins?.[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
5286
+ return config.services[service]?.baseUrl[env];
5287
+ },
5137
5288
  // a developer's own machine: their local services are the point
5138
5289
  allowPrivateHosts: true
5139
5290
  },
@@ -5364,6 +5515,7 @@ function specbook(options) {
5364
5515
  let notifyRegistryChanged = () => {
5365
5516
  };
5366
5517
  const memorySecrets = {};
5518
+ const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) });
5367
5519
  async function registryCode() {
5368
5520
  const files = await scanProject(root);
5369
5521
  const manifests = {};
@@ -5647,11 +5799,27 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
5647
5799
  if (!server || !cfgFile) return { services: {}, secretNames: {} };
5648
5800
  const mod = await server.ssrLoadModule(cfgFile);
5649
5801
  const cfg = mod.default ?? {};
5802
+ const tokenOrigins = {};
5803
+ const discoveryOrigins = {};
5804
+ for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
5805
+ try {
5806
+ const { tokenUrl, openIdConnectUrl } = profile.oauth ?? {};
5807
+ if (tokenUrl) tokenOrigins[name] = new URL(tokenUrl).origin;
5808
+ else if (openIdConnectUrl) {
5809
+ discoveryOrigins[name] = new URL(openIdConnectUrl).origin;
5810
+ const token = await tokenOriginOf(openIdConnectUrl);
5811
+ if (token) tokenOrigins[name] = token;
5812
+ }
5813
+ } catch {
5814
+ }
5815
+ }
5650
5816
  return {
5651
5817
  services: Object.fromEntries(
5652
5818
  Object.entries(cfg.services ?? {}).map(([k, v2]) => [k, { baseUrl: v2.baseUrl ?? {} }])
5653
5819
  ),
5654
- secretNames: cfg.secrets ?? {}
5820
+ secretNames: cfg.secrets ?? {},
5821
+ tokenOrigins,
5822
+ discoveryOrigins
5655
5823
  };
5656
5824
  }
5657
5825
  return {
@@ -5896,7 +6064,7 @@ import { existsSync as existsSync4, statSync } from "fs";
5896
6064
  import { join as join6, resolve as resolve4 } from "path";
5897
6065
 
5898
6066
  // src/commands/metadata.ts
5899
- import { readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
6067
+ import { readFile as readFile6 } from "fs/promises";
5900
6068
  import { join as join5 } from "path";
5901
6069
  import fg5 from "fast-glob";
5902
6070
  var isSequence = (v2) => v2?.kind === "sequence";
@@ -5933,7 +6101,7 @@ async function writeMetadata(root, outDir, loader) {
5933
6101
  } catch {
5934
6102
  }
5935
6103
  }
5936
- await writeFile4(
6104
+ await writeAtomically(
5937
6105
  join5(outDir, "specbook.json"),
5938
6106
  `${JSON.stringify({ services, sequences }, null, 2)}
5939
6107
  `
@@ -6104,14 +6272,14 @@ async function generateCommand(project, loader, only) {
6104
6272
 
6105
6273
  // src/commands/init.ts
6106
6274
  import { existsSync as existsSync6 } from "fs";
6107
- import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
6275
+ import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
6108
6276
  import { join as join8 } from "path";
6109
6277
  async function initCommand(cwd) {
6110
6278
  const dir = join8(cwd, ".specbook");
6111
6279
  await mkdir4(dir, { recursive: true });
6112
6280
  const cfg = join8(dir, "specbook.config.ts");
6113
6281
  if (!existsSync6(cfg)) {
6114
- await writeFile5(
6282
+ await writeFile4(
6115
6283
  cfg,
6116
6284
  `import { defineConfig } from '@specbook/core'
6117
6285
 
@@ -6129,11 +6297,11 @@ export default defineConfig({
6129
6297
  const envDir = await ensureEnvironmentsDir(cwd);
6130
6298
  const example = join8(envDir, "staging.secrets.env.example");
6131
6299
  if (!existsSync6(example)) {
6132
- await writeFile5(
6300
+ await writeFile4(
6133
6301
  example,
6134
6302
  "# Copy to staging.secrets.env (gitignored) and fill in. One KEY=value per line.\n# YOUR_API_KEY=\n"
6135
6303
  );
6136
- await writeFile5(
6304
+ await writeFile4(
6137
6305
  join8(envDir, "staging.env"),
6138
6306
  '# Non-secret variables for the "staging" environment (committed).\n# region=eu-west-1\n'
6139
6307
  );
@@ -6144,7 +6312,7 @@ export default defineConfig({
6144
6312
  pkg.imports = { ...pkg.imports ?? {}, "#api/*": "./.generated/*/index.ts" };
6145
6313
  pkg.scripts = { ...pkg.scripts ?? {} };
6146
6314
  if (!pkg.scripts.prepare) pkg.scripts.prepare = "specbook generate";
6147
- await writeFile5(pkgPath, `${JSON.stringify(pkg, null, 2)}
6315
+ await writeFile4(pkgPath, `${JSON.stringify(pkg, null, 2)}
6148
6316
  `);
6149
6317
  console.log('\u2713 added the "#api/*" subpath import and a "prepare" script to package.json');
6150
6318
  const gi = join8(cwd, ".gitignore");
@@ -6153,7 +6321,7 @@ export default defineConfig({
6153
6321
  (entry) => !current.split("\n").some((l) => l.trim() === entry)
6154
6322
  );
6155
6323
  if (missing.length) {
6156
- await writeFile5(
6324
+ await writeFile4(
6157
6325
  gi,
6158
6326
  `${current}${current.endsWith("\n") || !current ? "" : "\n"}${missing.join("\n")}
6159
6327
  `
@@ -6165,7 +6333,7 @@ export default defineConfig({
6165
6333
  // src/commands/preview.ts
6166
6334
  import { spawn } from "child_process";
6167
6335
  import { existsSync as existsSync7, readFileSync } from "fs";
6168
- import { mkdir as mkdir5, mkdtemp, rm as rm2, symlink, writeFile as writeFile6 } from "fs/promises";
6336
+ import { mkdir as mkdir5, mkdtemp, rm as rm2, symlink, writeFile as writeFile5 } from "fs/promises";
6169
6337
  import { createRequire as createRequire3 } from "module";
6170
6338
  import { tmpdir } from "os";
6171
6339
  import { dirname as dirname4, extname, join as join10, resolve as resolve7 } from "path";
@@ -6203,6 +6371,14 @@ async function devCommand(project, port = 5173) {
6203
6371
  });
6204
6372
  await server.listen();
6205
6373
  server.printUrls();
6374
+ const signIn = Object.entries(project.config.auth ?? {}).filter(([, p]) => p.oauth);
6375
+ const url = server.resolvedUrls?.local[0];
6376
+ if (signIn.length && url) {
6377
+ console.log(
6378
+ ` OAuth redirect URI to register for ${signIn.map(([n]) => n).join(", ")}: ${url}
6379
+ (it follows the port; pass --port to keep it the same between runs)`
6380
+ );
6381
+ }
6206
6382
  return server;
6207
6383
  }
6208
6384
  async function buildCommand(project, outDir) {
@@ -6247,7 +6423,7 @@ async function copyViewer(project, outDir, viewerDir = resolve6(uiRoot(), "dist-
6247
6423
 
6248
6424
  // src/commands/preview.ts
6249
6425
  var isUrl2 = (s) => /^https?:\/\//i.test(s);
6250
- var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "api";
6426
+ var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "api";
6251
6427
  async function link(root, name) {
6252
6428
  try {
6253
6429
  let dir = dirname4(createRequire3(import.meta.url).resolve(name));
@@ -6270,8 +6446,11 @@ async function previewCommand(source, o = {}) {
6270
6446
  console.log(`Reading ${source} \u2026`);
6271
6447
  const api = await SwaggerParser2.parse(spec);
6272
6448
  const title = api.info?.title ?? "API";
6273
- const service = slug2(title);
6274
- const baseUrl = liveBaseUrl(api, source);
6449
+ const service = slug3(title);
6450
+ const { servers } = openApiServers(api, source);
6451
+ const baseUrl = Object.fromEntries(servers.map((s) => [s.name, s.url]));
6452
+ const where = servers.length === 0 ? "" : servers.length === 1 && servers[0]?.name === "live" ? `, live calls go to ${servers[0].url}` : `
6453
+ Environments: ${servers.map((s) => `${s.name} \u2192 ${s.url}`).join(", ")}`;
6275
6454
  const root = await mkdtemp(join10(tmpdir(), "specbook-preview-"));
6276
6455
  const cleanup = () => rm2(root, { recursive: true, force: true }).catch(() => {
6277
6456
  });
@@ -6284,9 +6463,9 @@ async function previewCommand(source, o = {}) {
6284
6463
  const ext = /ya?ml/.test(extname(new URL(source).pathname)) ? ".yaml" : ".json";
6285
6464
  specFile = join10(root, `openapi/api${ext}`);
6286
6465
  await mkdir5(dirname4(specFile), { recursive: true });
6287
- await writeFile6(specFile, await res.text());
6466
+ await writeFile5(specFile, await res.text());
6288
6467
  }
6289
- const { auth, secrets, defaultProfile } = authFromSpec(api);
6468
+ const { auth, secrets, defaultProfile } = authFromSpec(api, source);
6290
6469
  const config = {
6291
6470
  project: service,
6292
6471
  secrets,
@@ -6294,22 +6473,20 @@ async function previewCommand(source, o = {}) {
6294
6473
  services: {
6295
6474
  [service]: {
6296
6475
  spec: specFile,
6297
- baseUrl: baseUrl ? { live: baseUrl } : {},
6476
+ baseUrl,
6298
6477
  ...defaultProfile ? { auth: defaultProfile } : {}
6299
6478
  }
6300
6479
  }
6301
6480
  };
6302
6481
  const configFile = join10(root, ".specbook/specbook.config.ts");
6303
6482
  await mkdir5(dirname4(configFile), { recursive: true });
6304
- await writeFile6(configFile, `export default ${JSON.stringify(config, null, 2)}
6483
+ await writeFile5(configFile, `export default ${JSON.stringify(config, null, 2)}
6305
6484
  `);
6306
6485
  await link(root, "@specbook/core");
6307
6486
  await link(root, "@faker-js/faker");
6308
6487
  console.log(`Generating "${title}" \u2026`);
6309
6488
  const out = await generateToDisk({ spec: specFile, service, outDir: join10(root, ".generated") });
6310
- console.log(
6311
- `\u2713 ${out.operations.length} operations${baseUrl ? `, live calls go to ${baseUrl}` : ""}`
6312
- );
6489
+ console.log(`\u2713 ${out.operations.length} operations${where}`);
6313
6490
  const project = { root, configFile, config };
6314
6491
  const server = await devCommand(project, o.port ?? 5173);
6315
6492
  const url = server.resolvedUrls?.local[0];
@@ -6326,7 +6503,7 @@ async function previewCommand(source, o = {}) {
6326
6503
  }
6327
6504
 
6328
6505
  // src/commands/run.ts
6329
- import { readFile as readFile11, writeFile as writeFile7 } from "fs/promises";
6506
+ import { readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
6330
6507
  import { createRequire as createRequire4 } from "module";
6331
6508
  import { join as join13, resolve as resolve10 } from "path";
6332
6509
  import { pathToFileURL } from "url";
@@ -6538,7 +6715,7 @@ Set them as environment variables, or in .specbook/environments/${o.env}.secrets
6538
6715
  const [kind, path] = spec.split(/:(.*)/s);
6539
6716
  if (kind === "ndjson") continue;
6540
6717
  const text = kind === "json" ? toJson(results) : kind === "junit" ? toJUnit(results) : kind === "github" ? formatGithub(results) : formatPretty(results);
6541
- if (path) await writeFile7(resolve10(path), `${text}
6718
+ if (path) await writeFile6(resolve10(path), `${text}
6542
6719
  `);
6543
6720
  else console.log(text);
6544
6721
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Specbook command line: init, add, generate, dev, build, run, check, secrets and preview.",
5
5
  "keywords": [
6
6
  "specbook",
@@ -37,10 +37,10 @@
37
37
  "dependencies": {
38
38
  "@apidevtools/swagger-parser": "10.1.1",
39
39
  "@faker-js/faker": "9.3.0",
40
- "@specbook/codegen": "0.1.0",
41
- "@specbook/core": "0.1.0",
42
- "@specbook/ui": "0.1.0",
43
- "@specbook/vite": "0.1.0",
40
+ "@specbook/codegen": "0.2.0",
41
+ "@specbook/core": "0.2.0",
42
+ "@specbook/ui": "0.2.0",
43
+ "@specbook/vite": "0.2.0",
44
44
  "fast-glob": "3.3.2",
45
45
  "swagger2openapi": "7.0.8",
46
46
  "vite": "8.3.4",