@lukoweb/apitogo 0.2.42 → 0.2.43

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,14 @@ 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_RUNTIME,
15
+ FULL_SERVER_RUNTIME_VERSION,
16
+ FULL_SERVER_SOURCE,
17
+ FULL_SPEC,
18
+ fullServerFiles,
19
+ } from "./full-server.js";
12
20
  import { docsFromPaths, starterPage } from "./init.js";
13
21
  import { detectExistingDocs } from "./layout.js";
14
22
  import { loadConfigV2 } from "./load.js";
@@ -129,8 +137,11 @@ export type AddOptions = {
129
137
  disabled?: boolean;
130
138
  /** Include every docs folder found instead of asking. */
131
139
  acceptAllDocs?: boolean;
132
- /** Content to create: `blank` is an empty shell, `starter` a filled example (portal, gateway and plans). */
133
- template?: "blank" | "starter";
140
+ /**
141
+ * Content to create: `blank` is an empty shell, `starter` a filled example (portal, gateway and plans), and `full`
142
+ * the starter plus a backend the platform runs, so the first deploy works without further steps.
143
+ */
144
+ template?: "blank" | "starter" | "full";
134
145
  portal?: {
135
146
  name?: string;
136
147
  docs?: string[] | false;
@@ -176,6 +187,10 @@ export type AddOptions = {
176
187
 
177
188
  export type PlannedFile = { path: string; content: string };
178
189
 
190
+ /** The templates that fill the new files with examples. */
191
+ const isFilled = (template: AddOptions["template"]) =>
192
+ template === "starter" || template === "full";
193
+
179
194
  export type ServiceEdit = {
180
195
  service: ServiceName;
181
196
  action: "add" | "remove";
@@ -397,7 +412,7 @@ async function buildPortal(
397
412
  const pages = path.join(resolved.root, "portal", "pages");
398
413
  if (template === "blank")
399
414
  await addFile(context, path.join(pages, ".gitkeep"), "");
400
- else if (options.template === "starter") {
415
+ else if (isFilled(options.template)) {
401
416
  // The starter has a full home page of its own, so the docs start at /introduction, where the page links to.
402
417
  await addFile(
403
418
  context,
@@ -447,20 +462,30 @@ async function buildPortal(
447
462
 
448
463
  /**
449
464
  * 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.
465
+ * keeps it in git) and the user is told it is empty; the full template writes a small Node.js server there instead.
466
+ * A folder that has files is left as it is. Returns whether the sample server was written.
451
467
  */
452
468
  async function createSourceFolder(
453
469
  context: Context,
454
470
  sourcePath: string,
455
- ): Promise<void> {
471
+ runtime: Runtime,
472
+ ): Promise<boolean> {
456
473
  const folder = path.join(context.dir, sourcePath);
457
474
  const entries = await readdir(folder).catch(() => [] as string[]);
458
- if (entries.some((name) => name !== ".gitkeep")) return;
475
+ if (entries.some((name) => name !== ".gitkeep")) return false;
476
+ if (context.options.template === "full" && runtime === FULL_SERVER_RUNTIME) {
477
+ for (const [file, content] of Object.entries(
478
+ fullServerFiles(context.resolved.project.name),
479
+ ))
480
+ await addFile(context, path.join(folder, file), content);
481
+ return true;
482
+ }
459
483
  if (entries.length === 0)
460
484
  await addFile(context, path.join(folder, ".gitkeep"), "");
461
485
  context.notes.push(
462
486
  `The folder ${sourcePath}/ is empty: put your backend code there before you deploy.`,
463
487
  );
488
+ return false;
464
489
  }
465
490
 
466
491
  async function buildGateway(
@@ -469,9 +494,11 @@ async function buildGateway(
469
494
  const { options, dir, resolved } = context;
470
495
  const o = options.gateway ?? {};
471
496
  const ask = options.ask;
497
+ // The full template runs a backend of its own, so it asks nothing about the origin.
498
+ const full = options.template === "full" && !o.url && o.origin !== "proxy";
472
499
  let mode =
473
500
  o.origin ??
474
- (o.url ? "proxy" : o.source || o.runtime ? "managed" : undefined);
501
+ (o.url ? "proxy" : o.source || o.runtime || full ? "managed" : undefined);
475
502
  if (!mode && ask) {
476
503
  mode = await ask.choose("How does the gateway reach your API?", [
477
504
  {
@@ -492,6 +519,8 @@ async function buildGateway(
492
519
  );
493
520
  }
494
521
  let origin: NonNullable<ConfigV2["gateway"]>["origin"];
522
+ // The sample server was written, so the sample OpenAPI file can describe its endpoints.
523
+ let sampleServer = false;
495
524
  if (mode === "proxy") {
496
525
  const url =
497
526
  o.url ??
@@ -506,6 +535,7 @@ async function buildGateway(
506
535
  } else {
507
536
  const source =
508
537
  o.source ??
538
+ (full ? FULL_SERVER_SOURCE : undefined) ??
509
539
  (ask
510
540
  ? await ask.text("Folder with your code, relative to the project", {
511
541
  default: "server",
@@ -513,6 +543,7 @@ async function buildGateway(
513
543
  : undefined);
514
544
  const runtime =
515
545
  o.runtime ??
546
+ (full ? FULL_SERVER_RUNTIME : undefined) ??
516
547
  (ask
517
548
  ? await ask.choose("Runtime of your code", [
518
549
  { value: "nodejs" as const, label: "Node.js" },
@@ -523,16 +554,18 @@ async function buildGateway(
523
554
  const runtimeVersion =
524
555
  o.runtimeVersion !== undefined && runtime
525
556
  ? 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;
557
+ : full && runtime === FULL_SERVER_RUNTIME
558
+ ? FULL_SERVER_RUNTIME_VERSION
559
+ : ask && runtime
560
+ ? await ask.choose(
561
+ "Runtime version",
562
+ RUNTIME_VERSIONS[runtime].map((choice, index) => ({
563
+ value: choice.value as string,
564
+ label:
565
+ index === 0 ? `${choice.label} (suggested)` : choice.label,
566
+ })),
567
+ )
568
+ : undefined;
536
569
  if (!source || !runtime || !runtimeVersion) {
537
570
  throw new ServiceEditError(
538
571
  "--origin managed needs --source, --runtime and --runtime-version.",
@@ -542,14 +575,15 @@ async function buildGateway(
542
575
  );
543
576
  }
544
577
  const sourcePath = insideProject(dir, source, "Source folder");
578
+ sampleServer = await createSourceFolder(context, sourcePath, runtime);
545
579
  origin = {
546
580
  mode,
547
581
  source: sourcePath,
548
582
  runtime,
549
583
  runtimeVersion,
550
584
  ...(o.exclude?.length ? { exclude: o.exclude } : {}),
585
+ ...(sampleServer ? { healthPath: FULL_SERVER_HEALTH_PATH } : {}),
551
586
  };
552
- await createSourceFolder(context, sourcePath);
553
587
  }
554
588
 
555
589
  let specs: string[];
@@ -591,7 +625,9 @@ async function buildGateway(
591
625
  target,
592
626
  options.template === "blank"
593
627
  ? BLANK_SPEC(resolved.project.name)
594
- : STARTER_SPEC(resolved.project.name),
628
+ : sampleServer
629
+ ? FULL_SPEC(resolved.project.name)
630
+ : STARTER_SPEC(resolved.project.name),
595
631
  );
596
632
  specs = [rel(dir, target)];
597
633
  } else {
@@ -706,7 +742,7 @@ async function buildPlans(
706
742
  o.template ??
707
743
  (options.template === "blank"
708
744
  ? "none"
709
- : options.template === "starter"
745
+ : isFilled(options.template)
710
746
  ? "starter"
711
747
  : "free");
712
748
  // 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,
@@ -1,70 +1,80 @@
1
- import { lstatSync, realpathSync } from "node:fs";
2
- import path from "node:path";
3
-
4
- /**
5
- * A config version 2 portal builds from `<state>/generated/`, where `content` is a link to the project folder.
6
- * That folder holds the state folder itself (`.apitogo/` or `apitogo/.state/`), which holds `generated/content`
7
- * again, so a file watcher that follows the link goes round forever (ENAMETOOLONG).
8
- */
9
- const STATE_INSIDE_CONTENT =
10
- /[\\/]content[\\/](?:\.apitogo|apitogo[\\/]\.state)(?:[\\/]|$)/;
11
-
12
- const within = (base: string, file: string) =>
13
- file === base || file.startsWith(base + path.sep);
14
-
15
- const unique = (paths: string[]) => [...new Set(paths)];
16
-
17
- /** Whether `root` is the site root (`<state>/generated`) of a config version 2 workspace: it has the `content` link. */
18
- function isWorkspace(root: string): boolean {
19
- try {
20
- return (
21
- path.basename(root) === "generated" &&
22
- lstatSync(path.join(root, "content")).isSymbolicLink()
23
- );
24
- } catch {
25
- return false;
26
- }
27
- }
28
-
29
- /**
30
- * The tenant's own folder of a config version 2 workspace (what `content` links to), or `undefined` for any other
31
- * project. The site root sits inside the state folder, so the folder that holds `node_modules` and the pages is
32
- * outside it and the dev server has to be told it may serve files from there.
33
- */
34
- export function tenantDir(root: string): string | undefined {
35
- return isWorkspace(root)
36
- ? realpathSync(path.join(root, "content"))
37
- : undefined;
38
- }
39
-
40
- /**
41
- * What the dev server's watcher must leave alone in a config version 2 workspace, or `undefined` for any other
42
- * project. The watcher meets the state folder twice: below the `content` link and, after resolving the link,
43
- * by its real path. So the whole state folder is skipped, except `generated/` itself (the site root) and the
44
- * tenant's own files.
45
- */
46
- export function stateIgnore(
47
- root: string,
48
- ): ((file: string) => boolean) | undefined {
49
- if (!isWorkspace(root)) return undefined;
50
-
51
- const state = path.dirname(root);
52
- const states = unique([state, realpathSync(state)]);
53
- const roots = unique([root, realpathSync(root)]);
54
-
55
- return (file) => {
56
- if (!states.some((base) => within(base, file))) return false;
57
- if (!roots.some((base) => within(base, file))) return true;
58
- return STATE_INSIDE_CONTENT.test(file);
59
- };
60
- }
61
-
62
- /**
63
- * The folders the dev server may serve files from. The site root of a config version 2 workspace sits inside the
64
- * state folder, so the package (`node_modules`) and the tenant's pages are outside it. Without them the page's main
65
- * script fails with "Failed to load url ... Does the file exist?" and the server stops.
66
- */
67
- export function devServerAllow(root: string, moduleDir: string): string[] {
68
- const tenant = tenantDir(root);
69
- return [root, path.dirname(root), moduleDir, ...(tenant ? [tenant] : [])];
70
- }
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * A config version 2 portal builds from `<state>/generated/`, where `content` is a link to the project folder.
6
+ * That folder holds the state folder itself (`.apitogo/` or `apitogo/.state/`), which holds `generated/content`
7
+ * again, so a file watcher that follows the link goes round forever (ENAMETOOLONG).
8
+ */
9
+ const STATE_INSIDE_CONTENT =
10
+ /[\\/]content[\\/](?:\.apitogo|apitogo[\\/]\.state)(?:[\\/]|$)/;
11
+
12
+ /**
13
+ * On Windows the site root can arrive as `C:/...` while the watcher reports `C:\...`, so both sides are resolved to
14
+ * the platform's form first; otherwise nothing matched and the watcher followed `content` until ELOOP.
15
+ */
16
+ const within = (base: string, file: string) => {
17
+ const resolvedBase = path.resolve(base);
18
+ const resolvedFile = path.resolve(file);
19
+ return (
20
+ resolvedFile === resolvedBase ||
21
+ resolvedFile.startsWith(resolvedBase + path.sep)
22
+ );
23
+ };
24
+
25
+ const unique = (paths: string[]) => [...new Set(paths)];
26
+
27
+ /** Whether `root` is the site root (`<state>/generated`) of a config version 2 workspace: it has the `content` link. */
28
+ function isWorkspace(root: string): boolean {
29
+ try {
30
+ return (
31
+ path.basename(root) === "generated" &&
32
+ lstatSync(path.join(root, "content")).isSymbolicLink()
33
+ );
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The tenant's own folder of a config version 2 workspace (what `content` links to), or `undefined` for any other
41
+ * project. The site root sits inside the state folder, so the folder that holds `node_modules` and the pages is
42
+ * outside it and the dev server has to be told it may serve files from there.
43
+ */
44
+ export function tenantDir(root: string): string | undefined {
45
+ return isWorkspace(root)
46
+ ? realpathSync(path.join(root, "content"))
47
+ : undefined;
48
+ }
49
+
50
+ /**
51
+ * What the dev server's watcher must leave alone in a config version 2 workspace, or `undefined` for any other
52
+ * project. The watcher meets the state folder twice: below the `content` link and, after resolving the link,
53
+ * by its real path. So the whole state folder is skipped, except `generated/` itself (the site root) and the
54
+ * tenant's own files.
55
+ */
56
+ export function stateIgnore(
57
+ root: string,
58
+ ): ((file: string) => boolean) | undefined {
59
+ if (!isWorkspace(root)) return undefined;
60
+
61
+ const state = path.dirname(root);
62
+ const states = unique([state, realpathSync(state)]);
63
+ const roots = unique([root, realpathSync(root)]);
64
+
65
+ return (file) => {
66
+ if (!states.some((base) => within(base, file))) return false;
67
+ if (!roots.some((base) => within(base, file))) return true;
68
+ return STATE_INSIDE_CONTENT.test(file);
69
+ };
70
+ }
71
+
72
+ /**
73
+ * The folders the dev server may serve files from. The site root of a config version 2 workspace sits inside the
74
+ * state folder, so the package (`node_modules`) and the tenant's pages are outside it. Without them the page's main
75
+ * script fails with "Failed to load url ... Does the file exist?" and the server stops.
76
+ */
77
+ export function devServerAllow(root: string, moduleDir: string): string[] {
78
+ const tenant = tenantDir(root);
79
+ return [root, path.dirname(root), moduleDir, ...(tenant ? [tenant] : [])];
80
+ }