@lukoweb/apitogo 0.2.42 → 0.2.44

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.
@@ -9,6 +9,15 @@ import {
9
9
  } from "node:fs/promises";
10
10
  import path from "node:path";
11
11
  import { ApitogoError } from "../../lib/util/invariant.js";
12
+ import {
13
+ FULL_SERVER_HEALTH_PATH,
14
+ FULL_SERVER_TIMEOUT_SECONDS,
15
+ FULL_SERVER_RUNTIME,
16
+ FULL_SERVER_RUNTIME_VERSION,
17
+ FULL_SERVER_SOURCE,
18
+ FULL_SPEC,
19
+ fullServerFiles,
20
+ } from "./full-server.js";
12
21
  import { docsFromPaths, starterPage } from "./init.js";
13
22
  import { detectExistingDocs } from "./layout.js";
14
23
  import { loadConfigV2 } from "./load.js";
@@ -129,8 +138,11 @@ export type AddOptions = {
129
138
  disabled?: boolean;
130
139
  /** Include every docs folder found instead of asking. */
131
140
  acceptAllDocs?: boolean;
132
- /** Content to create: `blank` is an empty shell, `starter` a filled example (portal, gateway and plans). */
133
- template?: "blank" | "starter";
141
+ /**
142
+ * Content to create: `blank` is an empty shell, `starter` a filled example (portal, gateway and plans), and `full`
143
+ * the starter plus a backend the platform runs, so the first deploy works without further steps.
144
+ */
145
+ template?: "blank" | "starter" | "full";
134
146
  portal?: {
135
147
  name?: string;
136
148
  docs?: string[] | false;
@@ -176,6 +188,10 @@ export type AddOptions = {
176
188
 
177
189
  export type PlannedFile = { path: string; content: string };
178
190
 
191
+ /** The templates that fill the new files with examples. */
192
+ const isFilled = (template: AddOptions["template"]) =>
193
+ template === "starter" || template === "full";
194
+
179
195
  export type ServiceEdit = {
180
196
  service: ServiceName;
181
197
  action: "add" | "remove";
@@ -397,7 +413,7 @@ async function buildPortal(
397
413
  const pages = path.join(resolved.root, "portal", "pages");
398
414
  if (template === "blank")
399
415
  await addFile(context, path.join(pages, ".gitkeep"), "");
400
- else if (options.template === "starter") {
416
+ else if (isFilled(options.template)) {
401
417
  // The starter has a full home page of its own, so the docs start at /introduction, where the page links to.
402
418
  await addFile(
403
419
  context,
@@ -447,20 +463,30 @@ async function buildPortal(
447
463
 
448
464
  /**
449
465
  * A managed gateway runs the code in its source folder, and a new project has none. The folder is made (a `.gitkeep`
450
- * keeps it in git) and the user is told it is empty. A folder that has files is left as it is.
466
+ * keeps it in git) and the user is told it is empty; the full template writes a small Node.js server there instead.
467
+ * A folder that has files is left as it is. Returns whether the sample server was written.
451
468
  */
452
469
  async function createSourceFolder(
453
470
  context: Context,
454
471
  sourcePath: string,
455
- ): Promise<void> {
472
+ runtime: Runtime,
473
+ ): Promise<boolean> {
456
474
  const folder = path.join(context.dir, sourcePath);
457
475
  const entries = await readdir(folder).catch(() => [] as string[]);
458
- if (entries.some((name) => name !== ".gitkeep")) return;
476
+ if (entries.some((name) => name !== ".gitkeep")) return false;
477
+ if (context.options.template === "full" && runtime === FULL_SERVER_RUNTIME) {
478
+ for (const [file, content] of Object.entries(
479
+ fullServerFiles(context.resolved.project.name),
480
+ ))
481
+ await addFile(context, path.join(folder, file), content);
482
+ return true;
483
+ }
459
484
  if (entries.length === 0)
460
485
  await addFile(context, path.join(folder, ".gitkeep"), "");
461
486
  context.notes.push(
462
487
  `The folder ${sourcePath}/ is empty: put your backend code there before you deploy.`,
463
488
  );
489
+ return false;
464
490
  }
465
491
 
466
492
  async function buildGateway(
@@ -469,9 +495,11 @@ async function buildGateway(
469
495
  const { options, dir, resolved } = context;
470
496
  const o = options.gateway ?? {};
471
497
  const ask = options.ask;
498
+ // The full template runs a backend of its own, so it asks nothing about the origin.
499
+ const full = options.template === "full" && !o.url && o.origin !== "proxy";
472
500
  let mode =
473
501
  o.origin ??
474
- (o.url ? "proxy" : o.source || o.runtime ? "managed" : undefined);
502
+ (o.url ? "proxy" : o.source || o.runtime || full ? "managed" : undefined);
475
503
  if (!mode && ask) {
476
504
  mode = await ask.choose("How does the gateway reach your API?", [
477
505
  {
@@ -492,6 +520,8 @@ async function buildGateway(
492
520
  );
493
521
  }
494
522
  let origin: NonNullable<ConfigV2["gateway"]>["origin"];
523
+ // The sample server was written, so the sample OpenAPI file can describe its endpoints.
524
+ let sampleServer = false;
495
525
  if (mode === "proxy") {
496
526
  const url =
497
527
  o.url ??
@@ -506,6 +536,7 @@ async function buildGateway(
506
536
  } else {
507
537
  const source =
508
538
  o.source ??
539
+ (full ? FULL_SERVER_SOURCE : undefined) ??
509
540
  (ask
510
541
  ? await ask.text("Folder with your code, relative to the project", {
511
542
  default: "server",
@@ -513,6 +544,7 @@ async function buildGateway(
513
544
  : undefined);
514
545
  const runtime =
515
546
  o.runtime ??
547
+ (full ? FULL_SERVER_RUNTIME : undefined) ??
516
548
  (ask
517
549
  ? await ask.choose("Runtime of your code", [
518
550
  { value: "nodejs" as const, label: "Node.js" },
@@ -523,16 +555,18 @@ async function buildGateway(
523
555
  const runtimeVersion =
524
556
  o.runtimeVersion !== undefined && runtime
525
557
  ? runtimeVersionSlug(runtime, o.runtimeVersion)
526
- : ask && runtime
527
- ? await ask.choose(
528
- "Runtime version",
529
- RUNTIME_VERSIONS[runtime].map((choice, index) => ({
530
- value: choice.value as string,
531
- label:
532
- index === 0 ? `${choice.label} (suggested)` : choice.label,
533
- })),
534
- )
535
- : undefined;
558
+ : full && runtime === FULL_SERVER_RUNTIME
559
+ ? FULL_SERVER_RUNTIME_VERSION
560
+ : ask && runtime
561
+ ? await ask.choose(
562
+ "Runtime version",
563
+ RUNTIME_VERSIONS[runtime].map((choice, index) => ({
564
+ value: choice.value as string,
565
+ label:
566
+ index === 0 ? `${choice.label} (suggested)` : choice.label,
567
+ })),
568
+ )
569
+ : undefined;
536
570
  if (!source || !runtime || !runtimeVersion) {
537
571
  throw new ServiceEditError(
538
572
  "--origin managed needs --source, --runtime and --runtime-version.",
@@ -542,14 +576,17 @@ async function buildGateway(
542
576
  );
543
577
  }
544
578
  const sourcePath = insideProject(dir, source, "Source folder");
579
+ sampleServer = await createSourceFolder(context, sourcePath, runtime);
545
580
  origin = {
546
581
  mode,
547
582
  source: sourcePath,
548
583
  runtime,
549
584
  runtimeVersion,
550
585
  ...(o.exclude?.length ? { exclude: o.exclude } : {}),
586
+ ...(sampleServer ? { healthPath: FULL_SERVER_HEALTH_PATH } : {}),
587
+ // A full project's API is hosted by the platform and sleeps when idle; the first call must outlast its start.
588
+ ...(full ? { timeoutSeconds: FULL_SERVER_TIMEOUT_SECONDS } : {}),
551
589
  };
552
- await createSourceFolder(context, sourcePath);
553
590
  }
554
591
 
555
592
  let specs: string[];
@@ -591,7 +628,9 @@ async function buildGateway(
591
628
  target,
592
629
  options.template === "blank"
593
630
  ? BLANK_SPEC(resolved.project.name)
594
- : STARTER_SPEC(resolved.project.name),
631
+ : sampleServer
632
+ ? FULL_SPEC(resolved.project.name)
633
+ : STARTER_SPEC(resolved.project.name),
595
634
  );
596
635
  specs = [rel(dir, target)];
597
636
  } else {
@@ -706,7 +745,7 @@ async function buildPlans(
706
745
  o.template ??
707
746
  (options.template === "blank"
708
747
  ? "none"
709
- : options.template === "starter"
748
+ : isFilled(options.template)
710
749
  ? "starter"
711
750
  : "free");
712
751
  // Only paid plans need a billing provider. A catalog of free plans takes no payment, so its default is `none` and the
@@ -69,7 +69,8 @@ export type PortalPlan = {
69
69
  };
70
70
  /** Pricing, subscriptions, API keys and usage pages. */
71
71
  plansPlugin?: {
72
- apiBaseUrl: string;
72
+ /** The plans backend; absent in a demo preview, where the pages show sample data. */
73
+ apiBaseUrl?: string;
73
74
  keysApiBaseUrl?: string;
74
75
  /** The billing provider of the plans service (`none`, `stripe`, ...). `none`: the pages offer no payment. */
75
76
  billing?: string;
@@ -84,6 +85,11 @@ export type PortalPlan = {
84
85
  skipped: { piece: string; reason: string }[];
85
86
  /** True only for a stub manifest. Production builds refuse it. */
86
87
  preview: boolean;
88
+ /**
89
+ * A preview with no deployed identity: the portal signs in with the demo account instead, and the account and plans
90
+ * pages show sample data. Never set outside a preview.
91
+ */
92
+ demo?: boolean;
87
93
  };
88
94
 
89
95
  /** npm packages the generated site needs, beyond `@lukoweb/apitogo`, for what the plan registers. */
@@ -203,6 +209,20 @@ export function planPortal(
203
209
  return plan;
204
210
  }
205
211
 
212
+ // Before the first deploy there is no identity to sign in with. A preview signs in with the demo account instead,
213
+ // and the account and plans pages show sample data, so the console can be tried locally.
214
+ if (plan.preview && identity && !identity.issuer) {
215
+ plan.demo = true;
216
+ plan.userPanel = true;
217
+ skip("sign-in", "preview: the demo account signs in");
218
+ if (plans)
219
+ plan.plansPlugin = {
220
+ billing: "none",
221
+ links: plan.apiReference ? { api: `/${API_REFERENCE_PATH}` } : {},
222
+ };
223
+ return plan;
224
+ }
225
+
206
226
  if (!identity) {
207
227
  skip("sign-in", "services.json has no identity entry");
208
228
  } else if (!serviceReady(identity)) {
@@ -274,6 +294,10 @@ export function buildSiteConfig(
274
294
  extras: {
275
295
  redirects?: HomeRedirect[];
276
296
  docsNavigation?: unknown[];
297
+ /** The first docs page, linked from the header next to the API reference. */
298
+ docsPath?: string;
299
+ /** A demo preview's plan catalog, from `plans.json`; the pages read it from the site config. */
300
+ demoPlans?: unknown[];
277
301
  protectedRoutes?: string[];
278
302
  } = {},
279
303
  ): Record<string, unknown> {
@@ -346,22 +370,29 @@ export function buildSiteConfig(
346
370
  }
347
371
  : {}),
348
372
  ...(navigation.length > 0 ? { navigation } : {}),
349
- // The sidebar link above is only seen inside the docs; the header is what every page shows.
373
+ // The sidebar link above is only seen inside the docs; the header is what every page shows. A header navigation
374
+ // replaces the docs tab, so the docs get a header link of their own.
350
375
  ...(plan.apiReference
351
376
  ? {
352
377
  header: {
353
378
  navigation: [
379
+ ...(plan.docs && extras.docsPath
380
+ ? [{ label: "Documentation", to: extras.docsPath }]
381
+ : []),
354
382
  { label: "API Reference", to: `/${API_REFERENCE_PATH}` },
355
383
  ],
356
384
  },
357
385
  }
358
386
  : {}),
387
+ ...(extras.demoPlans?.length ? { plans: extras.demoPlans } : {}),
359
388
  ...(redirects.length > 0 ? { redirects } : {}),
360
389
  ...(extras.protectedRoutes?.length
361
390
  ? { protectedRoutes: extras.protectedRoutes }
362
391
  : {}),
363
392
  ...(theme !== undefined ? { theme } : {}),
364
- ...(plan.auth ? { authentication: authenticationFor(plan) } : {}),
393
+ ...(plan.auth || plan.demo
394
+ ? { authentication: authenticationFor(plan) }
395
+ : {}),
365
396
  ...(plan.preview ? { preview: true } : {}),
366
397
  };
367
398
  }
@@ -373,6 +404,8 @@ export function buildSiteConfig(
373
404
  * form. Without the plans service there is no backend, and the portal signs in with the identity directly (OpenID).
374
405
  */
375
406
  function authenticationFor(plan: PortalPlan): Record<string, unknown> {
407
+ // The demo form: demo@apitogo.local / password, only in a local run.
408
+ if (plan.demo) return { type: "dev-portal", demoMode: true };
376
409
  if (plan.plansPlugin)
377
410
  return {
378
411
  type: "dev-portal",
@@ -389,7 +422,7 @@ function authenticationFor(plan: PortalPlan): Record<string, unknown> {
389
422
 
390
423
  /** Shown on every page of a preview run, so a stand-in manifest is never mistaken for a deployed portal. */
391
424
  export const PREVIEW_BANNER_MESSAGE =
392
- "Preview mode: sign-in and plans use a stand-in manifest. Run `apitogo deploy` for the real services.";
425
+ "Preview mode: sign in with demo@apitogo.local / password to try the console. Run `apitogo deploy` for the real services.";
393
426
 
394
427
  /** The wrapper source. Imports exist only for what the plan registers. */
395
428
  export function renderWrapper(plan: PortalPlan): string {
@@ -423,6 +456,24 @@ export function renderWrapper(plan: PortalPlan): string {
423
456
  ].join("\n");
424
457
  }
425
458
 
459
+ /**
460
+ * The plans of the project's `plans.json`, for the pricing and account pages of a demo preview: with no plans backend
461
+ * they read their catalog from the site config. A missing or unreadable file gives no plans; `check` reports it.
462
+ */
463
+ async function readDemoPlans(
464
+ file: string | undefined,
465
+ ): Promise<unknown[] | undefined> {
466
+ if (!file) return undefined;
467
+ try {
468
+ const parsed = JSON.parse(await readFile(file, "utf8")) as {
469
+ plans?: unknown;
470
+ };
471
+ return Array.isArray(parsed.plans) ? parsed.plans : undefined;
472
+ } catch {
473
+ return undefined;
474
+ }
475
+ }
476
+
426
477
  /** The portal theme file, when it exists and parses. `check` reports an invalid one; the build just skips it. */
427
478
  async function readTheme(resolved: ResolvedConfigV2): Promise<unknown> {
428
479
  const file = resolved.portal?.theme;
@@ -567,6 +618,11 @@ export async function preparePortal(
567
618
  {
568
619
  redirects: home ? [home] : [],
569
620
  docsNavigation: docsNavigation(hostedDocRoutes),
621
+ docsPath: pickLandingRoute(hostedDocRoutes),
622
+ demoPlans:
623
+ plan.demo && plan.plansPlugin
624
+ ? await readDemoPlans(resolved.plans?.file)
625
+ : undefined,
570
626
  protectedRoutes: protectedRoutesFor({
571
627
  requireAuth: resolved.portal?.requireAuth,
572
628
  hasAuth: !!plan.auth,