@lukoweb/apitogo 0.2.36 → 0.2.38

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.
@@ -56,6 +56,18 @@ const DEFAULT_RUNTIME_VERSION = {
56
56
  dotnet: "DOTNET_8",
57
57
  } as const;
58
58
 
59
+ /** What users read first (decision 28); the technical name follows in brackets. */
60
+ export const SERVICE_LABELS: Record<ServiceName, string> = {
61
+ portal: "Developer website",
62
+ gateway: "API protection",
63
+ identity: "Login for API users",
64
+ plans: "Pricing and API keys",
65
+ };
66
+
67
+ /** For example `API protection (gateway)`. */
68
+ export const serviceLabel = (service: ServiceName): string =>
69
+ `${SERVICE_LABELS[service]} (${service})`;
70
+
59
71
  export function isServiceName(value: string): value is ServiceName {
60
72
  return (SERVICES as readonly string[]).includes(value);
61
73
  }
@@ -69,6 +81,8 @@ export type AddOptions = {
69
81
  disabled?: boolean;
70
82
  /** Include every docs folder found instead of asking. */
71
83
  acceptAllDocs?: boolean;
84
+ /** Content to create: `blank` is an empty shell, `starter` a filled example (portal, gateway and plans). */
85
+ template?: "blank" | "starter";
72
86
  portal?: {
73
87
  name?: string;
74
88
  docs?: string[] | false;
@@ -124,6 +138,8 @@ export type ServiceEdit = {
124
138
  /** Files created by an add; files that already exist are listed in `keep` and never overwritten. */
125
139
  files: PlannedFile[];
126
140
  keep: string[];
141
+ /** Files and folders a `remove --delete-files` will delete (absolute paths). */
142
+ deletes: string[];
127
143
  /** Human-readable follow-ups: secrets to set, files left behind after a remove. */
128
144
  notes: string[];
129
145
  };
@@ -137,6 +153,13 @@ const exists = (target: string) =>
137
153
  const rel = (dir: string, target: string) =>
138
154
  path.relative(dir, target).split(path.sep).join("/") || ".";
139
155
 
156
+ const BLANK_SPEC = (name: string) => `openapi: 3.1.0
157
+ info:
158
+ title: ${JSON.stringify(name)}
159
+ version: 1.0.0
160
+ paths: {}
161
+ `;
162
+
140
163
  const STARTER_SPEC = (name: string) => `openapi: 3.1.0
141
164
  info:
142
165
  title: ${JSON.stringify(name)}
@@ -288,11 +311,21 @@ async function buildPortal(
288
311
  }
289
312
  }
290
313
  if (content.length === 0) {
291
- await addFile(
292
- context,
293
- path.join(resolved.root, "portal", "pages", "index.mdx"),
294
- starterPage(name),
295
- );
314
+ let template = options.template;
315
+ if (!template && options.ask)
316
+ template = await options.ask.choose("Content of the new site", [
317
+ { value: "starter", label: "A starter page I can edit" },
318
+ { value: "blank", label: "An empty pages folder" },
319
+ ]);
320
+ const pages = path.join(resolved.root, "portal", "pages");
321
+ if (template === "blank")
322
+ await addFile(context, path.join(pages, ".gitkeep"), "");
323
+ else
324
+ await addFile(
325
+ context,
326
+ path.join(pages, "index.mdx"),
327
+ starterPage(name),
328
+ );
296
329
  }
297
330
  }
298
331
 
@@ -391,7 +424,9 @@ async function buildGateway(
391
424
 
392
425
  let specs: string[];
393
426
  let requestedSpecs = o.spec ?? [];
394
- let createSpec = o.createSpec === true;
427
+ let createSpec =
428
+ o.createSpec === true ||
429
+ (requestedSpecs.length === 0 && options.template !== undefined);
395
430
  if (requestedSpecs.length === 0 && !createSpec && ask) {
396
431
  const answer = await ask.choose("OpenAPI file of your API", [
397
432
  { value: "create", label: "Start from a small file the command creates" },
@@ -421,7 +456,13 @@ async function buildGateway(
421
456
  }
422
457
  } else if (createSpec) {
423
458
  const target = path.join(resolved.apisDir, "openapi.yaml");
424
- await addFile(context, target, STARTER_SPEC(resolved.project.name));
459
+ await addFile(
460
+ context,
461
+ target,
462
+ options.template === "blank"
463
+ ? BLANK_SPEC(resolved.project.name)
464
+ : STARTER_SPEC(resolved.project.name),
465
+ );
425
466
  specs = [rel(dir, target)];
426
467
  } else {
427
468
  throw new ServiceEditError(
@@ -531,7 +572,13 @@ async function buildPlans(
531
572
  ): Promise<NonNullable<ConfigV2["plans"]>> {
532
573
  const { options, dir, resolved } = context;
533
574
  const o = options.plans ?? {};
534
- const template = o.template ?? "free";
575
+ const template =
576
+ o.template ??
577
+ (options.template === "blank"
578
+ ? "none"
579
+ : options.template === "starter"
580
+ ? "free"
581
+ : "free");
535
582
  const billing = o.billing ?? "stripe";
536
583
  if (template === "tiered" && billing === "none") {
537
584
  throw new ServiceEditError(
@@ -655,6 +702,7 @@ export async function planAddService(
655
702
  config: next,
656
703
  files: context.files,
657
704
  keep: context.keep,
705
+ deletes: [],
658
706
  notes: context.notes,
659
707
  };
660
708
  }
@@ -665,7 +713,7 @@ export async function planAddService(
665
713
  */
666
714
  export async function planRemoveService(
667
715
  service: ServiceName,
668
- options: { dir: string },
716
+ options: { dir: string; deleteFiles?: boolean },
669
717
  ): Promise<ServiceEdit> {
670
718
  const dir = path.resolve(options.dir);
671
719
  const { config, resolved } = loadConfigV2(dir);
@@ -690,14 +738,31 @@ export async function planRemoveService(
690
738
  );
691
739
  }
692
740
 
693
- const left: string[] = [];
694
741
  const r = resolved;
695
- if (service === "portal" && r.portal) left.push(rel(dir, r.portal.dir));
696
- if (service === "plans" && r.plans) left.push(rel(dir, r.plans.file));
697
- if (service === "gateway" && r.gateway)
698
- left.push(...r.gateway.specs.map((s) => rel(dir, s)));
742
+ const owned: string[] = [];
743
+ if (service === "portal" && r.portal) owned.push(r.portal.dir);
744
+ if (service === "plans" && r.plans) owned.push(r.plans.file);
745
+ if (service === "gateway" && r.gateway) owned.push(...r.gateway.specs);
699
746
  void removed;
700
747
 
748
+ // `--delete-files` only deletes what lives inside the project's own `apitogo/` folder. A docs folder, a backend
749
+ // or a spec that the tenant pointed the config at is theirs and is always kept.
750
+ const inside = (target: string) => {
751
+ const relative = path.relative(resolved.root, target);
752
+ return (
753
+ relative !== "" &&
754
+ !relative.startsWith("..") &&
755
+ !path.isAbsolute(relative)
756
+ );
757
+ };
758
+ const deletes: string[] = [];
759
+ const left: string[] = [];
760
+ for (const target of owned) {
761
+ if (options.deleteFiles && inside(target) && (await exists(target)))
762
+ deletes.push(target);
763
+ else left.push(rel(dir, target));
764
+ }
765
+
701
766
  return {
702
767
  service,
703
768
  action: "remove",
@@ -706,7 +771,16 @@ export async function planRemoveService(
706
771
  config: next,
707
772
  files: [],
708
773
  keep: [],
709
- notes: left.length > 0 ? [`Files are kept: ${left.join(", ")}.`] : [],
774
+ deletes,
775
+ notes: [
776
+ ...(left.length > 0
777
+ ? [
778
+ options.deleteFiles
779
+ ? `Files outside ${rel(dir, resolved.root)}/ are kept: ${left.join(", ")}.`
780
+ : `Files are kept: ${left.join(", ")}. Use --delete-files to delete the ones APItoGo created.`,
781
+ ]
782
+ : []),
783
+ ],
710
784
  };
711
785
  }
712
786
 
@@ -740,6 +814,8 @@ export async function applyServiceEdit(
740
814
  }
741
815
  }
742
816
  await writeConfig(edit.configPath, edit.config);
817
+ for (const target of edit.deletes)
818
+ await rm(target, { recursive: true, force: true });
743
819
  return { created };
744
820
  }
745
821
 
@@ -754,6 +830,12 @@ export function formatServiceEdit(edit: ServiceEdit): string[] {
754
830
  ...edit.files.map((file) => ` ${rel(edit.dir, file.path)}`),
755
831
  ]
756
832
  : []),
833
+ ...(edit.deletes.length > 0
834
+ ? [
835
+ "Will delete:",
836
+ ...edit.deletes.map((file) => ` ${rel(edit.dir, file)}`),
837
+ ]
838
+ : []),
757
839
  ...(edit.keep.length > 0
758
840
  ? [
759
841
  `Already there, left alone: ${edit.keep.map((file) => rel(edit.dir, file)).join(", ")}`,
@@ -0,0 +1,31 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { applyInitPlan, planInit } from "./init.js";
4
+ import type { Layout } from "./schema.js";
5
+ import { applyServiceEdit, planAddService } from "./services.js";
6
+
7
+ /**
8
+ * A project as the tests of `add`, `check` and `deploy` expect to find it: `init`, then the portal with its starter
9
+ * page. `layout: "standalone"` is the legacy layout, kept so older projects still load.
10
+ */
11
+ export async function makeProject(
12
+ dir: string,
13
+ options: { layout?: Layout; portal?: boolean; name?: string } = {},
14
+ ): Promise<void> {
15
+ await applyInitPlan(await planInit({ dir, name: options.name }));
16
+ const configPath = path.join(dir, "apitogo.config.json");
17
+ const config = JSON.parse(await readFile(configPath, "utf8"));
18
+ const layout = options.layout ?? "standalone";
19
+ await writeFile(
20
+ configPath,
21
+ `${JSON.stringify({ ...config, layout }, null, 2)}\n`,
22
+ );
23
+ if (options.portal !== false)
24
+ await applyServiceEdit(
25
+ await planAddService("portal", {
26
+ dir,
27
+ template: "starter",
28
+ portal: { docs: false },
29
+ }),
30
+ );
31
+ }
@@ -16,6 +16,7 @@ import {
16
16
  stubManifest,
17
17
  } from "./manifest.js";
18
18
  import { type PortalProfile, portalProfile } from "./portal-profile.js";
19
+ import { protectedRoutesFor } from "./protected-routes.js";
19
20
  import type { ResolvedConfigV2 } from "./resolve.js";
20
21
 
21
22
  export const WRAPPER_FILE = "apitogo.config.tsx";
@@ -62,7 +63,12 @@ export type PortalPlan = {
62
63
  audience?: string;
63
64
  };
64
65
  /** Pricing, subscriptions, API keys and usage pages. */
65
- plansPlugin?: { apiBaseUrl: string; keysApiBaseUrl?: string };
66
+ plansPlugin?: {
67
+ apiBaseUrl: string;
68
+ keysApiBaseUrl?: string;
69
+ /** The billing provider of the plans service (`none`, `stripe`, ...). `none`: the pages offer no payment. */
70
+ billing?: string;
71
+ };
66
72
  /** Account pages. */
67
73
  userPanel: boolean;
68
74
  /** Public sign-up page; only when identity declares `nativeSignup`. */
@@ -224,6 +230,7 @@ export function planPortal(
224
230
  plan.plansPlugin = {
225
231
  apiBaseUrl: plans.apiBaseUrl,
226
232
  keysApiBaseUrl: plans.keysApiBaseUrl,
233
+ billing: plans.billing,
227
234
  };
228
235
  }
229
236
 
@@ -249,7 +256,11 @@ export function buildSiteConfig(
249
256
  resolved: ResolvedConfigV2,
250
257
  plan: PortalPlan,
251
258
  theme?: unknown,
252
- extras: { redirects?: HomeRedirect[]; docsNavigation?: unknown[] } = {},
259
+ extras: {
260
+ redirects?: HomeRedirect[];
261
+ docsNavigation?: unknown[];
262
+ protectedRoutes?: string[];
263
+ } = {},
253
264
  ): Record<string, unknown> {
254
265
  const portal = resolved.portal;
255
266
  const redirects = extras.redirects ?? [];
@@ -310,6 +321,9 @@ export function buildSiteConfig(
310
321
  : {}),
311
322
  ...(navigation.length > 0 ? { navigation } : {}),
312
323
  ...(redirects.length > 0 ? { redirects } : {}),
324
+ ...(extras.protectedRoutes?.length
325
+ ? { protectedRoutes: extras.protectedRoutes }
326
+ : {}),
313
327
  ...(theme !== undefined ? { theme } : {}),
314
328
  ...(plan.auth ? { authentication: authenticationFor(plan) } : {}),
315
329
  ...(plan.preview ? { preview: true } : {}),
@@ -490,6 +504,10 @@ export async function preparePortal(
490
504
  externalDocsUrl: profile.docs.url,
491
505
  apiReferencePath: plan.apiReference ? `/${API_REFERENCE_PATH}` : undefined,
492
506
  userPanel: plan.userPanel,
507
+ pricingPath:
508
+ plan.plansPlugin && hasCapability(effective.services.plans, "catalog")
509
+ ? CONSOLE_ROUTES.pricing
510
+ : undefined,
493
511
  });
494
512
  const siteConfig = buildSiteConfig(
495
513
  resolved,
@@ -498,6 +516,14 @@ export async function preparePortal(
498
516
  {
499
517
  redirects: home ? [home] : [],
500
518
  docsNavigation: docsNavigation(hostedDocRoutes),
519
+ protectedRoutes: protectedRoutesFor({
520
+ requireAuth: resolved.portal?.requireAuth,
521
+ hasAuth: !!plan.auth,
522
+ docRoutes: hostedDocRoutes,
523
+ apiReferencePath: plan.apiReference
524
+ ? `/${API_REFERENCE_PATH}`
525
+ : undefined,
526
+ }),
501
527
  },
502
528
  );
503
529
  const wrapper = renderWrapper(plan);