@lukoweb/apitogo 0.2.31 → 0.2.34

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 (41) hide show
  1. package/dist/cli/cli.js +436 -153
  2. package/dist/declarations/app/entry.server.d.ts +2 -2
  3. package/dist/declarations/app/main.d.ts +3 -1
  4. package/dist/declarations/cli/platform/client/platform-api.d.ts +0 -4
  5. package/dist/declarations/cli/platform/helpers/deploy-artifact-hash.d.ts +1 -1
  6. package/dist/declarations/cli/platform/helpers/package-files.d.ts +1 -1
  7. package/dist/declarations/cli/platform/helpers/zip.d.ts +1 -1
  8. package/dist/declarations/cli/platform/tools/infrastructure.d.ts +0 -4
  9. package/dist/declarations/config/effective-redirects.d.ts +6 -0
  10. package/dist/declarations/config/header-navigation-guard.d.ts +2 -0
  11. package/dist/declarations/config/resolve-modules.d.ts +1 -4
  12. package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +1 -2
  13. package/docs/cli/commands.md +19 -0
  14. package/docs/configuration/gateway.md +52 -0
  15. package/docs/guides/customer-account.md +11 -1
  16. package/docs/mcp/tools.md +101 -102
  17. package/package.json +2 -1
  18. package/src/app/demo.tsx +78 -77
  19. package/src/app/entry.client.tsx +115 -107
  20. package/src/app/entry.server.tsx +164 -160
  21. package/src/app/main.tsx +175 -167
  22. package/src/app/standalone.tsx +61 -60
  23. package/src/config/effective-redirects.ts +31 -0
  24. package/src/config/header-navigation-guard.ts +13 -0
  25. package/src/config/resolve-modules.ts +331 -354
  26. package/src/config/v2/home-redirect.ts +104 -98
  27. package/src/config/v2/index.ts +127 -126
  28. package/src/config/v2/ledger.ts +242 -235
  29. package/src/config/v2/resolve.ts +12 -4
  30. package/src/config/v2/rules.ts +716 -714
  31. package/src/config/v2/services.ts +799 -663
  32. package/src/config/v2/workspace.ts +17 -1
  33. package/src/config/validators/HeaderNavigationSchema.ts +42 -44
  34. package/src/lib/assets/powered-by-icon.png +0 -0
  35. package/src/lib/authentication/providers/dev-portal-auth-pages.tsx +667 -602
  36. package/src/lib/authentication/providers/dev-portal.tsx +476 -448
  37. package/src/lib/components/HeaderNavigation.tsx +176 -176
  38. package/src/lib/components/MobileTopNavigation.tsx +283 -283
  39. package/src/lib/components/navigation/PoweredByZudoku.tsx +42 -39
  40. package/src/vite/plugin-offroot-resolve.ts +68 -67
  41. package/src/vite/prerender/prerender.ts +242 -242
@@ -1,663 +1,799 @@
1
- import { access, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import { ApitogoError } from "../../lib/util/invariant.js";
4
- import { docsFromPaths, starterPage } from "./init.js";
5
- import { detectExistingDocs } from "./layout.js";
6
- import { loadConfigV2 } from "./load.js";
7
- import { parseConfigV2 } from "./parse.js";
8
- import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
9
- import { checkDependencies } from "./rules.js";
10
- import type { ConfigV2 } from "./schema.js";
11
-
12
- export const SERVICES = ["portal", "gateway", "identity", "plans"] as const;
13
- export type ServiceName = (typeof SERVICES)[number];
14
-
15
- export class ServiceEditError extends ApitogoError {
16
- constructor(message: string, developerHint?: string) {
17
- super(message, { developerHint, title: "Cannot change the services" });
18
- this.name = "ServiceEditError";
19
- }
20
- }
21
-
22
- export function isServiceName(value: string): value is ServiceName {
23
- return (SERVICES as readonly string[]).includes(value);
24
- }
25
-
26
- /** Options for `apitogo add`, one group per service (Reference, section 1.3). Everything is optional here. */
27
- export type AddOptions = {
28
- dir: string;
29
- /** Add the section with `enabled: false`. */
30
- disabled?: boolean;
31
- /** Include every docs folder found instead of asking. */
32
- acceptAllDocs?: boolean;
33
- portal?: {
34
- name?: string;
35
- docs?: string[] | false;
36
- docsProvider?: "apitogo" | "external";
37
- docsUrl?: string;
38
- noConsole?: boolean;
39
- requireAuth?: boolean;
40
- search?: "pagefind" | "inkeep" | "none";
41
- };
42
- gateway?: {
43
- provider?: string;
44
- origin?: "managed" | "proxy";
45
- url?: string;
46
- source?: string;
47
- runtime?: "nodejs" | "python" | "dotnet";
48
- runtimeVersion?: string;
49
- exclude?: string[];
50
- spec?: string[];
51
- createSpec?: boolean;
52
- auth?: "identity" | "oidc";
53
- oidcMetadataUrl?: string;
54
- issuers?: string[];
55
- audiences?: string[];
56
- rateLimit?: string;
57
- quota?: string;
58
- cors?: string[];
59
- };
60
- identity?: {
61
- native?: boolean;
62
- provider?: "apitogo" | "oidc";
63
- issuer?: string;
64
- clientId?: string;
65
- connection?: string;
66
- authority?: string;
67
- connectionClientId?: string;
68
- };
69
- plans?: {
70
- file?: string;
71
- template?: "free" | "tiered" | "none";
72
- billing?: "stripe" | "none";
73
- };
74
- };
75
-
76
- export type PlannedFile = { path: string; content: string };
77
-
78
- export type ServiceEdit = {
79
- service: ServiceName;
80
- action: "add" | "remove";
81
- dir: string;
82
- configPath: string;
83
- /** The config that will be written. */
84
- config: ConfigV2;
85
- /** Files created by an add; files that already exist are listed in `keep` and never overwritten. */
86
- files: PlannedFile[];
87
- keep: string[];
88
- /** Human-readable follow-ups: secrets to set, files left behind after a remove. */
89
- notes: string[];
90
- };
91
-
92
- const exists = (target: string) =>
93
- access(target).then(
94
- () => true,
95
- () => false,
96
- );
97
-
98
- const rel = (dir: string, target: string) =>
99
- path.relative(dir, target).split(path.sep).join("/") || ".";
100
-
101
- const STARTER_SPEC = (name: string) => `openapi: 3.1.0
102
- info:
103
- title: ${JSON.stringify(name)}
104
- version: 1.0.0
105
- paths:
106
- /health:
107
- get:
108
- operationId: getHealth
109
- summary: Health check
110
- responses:
111
- "200":
112
- description: The service is up.
113
- `;
114
-
115
- const PLAN_TEMPLATES: Record<"free" | "tiered" | "none", unknown[]> = {
116
- none: [],
117
- free: [
118
- {
119
- sku: "free",
120
- displayName: "Free",
121
- default: true,
122
- limits: {
123
- // The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
124
- rateLimit: { calls: 3, periodSeconds: 60 },
125
- quota: { calls: 1000, periodDays: 30 },
126
- },
127
- },
128
- ],
129
- tiered: [
130
- {
131
- sku: "free",
132
- displayName: "Free",
133
- default: true,
134
- limits: {
135
- // The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
136
- rateLimit: { calls: 3, periodSeconds: 60 },
137
- quota: { calls: 1000, periodDays: 30 },
138
- },
139
- },
140
- {
141
- sku: "pro",
142
- displayName: "Pro",
143
- priceAmount: 49,
144
- priceCurrency: "usd",
145
- billingInterval: "month",
146
- limits: {
147
- rateLimit: { calls: 100, periodSeconds: 60 },
148
- quota: { calls: 100000, periodDays: 30 },
149
- },
150
- },
151
- {
152
- sku: "enterprise",
153
- displayName: "Enterprise",
154
- pricingModel: "custom",
155
- priceLabel: "Contact us",
156
- },
157
- ],
158
- };
159
-
160
- function pair(value: string, flag: string, unit: string): [number, number] {
161
- const match = /^(\d+)\/(\d+)$/.exec(value);
162
- if (!match) {
163
- throw new ServiceEditError(
164
- `${flag} must look like <calls>/<${unit}>, for example 100/60.`,
165
- );
166
- }
167
- return [Number(match[1]), Number(match[2])];
168
- }
169
-
170
- /** The path of a file inside the project, relative to the config, refusing anything outside it. */
171
- function insideProject(dir: string, requested: string, what: string): string {
172
- const absolute = path.resolve(dir, requested);
173
- const relative = path.relative(dir, absolute);
174
- if (
175
- relative === "" ||
176
- relative.startsWith("..") ||
177
- path.isAbsolute(relative)
178
- ) {
179
- throw new ServiceEditError(
180
- `${what} "${requested}" is outside the project directory.`,
181
- );
182
- }
183
- return relative.split(path.sep).join("/");
184
- }
185
-
186
- type Context = {
187
- dir: string;
188
- config: ConfigV2;
189
- resolved: ResolvedConfigV2;
190
- options: AddOptions;
191
- files: PlannedFile[];
192
- keep: string[];
193
- notes: string[];
194
- };
195
-
196
- async function addFile(context: Context, target: string, content: string) {
197
- if (await exists(target)) context.keep.push(target);
198
- else context.files.push({ path: target, content });
199
- }
200
-
201
- async function buildPortal(
202
- context: Context,
203
- ): Promise<NonNullable<ConfigV2["portal"]>> {
204
- const { options, dir, resolved } = context;
205
- const o = options.portal ?? {};
206
- const external = o.docsProvider === "external";
207
- if (!external && o.docsUrl) {
208
- throw new ServiceEditError("--docs-url needs --docs-provider external.");
209
- }
210
- if (external && !o.docsUrl) {
211
- throw new ServiceEditError(
212
- "--docs-provider external needs --docs-url <https url>.",
213
- );
214
- }
215
- const name = o.name?.trim() || resolved.project.name;
216
-
217
- let content: string[] = [];
218
- if (!external) {
219
- if (Array.isArray(o.docs) && o.docs.length > 0) {
220
- content = await docsFromPaths(dir, o.docs);
221
- } else if (o.docs !== false) {
222
- const candidates = await detectExistingDocs(dir);
223
- if (
224
- candidates.length === 1 ||
225
- (candidates.length > 1 && options.acceptAllDocs)
226
- ) {
227
- content = candidates.map((candidate) => candidate.glob);
228
- } else if (candidates.length > 1) {
229
- throw new ServiceEditError(
230
- `Several places with documentation were found (${candidates.map((c) => c.path).join(", ")}).`,
231
- "Pass --docs <path> for each one to include, --no-docs to include none, or --yes to include all.",
232
- );
233
- }
234
- }
235
- if (content.length === 0) {
236
- await addFile(
237
- context,
238
- path.join(resolved.root, "portal", "pages", "index.mdx"),
239
- starterPage(name),
240
- );
241
- }
242
- }
243
-
244
- return {
245
- ...(options.disabled ? { enabled: false } : {}),
246
- name,
247
- ...(external
248
- ? { docs: { provider: "external" as const, url: o.docsUrl } }
249
- : content.length > 0
250
- ? { docs: { content } }
251
- : {}),
252
- ...(o.noConsole ? { console: { enabled: false } } : {}),
253
- ...(o.requireAuth ? { requireAuth: true } : {}),
254
- ...(o.search && !external ? { search: o.search } : {}),
255
- };
256
- }
257
-
258
- async function buildGateway(
259
- context: Context,
260
- ): Promise<NonNullable<ConfigV2["gateway"]>> {
261
- const { options, dir, resolved } = context;
262
- const o = options.gateway ?? {};
263
- const mode =
264
- o.origin ??
265
- (o.url ? "proxy" : o.source || o.runtime ? "managed" : undefined);
266
- if (!mode) {
267
- throw new ServiceEditError(
268
- "The gateway needs an origin: say how it reaches your API.",
269
- "Pass --origin proxy --url <https url> for an API you host, or --origin managed --source <folder> --runtime <nodejs|python|dotnet> --runtime-version <version> to have it run your code.",
270
- );
271
- }
272
- let origin: NonNullable<ConfigV2["gateway"]>["origin"];
273
- if (mode === "proxy") {
274
- if (!o.url)
275
- throw new ServiceEditError("--origin proxy needs --url <https url>.");
276
- origin = { mode, url: o.url };
277
- } else {
278
- if (!o.source || !o.runtime || !o.runtimeVersion) {
279
- throw new ServiceEditError(
280
- "--origin managed needs --source, --runtime and --runtime-version.",
281
- "For example: --source server --runtime nodejs --runtime-version NODE_22_LTS.",
282
- );
283
- }
284
- origin = {
285
- mode,
286
- source: insideProject(dir, o.source, "Source folder"),
287
- runtime: o.runtime,
288
- runtimeVersion: o.runtimeVersion,
289
- ...(o.exclude?.length ? { exclude: o.exclude } : {}),
290
- };
291
- }
292
-
293
- let specs: string[];
294
- if (o.spec?.length) {
295
- specs = [];
296
- for (const requested of o.spec) {
297
- const relative = insideProject(dir, requested, "OpenAPI file");
298
- if (
299
- !(await stat(path.join(dir, relative)).then(
300
- (s) => s.isFile(),
301
- () => false,
302
- ))
303
- ) {
304
- throw new ServiceEditError(
305
- `OpenAPI file "${requested}" does not exist in ${dir}.`,
306
- );
307
- }
308
- specs.push(relative);
309
- }
310
- } else if (o.createSpec) {
311
- const target = path.join(resolved.apisDir, "openapi.yaml");
312
- await addFile(context, target, STARTER_SPEC(resolved.project.name));
313
- specs = [rel(dir, target)];
314
- } else {
315
- throw new ServiceEditError(
316
- "The gateway needs an OpenAPI file and none was given.",
317
- "Pass --spec <path> for an existing file, or --create-spec to start from a small one.",
318
- );
319
- }
320
-
321
- const auth = o.auth
322
- ? o.auth === "identity"
323
- ? { mode: "identity" as const }
324
- : {
325
- mode: "oidc" as const,
326
- ...(o.oidcMetadataUrl ? { metadataUrl: o.oidcMetadataUrl } : {}),
327
- ...(o.issuers?.length ? { issuers: o.issuers } : {}),
328
- ...(o.audiences?.length ? { audiences: o.audiences } : {}),
329
- }
330
- : undefined;
331
-
332
- const rateLimit = o.rateLimit
333
- ? pair(o.rateLimit, "--rate-limit", "seconds")
334
- : undefined;
335
- const quota = o.quota ? pair(o.quota, "--quota", "days") : undefined;
336
- const policies = {
337
- ...(rateLimit
338
- ? { rateLimit: { calls: rateLimit[0], periodSeconds: rateLimit[1] } }
339
- : {}),
340
- ...(quota ? { quota: { calls: quota[0], periodDays: quota[1] } } : {}),
341
- ...(o.cors?.length ? { cors: o.cors } : {}),
342
- };
343
-
344
- return {
345
- ...(options.disabled ? { enabled: false } : {}),
346
- ...(o.provider ? { provider: o.provider } : {}),
347
- origin,
348
- specs,
349
- ...(auth ? { auth } : {}),
350
- ...(Object.keys(policies).length > 0 ? { policies } : {}),
351
- };
352
- }
353
-
354
- function buildIdentity(context: Context): NonNullable<ConfigV2["identity"]> {
355
- const { options } = context;
356
- const o = options.identity ?? {};
357
- const provider = o.provider ?? "apitogo";
358
- if (provider === "oidc") {
359
- if (!o.issuer)
360
- throw new ServiceEditError("--provider oidc needs --issuer <https url>.");
361
- if (o.connection || o.native === false) {
362
- throw new ServiceEditError(
363
- "--no-native and --connection only apply to the apitogo identity provider.",
364
- );
365
- }
366
- return {
367
- ...(options.disabled ? { enabled: false } : {}),
368
- provider,
369
- issuer: o.issuer,
370
- ...(o.clientId ? { clientId: o.clientId } : {}),
371
- };
372
- }
373
- if (o.issuer || o.clientId) {
374
- throw new ServiceEditError(
375
- "--issuer and --client-id need --provider oidc.",
376
- );
377
- }
378
- const connections = o.connection
379
- ? (() => {
380
- if (!o.authority || !o.connectionClientId) {
381
- throw new ServiceEditError(
382
- "--connection needs --authority <https url> and --connection-client-id <id>.",
383
- );
384
- }
385
- const secret = `${o.connection.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_CLIENT_SECRET`;
386
- context.notes.push(
387
- `Set the connection's client secret: apitogo secrets set ${secret}`,
388
- );
389
- return [
390
- {
391
- id: o.connection,
392
- type: "oidc" as const,
393
- authority: o.authority,
394
- clientId: o.connectionClientId,
395
- clientSecret: `secret:${secret}`,
396
- },
397
- ];
398
- })()
399
- : undefined;
400
- return {
401
- ...(options.disabled ? { enabled: false } : {}),
402
- ...(provider !== "apitogo" ? { provider } : {}),
403
- ...(o.native === false ? { native: false } : {}),
404
- ...(connections ? { connections } : {}),
405
- };
406
- }
407
-
408
- async function buildPlans(
409
- context: Context,
410
- ): Promise<NonNullable<ConfigV2["plans"]>> {
411
- const { options, dir, resolved } = context;
412
- const o = options.plans ?? {};
413
- const template = o.template ?? "free";
414
- const billing = o.billing ?? "stripe";
415
- if (template === "tiered" && billing === "none") {
416
- throw new ServiceEditError(
417
- "The tiered template has paid plans, which need a billing provider.",
418
- "Use --billing stripe, or --template free.",
419
- );
420
- }
421
- const file = o.file ? insideProject(dir, o.file, "Plans file") : undefined;
422
- const target = file
423
- ? path.join(dir, file)
424
- : (resolved.plans?.file ?? path.join(resolved.root, "plans.json"));
425
- await addFile(
426
- context,
427
- target,
428
- `${JSON.stringify({ version: 1, plans: PLAN_TEMPLATES[template] }, null, 2)}\n`,
429
- );
430
- if (billing === "stripe") {
431
- context.notes.push(
432
- "Set the billing key: apitogo secrets set STRIPE_API_KEY",
433
- );
434
- }
435
- return {
436
- ...(options.disabled ? { enabled: false } : {}),
437
- ...(file ? { file } : {}),
438
- ...(billing === "stripe"
439
- ? {
440
- billing: {
441
- provider: "stripe" as const,
442
- apiKey: "secret:STRIPE_API_KEY",
443
- },
444
- }
445
- : { billing: { provider: "none" as const } }),
446
- };
447
- }
448
-
449
- function reparse(configPath: string, raw: ConfigV2): ConfigV2 {
450
- // The strict parser is the one source of truth for what a valid config is.
451
- return parseConfigV2(JSON.parse(JSON.stringify(raw)), configPath);
452
- }
453
-
454
- function dependencyProblems(config: ConfigV2, configPath: string) {
455
- return checkDependencies(config, resolveConfigV2(config, configPath));
456
- }
457
-
458
- /**
459
- * Decides what `add <service>` would write without touching the disk. The strict schema and the dependency rules
460
- * (Reference, section 2.1) run on the new config first, so a refused add changes nothing.
461
- */
462
- export async function planAddService(
463
- service: ServiceName,
464
- options: AddOptions,
465
- ): Promise<ServiceEdit> {
466
- const dir = path.resolve(options.dir);
467
- const { config, resolved } = loadConfigV2(dir);
468
- if (config[service] !== undefined) {
469
- throw new ServiceEditError(
470
- `${service} is already in ${path.basename(resolved.configPath)}.`,
471
- `Edit the file, or run \`apitogo remove ${service}\` first. Its files are kept.`,
472
- );
473
- }
474
-
475
- const context: Context = {
476
- dir,
477
- config,
478
- resolved,
479
- options,
480
- files: [],
481
- keep: [],
482
- notes: [],
483
- };
484
- const section =
485
- service === "portal"
486
- ? await buildPortal(context)
487
- : service === "gateway"
488
- ? await buildGateway(context)
489
- : service === "identity"
490
- ? buildIdentity(context)
491
- : await buildPlans(context);
492
-
493
- const next = reparse(resolved.configPath, { ...config, [service]: section });
494
- if (!options.disabled) {
495
- const before = new Set(
496
- dependencyProblems(config, resolved.configPath).map(
497
- (i) => i.code + i.path,
498
- ),
499
- );
500
- const added = dependencyProblems(next, resolved.configPath).filter(
501
- (issue) => !before.has(issue.code + issue.path),
502
- );
503
- if (added.length > 0) {
504
- throw new ServiceEditError(
505
- added.map((issue) => issue.message).join(" "),
506
- added
507
- .map((issue) => issue.hint)
508
- .filter(Boolean)
509
- .join(" ") || "Add the missing service first.",
510
- );
511
- }
512
- }
513
-
514
- return {
515
- service,
516
- action: "add",
517
- dir,
518
- configPath: resolved.configPath,
519
- config: next,
520
- files: context.files,
521
- keep: context.keep,
522
- notes: context.notes,
523
- };
524
- }
525
-
526
- /**
527
- * Decides what `remove <service>` would do: the section goes, every file stays. Refuses when another enabled
528
- * service would be left without something it needs.
529
- */
530
- export async function planRemoveService(
531
- service: ServiceName,
532
- options: { dir: string },
533
- ): Promise<ServiceEdit> {
534
- const dir = path.resolve(options.dir);
535
- const { config, resolved } = loadConfigV2(dir);
536
- if (config[service] === undefined) {
537
- throw new ServiceEditError(
538
- `${service} is not in ${path.basename(resolved.configPath)}.`,
539
- );
540
- }
541
- const { [service]: removed, ...rest } = config;
542
- const next = reparse(resolved.configPath, rest as ConfigV2);
543
-
544
- const before = new Set(
545
- dependencyProblems(config, resolved.configPath).map((i) => i.code + i.path),
546
- );
547
- const caused = dependencyProblems(next, resolved.configPath).filter(
548
- (issue) => !before.has(issue.code + issue.path),
549
- );
550
- if (caused.length > 0) {
551
- throw new ServiceEditError(
552
- `${service} cannot be removed while something depends on it: ${caused.map((issue) => issue.message).join(" ")}`,
553
- "Remove or change the dependent service first.",
554
- );
555
- }
556
-
557
- const left: string[] = [];
558
- const r = resolved;
559
- if (service === "portal" && r.portal) left.push(rel(dir, r.portal.dir));
560
- if (service === "plans" && r.plans) left.push(rel(dir, r.plans.file));
561
- if (service === "gateway" && r.gateway)
562
- left.push(...r.gateway.specs.map((s) => rel(dir, s)));
563
- void removed;
564
-
565
- return {
566
- service,
567
- action: "remove",
568
- dir,
569
- configPath: resolved.configPath,
570
- config: next,
571
- files: [],
572
- keep: [],
573
- notes: left.length > 0 ? [`Files are kept: ${left.join(", ")}.`] : [],
574
- };
575
- }
576
-
577
- /** Replaces the config file atomically: a failure leaves the old file in place. */
578
- async function writeConfig(
579
- configPath: string,
580
- config: ConfigV2,
581
- ): Promise<void> {
582
- const temporary = `${configPath}.${process.pid}.tmp`;
583
- try {
584
- await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8");
585
- await rename(temporary, configPath);
586
- } catch (error) {
587
- await rm(temporary, { force: true });
588
- throw error;
589
- }
590
- }
591
-
592
- /** Writes new files (never over existing ones), then replaces the config atomically. A failure leaves the old config. */
593
- export async function applyServiceEdit(
594
- edit: ServiceEdit,
595
- ): Promise<{ created: string[] }> {
596
- const created: string[] = [];
597
- for (const file of edit.files) {
598
- await mkdir(path.dirname(file.path), { recursive: true });
599
- try {
600
- await writeFile(file.path, file.content, { flag: "wx" });
601
- created.push(file.path);
602
- } catch (error) {
603
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
604
- }
605
- }
606
- await writeConfig(edit.configPath, edit.config);
607
- return { created };
608
- }
609
-
610
- /** The "will change" list shown before anything is written. */
611
- export function formatServiceEdit(edit: ServiceEdit): string[] {
612
- const verb = edit.action === "add" ? "Adds" : "Removes";
613
- return [
614
- `${verb} the ${edit.service} section in ${path.basename(edit.configPath)}.`,
615
- ...(edit.files.length > 0
616
- ? [
617
- "Will create:",
618
- ...edit.files.map((file) => ` ${rel(edit.dir, file.path)}`),
619
- ]
620
- : []),
621
- ...(edit.keep.length > 0
622
- ? [
623
- `Already there, left alone: ${edit.keep.map((file) => rel(edit.dir, file)).join(", ")}`,
624
- ]
625
- : []),
626
- ...edit.notes,
627
- ];
628
- }
629
-
630
- /**
631
- * Writes `project.id` into the config when it has none: `deploy` and `project create` link a folder to a platform
632
- * project (Reference, section 3.1). An id that is already there is never replaced.
633
- */
634
- export async function setProjectId(
635
- dir: string,
636
- projectId: string,
637
- ): Promise<boolean> {
638
- const { config, resolved } = loadConfigV2(dir);
639
- if (config.project?.id) return false;
640
- const next = reparse(resolved.configPath, {
641
- ...config,
642
- project: { ...config.project, id: projectId },
643
- });
644
- await writeConfig(resolved.configPath, next);
645
- return true;
646
- }
647
-
648
- /** Removes `project.id` when it is the given id: the project is gone, so the link would only point at nothing. */
649
- export async function clearProjectId(
650
- dir: string,
651
- projectId: string,
652
- ): Promise<boolean> {
653
- const { config, resolved } = loadConfigV2(dir);
654
- if (config.project?.id !== projectId) return false;
655
- const { id: _id, ...project } = config.project;
656
- const { project: _old, ...rest } = config;
657
- const next = reparse(resolved.configPath, {
658
- ...rest,
659
- ...(Object.keys(project).length > 0 ? { project } : {}),
660
- });
661
- await writeConfig(resolved.configPath, next);
662
- return true;
663
- }
1
+ import { access, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ApitogoError } from "../../lib/util/invariant.js";
4
+ import { docsFromPaths, starterPage } from "./init.js";
5
+ import { detectExistingDocs } from "./layout.js";
6
+ import { loadConfigV2 } from "./load.js";
7
+ import { parseConfigV2 } from "./parse.js";
8
+ import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
9
+ import { checkDependencies } from "./rules.js";
10
+ import type { ConfigV2 } from "./schema.js";
11
+
12
+ export const SERVICES = ["portal", "gateway", "identity", "plans"] as const;
13
+ export type ServiceName = (typeof SERVICES)[number];
14
+
15
+ export class ServiceEditError extends ApitogoError {
16
+ constructor(
17
+ message: string,
18
+ developerHint?: string,
19
+ /** Services the change needs and the project does not have yet; the command may offer to add them. */
20
+ public readonly missingServices: readonly ServiceName[] = [],
21
+ ) {
22
+ super(message, { developerHint, title: "Cannot change the services" });
23
+ this.name = "ServiceEditError";
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Asks the person at the keyboard for a value the flags did not give. A command that cannot ask (no terminal,
29
+ * `--json`, `--yes`) passes none, and a missing value is an error with a hint, as before.
30
+ */
31
+ export type Asker = {
32
+ text(
33
+ question: string,
34
+ options?: {
35
+ default?: string;
36
+ /** A problem with the value, or undefined when it is fine. The question is asked again on a problem. */
37
+ validate?: (value: string) => string | undefined;
38
+ },
39
+ ): Promise<string>;
40
+ choose<T extends string>(
41
+ question: string,
42
+ choices: readonly { value: T; label: string }[],
43
+ ): Promise<T>;
44
+ confirm(question: string, defaultYes?: boolean): Promise<boolean>;
45
+ };
46
+
47
+ const httpsProblem = (value: string) =>
48
+ /^https:\/\/\S+$/i.test(value)
49
+ ? undefined
50
+ : "The address must start with https://";
51
+
52
+ /** The runtime version a prompt suggests; the platform lists the others. */
53
+ const DEFAULT_RUNTIME_VERSION = {
54
+ nodejs: "NODE_22_LTS",
55
+ python: "PYTHON_312",
56
+ dotnet: "DOTNET_8",
57
+ } as const;
58
+
59
+ export function isServiceName(value: string): value is ServiceName {
60
+ return (SERVICES as readonly string[]).includes(value);
61
+ }
62
+
63
+ /** Options for `apitogo add`, one group per service (Reference, section 1.3). Everything is optional here. */
64
+ export type AddOptions = {
65
+ dir: string;
66
+ /** Asks for what is missing instead of failing. */
67
+ ask?: Asker;
68
+ /** Add the section with `enabled: false`. */
69
+ disabled?: boolean;
70
+ /** Include every docs folder found instead of asking. */
71
+ acceptAllDocs?: boolean;
72
+ portal?: {
73
+ name?: string;
74
+ docs?: string[] | false;
75
+ docsProvider?: "apitogo" | "external";
76
+ docsUrl?: string;
77
+ noConsole?: boolean;
78
+ requireAuth?: boolean;
79
+ search?: "pagefind" | "inkeep" | "none";
80
+ };
81
+ gateway?: {
82
+ provider?: string;
83
+ origin?: "managed" | "proxy";
84
+ url?: string;
85
+ source?: string;
86
+ runtime?: "nodejs" | "python" | "dotnet";
87
+ runtimeVersion?: string;
88
+ exclude?: string[];
89
+ spec?: string[];
90
+ createSpec?: boolean;
91
+ auth?: "identity" | "oidc";
92
+ oidcMetadataUrl?: string;
93
+ issuers?: string[];
94
+ audiences?: string[];
95
+ rateLimit?: string;
96
+ quota?: string;
97
+ cors?: string[];
98
+ };
99
+ identity?: {
100
+ native?: boolean;
101
+ provider?: "apitogo" | "oidc";
102
+ issuer?: string;
103
+ clientId?: string;
104
+ connection?: string;
105
+ authority?: string;
106
+ connectionClientId?: string;
107
+ };
108
+ plans?: {
109
+ file?: string;
110
+ template?: "free" | "tiered" | "none";
111
+ billing?: "stripe" | "none";
112
+ };
113
+ };
114
+
115
+ export type PlannedFile = { path: string; content: string };
116
+
117
+ export type ServiceEdit = {
118
+ service: ServiceName;
119
+ action: "add" | "remove";
120
+ dir: string;
121
+ configPath: string;
122
+ /** The config that will be written. */
123
+ config: ConfigV2;
124
+ /** Files created by an add; files that already exist are listed in `keep` and never overwritten. */
125
+ files: PlannedFile[];
126
+ keep: string[];
127
+ /** Human-readable follow-ups: secrets to set, files left behind after a remove. */
128
+ notes: string[];
129
+ };
130
+
131
+ const exists = (target: string) =>
132
+ access(target).then(
133
+ () => true,
134
+ () => false,
135
+ );
136
+
137
+ const rel = (dir: string, target: string) =>
138
+ path.relative(dir, target).split(path.sep).join("/") || ".";
139
+
140
+ const STARTER_SPEC = (name: string) => `openapi: 3.1.0
141
+ info:
142
+ title: ${JSON.stringify(name)}
143
+ version: 1.0.0
144
+ paths:
145
+ /health:
146
+ get:
147
+ operationId: getHealth
148
+ summary: Health check
149
+ responses:
150
+ "200":
151
+ description: The service is up.
152
+ `;
153
+
154
+ const PLAN_TEMPLATES: Record<"free" | "tiered" | "none", unknown[]> = {
155
+ none: [],
156
+ free: [
157
+ {
158
+ sku: "free",
159
+ displayName: "Free",
160
+ default: true,
161
+ limits: {
162
+ // The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
163
+ rateLimit: { calls: 3, periodSeconds: 60 },
164
+ quota: { calls: 1000, periodDays: 30 },
165
+ },
166
+ },
167
+ ],
168
+ tiered: [
169
+ {
170
+ sku: "free",
171
+ displayName: "Free",
172
+ default: true,
173
+ limits: {
174
+ // The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
175
+ rateLimit: { calls: 3, periodSeconds: 60 },
176
+ quota: { calls: 1000, periodDays: 30 },
177
+ },
178
+ },
179
+ {
180
+ sku: "pro",
181
+ displayName: "Pro",
182
+ priceAmount: 49,
183
+ priceCurrency: "usd",
184
+ billingInterval: "month",
185
+ limits: {
186
+ rateLimit: { calls: 100, periodSeconds: 60 },
187
+ quota: { calls: 100000, periodDays: 30 },
188
+ },
189
+ },
190
+ {
191
+ sku: "enterprise",
192
+ displayName: "Enterprise",
193
+ pricingModel: "custom",
194
+ priceLabel: "Contact us",
195
+ },
196
+ ],
197
+ };
198
+
199
+ function pair(value: string, flag: string, unit: string): [number, number] {
200
+ const match = /^(\d+)\/(\d+)$/.exec(value);
201
+ if (!match) {
202
+ throw new ServiceEditError(
203
+ `${flag} must look like <calls>/<${unit}>, for example 100/60.`,
204
+ );
205
+ }
206
+ return [Number(match[1]), Number(match[2])];
207
+ }
208
+
209
+ /** The path of a file inside the project, relative to the config, refusing anything outside it. */
210
+ function insideProject(dir: string, requested: string, what: string): string {
211
+ const absolute = path.resolve(dir, requested);
212
+ const relative = path.relative(dir, absolute);
213
+ if (
214
+ relative === "" ||
215
+ relative.startsWith("..") ||
216
+ path.isAbsolute(relative)
217
+ ) {
218
+ throw new ServiceEditError(
219
+ `${what} "${requested}" is outside the project directory.`,
220
+ );
221
+ }
222
+ return relative.split(path.sep).join("/");
223
+ }
224
+
225
+ type Context = {
226
+ dir: string;
227
+ config: ConfigV2;
228
+ resolved: ResolvedConfigV2;
229
+ options: AddOptions;
230
+ files: PlannedFile[];
231
+ keep: string[];
232
+ notes: string[];
233
+ };
234
+
235
+ async function addFile(context: Context, target: string, content: string) {
236
+ if (await exists(target)) context.keep.push(target);
237
+ else context.files.push({ path: target, content });
238
+ }
239
+
240
+ async function buildPortal(
241
+ context: Context,
242
+ ): Promise<NonNullable<ConfigV2["portal"]>> {
243
+ const { options, dir, resolved } = context;
244
+ const o = options.portal ?? {};
245
+ const external = o.docsProvider === "external";
246
+ if (!external && o.docsUrl) {
247
+ throw new ServiceEditError("--docs-url needs --docs-provider external.");
248
+ }
249
+ const docsUrl =
250
+ o.docsUrl ??
251
+ (external && options.ask
252
+ ? await options.ask.text("HTTPS address of your docs site", {
253
+ validate: httpsProblem,
254
+ })
255
+ : undefined);
256
+ if (external && !docsUrl) {
257
+ throw new ServiceEditError(
258
+ "--docs-provider external needs --docs-url <https url>.",
259
+ );
260
+ }
261
+ const name = o.name?.trim() || resolved.project.name;
262
+
263
+ let content: string[] = [];
264
+ if (!external) {
265
+ if (Array.isArray(o.docs) && o.docs.length > 0) {
266
+ content = await docsFromPaths(dir, o.docs);
267
+ } else if (o.docs !== false) {
268
+ const candidates = await detectExistingDocs(dir);
269
+ if (
270
+ candidates.length === 1 ||
271
+ (candidates.length > 1 && options.acceptAllDocs)
272
+ ) {
273
+ content = candidates.map((candidate) => candidate.glob);
274
+ } else if (candidates.length > 1 && options.ask) {
275
+ const places = candidates.map((c) => c.path).join(", ");
276
+ if (
277
+ await options.ask.confirm(
278
+ `Several places with documentation were found (${places}). Include all of them?`,
279
+ true,
280
+ )
281
+ )
282
+ content = candidates.map((candidate) => candidate.glob);
283
+ } else if (candidates.length > 1) {
284
+ throw new ServiceEditError(
285
+ `Several places with documentation were found (${candidates.map((c) => c.path).join(", ")}).`,
286
+ "Pass --docs <path> for each one to include, --no-docs to include none, or --yes to include all.",
287
+ );
288
+ }
289
+ }
290
+ if (content.length === 0) {
291
+ await addFile(
292
+ context,
293
+ path.join(resolved.root, "portal", "pages", "index.mdx"),
294
+ starterPage(name),
295
+ );
296
+ }
297
+ }
298
+
299
+ return {
300
+ ...(options.disabled ? { enabled: false } : {}),
301
+ name,
302
+ ...(external
303
+ ? { docs: { provider: "external" as const, url: docsUrl } }
304
+ : content.length > 0
305
+ ? { docs: { content } }
306
+ : {}),
307
+ ...(o.noConsole ? { console: { enabled: false } } : {}),
308
+ ...(o.requireAuth ? { requireAuth: true } : {}),
309
+ ...(o.search && !external ? { search: o.search } : {}),
310
+ };
311
+ }
312
+
313
+ async function buildGateway(
314
+ context: Context,
315
+ ): Promise<NonNullable<ConfigV2["gateway"]>> {
316
+ const { options, dir, resolved } = context;
317
+ const o = options.gateway ?? {};
318
+ const ask = options.ask;
319
+ let mode =
320
+ o.origin ??
321
+ (o.url ? "proxy" : o.source || o.runtime ? "managed" : undefined);
322
+ if (!mode && ask) {
323
+ mode = await ask.choose("How does the gateway reach your API?", [
324
+ {
325
+ value: "proxy" as const,
326
+ label:
327
+ "My API runs somewhere else: the gateway forwards calls to its https address",
328
+ },
329
+ {
330
+ value: "managed" as const,
331
+ label: "The platform runs my code (nodejs, python or dotnet)",
332
+ },
333
+ ]);
334
+ }
335
+ if (!mode) {
336
+ throw new ServiceEditError(
337
+ "The gateway needs an origin: say how it reaches your API.",
338
+ "Pass --origin proxy --url <https url> for an API you host, or --origin managed --source <folder> --runtime <nodejs|python|dotnet> --runtime-version <version> to have it run your code.",
339
+ );
340
+ }
341
+ let origin: NonNullable<ConfigV2["gateway"]>["origin"];
342
+ if (mode === "proxy") {
343
+ const url =
344
+ o.url ??
345
+ (ask
346
+ ? await ask.text("https address of your API", {
347
+ validate: httpsProblem,
348
+ })
349
+ : undefined);
350
+ if (!url)
351
+ throw new ServiceEditError("--origin proxy needs --url <https url>.");
352
+ origin = { mode, url };
353
+ } else {
354
+ const source =
355
+ o.source ??
356
+ (ask
357
+ ? await ask.text("Folder with your code, relative to the project", {
358
+ default: "server",
359
+ })
360
+ : undefined);
361
+ const runtime =
362
+ o.runtime ??
363
+ (ask
364
+ ? await ask.choose("Runtime of your code", [
365
+ { value: "nodejs" as const, label: "Node.js" },
366
+ { value: "python" as const, label: "Python" },
367
+ { value: "dotnet" as const, label: ".NET" },
368
+ ])
369
+ : undefined);
370
+ const runtimeVersion =
371
+ o.runtimeVersion ??
372
+ (ask && runtime
373
+ ? await ask.text("Runtime version", {
374
+ default: DEFAULT_RUNTIME_VERSION[runtime],
375
+ })
376
+ : undefined);
377
+ if (!source || !runtime || !runtimeVersion) {
378
+ throw new ServiceEditError(
379
+ "--origin managed needs --source, --runtime and --runtime-version.",
380
+ "For example: --source server --runtime nodejs --runtime-version NODE_22_LTS.",
381
+ );
382
+ }
383
+ origin = {
384
+ mode,
385
+ source: insideProject(dir, source, "Source folder"),
386
+ runtime,
387
+ runtimeVersion,
388
+ ...(o.exclude?.length ? { exclude: o.exclude } : {}),
389
+ };
390
+ }
391
+
392
+ let specs: string[];
393
+ let requestedSpecs = o.spec ?? [];
394
+ let createSpec = o.createSpec === true;
395
+ if (requestedSpecs.length === 0 && !createSpec && ask) {
396
+ const answer = await ask.choose("OpenAPI file of your API", [
397
+ { value: "create", label: "Start from a small file the command creates" },
398
+ { value: "existing", label: "Use a file I already have" },
399
+ ]);
400
+ if (answer === "create") createSpec = true;
401
+ else
402
+ requestedSpecs = [
403
+ await ask.text("Path of your OpenAPI file, relative to the project"),
404
+ ];
405
+ }
406
+ if (requestedSpecs.length > 0) {
407
+ specs = [];
408
+ for (const requested of requestedSpecs) {
409
+ const relative = insideProject(dir, requested, "OpenAPI file");
410
+ if (
411
+ !(await stat(path.join(dir, relative)).then(
412
+ (s) => s.isFile(),
413
+ () => false,
414
+ ))
415
+ ) {
416
+ throw new ServiceEditError(
417
+ `OpenAPI file "${requested}" does not exist in ${dir}.`,
418
+ );
419
+ }
420
+ specs.push(relative);
421
+ }
422
+ } else if (createSpec) {
423
+ const target = path.join(resolved.apisDir, "openapi.yaml");
424
+ await addFile(context, target, STARTER_SPEC(resolved.project.name));
425
+ specs = [rel(dir, target)];
426
+ } else {
427
+ throw new ServiceEditError(
428
+ "The gateway needs an OpenAPI file and none was given.",
429
+ "Pass --spec <path> for an existing file, or --create-spec to start from a small one.",
430
+ );
431
+ }
432
+
433
+ const auth = o.auth
434
+ ? o.auth === "identity"
435
+ ? { mode: "identity" as const }
436
+ : {
437
+ mode: "oidc" as const,
438
+ ...(o.oidcMetadataUrl ? { metadataUrl: o.oidcMetadataUrl } : {}),
439
+ ...(o.issuers?.length ? { issuers: o.issuers } : {}),
440
+ ...(o.audiences?.length ? { audiences: o.audiences } : {}),
441
+ }
442
+ : undefined;
443
+
444
+ const rateLimit = o.rateLimit
445
+ ? pair(o.rateLimit, "--rate-limit", "seconds")
446
+ : undefined;
447
+ const quota = o.quota ? pair(o.quota, "--quota", "days") : undefined;
448
+ const policies = {
449
+ ...(rateLimit
450
+ ? { rateLimit: { calls: rateLimit[0], periodSeconds: rateLimit[1] } }
451
+ : {}),
452
+ ...(quota ? { quota: { calls: quota[0], periodDays: quota[1] } } : {}),
453
+ ...(o.cors?.length ? { cors: o.cors } : {}),
454
+ };
455
+
456
+ return {
457
+ ...(options.disabled ? { enabled: false } : {}),
458
+ ...(o.provider ? { provider: o.provider } : {}),
459
+ origin,
460
+ specs,
461
+ ...(auth ? { auth } : {}),
462
+ ...(Object.keys(policies).length > 0 ? { policies } : {}),
463
+ };
464
+ }
465
+
466
+ async function buildIdentity(
467
+ context: Context,
468
+ ): Promise<NonNullable<ConfigV2["identity"]>> {
469
+ const { options } = context;
470
+ const o = options.identity ?? {};
471
+ const provider = o.provider ?? "apitogo";
472
+ if (provider === "oidc") {
473
+ const issuer =
474
+ o.issuer ??
475
+ (options.ask
476
+ ? await options.ask.text("https address of your OIDC issuer", {
477
+ validate: httpsProblem,
478
+ })
479
+ : undefined);
480
+ if (!issuer)
481
+ throw new ServiceEditError("--provider oidc needs --issuer <https url>.");
482
+ if (o.connection || o.native === false) {
483
+ throw new ServiceEditError(
484
+ "--no-native and --connection only apply to the apitogo identity provider.",
485
+ );
486
+ }
487
+ return {
488
+ ...(options.disabled ? { enabled: false } : {}),
489
+ provider,
490
+ issuer,
491
+ ...(o.clientId ? { clientId: o.clientId } : {}),
492
+ };
493
+ }
494
+ if (o.issuer || o.clientId) {
495
+ throw new ServiceEditError(
496
+ "--issuer and --client-id need --provider oidc.",
497
+ );
498
+ }
499
+ const connections = o.connection
500
+ ? (() => {
501
+ if (!o.authority || !o.connectionClientId) {
502
+ throw new ServiceEditError(
503
+ "--connection needs --authority <https url> and --connection-client-id <id>.",
504
+ );
505
+ }
506
+ const secret = `${o.connection.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_CLIENT_SECRET`;
507
+ context.notes.push(
508
+ `Set the connection's client secret: apitogo secrets set ${secret}`,
509
+ );
510
+ return [
511
+ {
512
+ id: o.connection,
513
+ type: "oidc" as const,
514
+ authority: o.authority,
515
+ clientId: o.connectionClientId,
516
+ clientSecret: `secret:${secret}`,
517
+ },
518
+ ];
519
+ })()
520
+ : undefined;
521
+ return {
522
+ ...(options.disabled ? { enabled: false } : {}),
523
+ ...(provider !== "apitogo" ? { provider } : {}),
524
+ ...(o.native === false ? { native: false } : {}),
525
+ ...(connections ? { connections } : {}),
526
+ };
527
+ }
528
+
529
+ async function buildPlans(
530
+ context: Context,
531
+ ): Promise<NonNullable<ConfigV2["plans"]>> {
532
+ const { options, dir, resolved } = context;
533
+ const o = options.plans ?? {};
534
+ const template = o.template ?? "free";
535
+ const billing = o.billing ?? "stripe";
536
+ if (template === "tiered" && billing === "none") {
537
+ throw new ServiceEditError(
538
+ "The tiered template has paid plans, which need a billing provider.",
539
+ "Use --billing stripe, or --template free.",
540
+ );
541
+ }
542
+ const file = o.file ? insideProject(dir, o.file, "Plans file") : undefined;
543
+ const target = file
544
+ ? path.join(dir, file)
545
+ : (resolved.plans?.file ?? path.join(resolved.root, "plans.json"));
546
+ await addFile(
547
+ context,
548
+ target,
549
+ `${JSON.stringify({ version: 1, plans: PLAN_TEMPLATES[template] }, null, 2)}\n`,
550
+ );
551
+ if (billing === "stripe") {
552
+ context.notes.push(
553
+ "Set the billing key: apitogo secrets set STRIPE_API_KEY",
554
+ );
555
+ }
556
+ return {
557
+ ...(options.disabled ? { enabled: false } : {}),
558
+ ...(file ? { file } : {}),
559
+ ...(billing === "stripe"
560
+ ? {
561
+ billing: {
562
+ provider: "stripe" as const,
563
+ apiKey: "secret:STRIPE_API_KEY",
564
+ },
565
+ }
566
+ : { billing: { provider: "none" as const } }),
567
+ };
568
+ }
569
+
570
+ function reparse(configPath: string, raw: ConfigV2): ConfigV2 {
571
+ // The strict parser is the one source of truth for what a valid config is.
572
+ return parseConfigV2(JSON.parse(JSON.stringify(raw)), configPath);
573
+ }
574
+
575
+ /** Which services the dependency problems ask for (Reference, section 2.1). */
576
+ function missingServicesOf(issues: { code: string }[]): ServiceName[] {
577
+ const missing = new Set<ServiceName>();
578
+ for (const { code } of issues) {
579
+ if (code === "PLANS_GATEWAY_REQUIRED") missing.add("gateway");
580
+ if (
581
+ code === "PLANS_IDENTITY_REQUIRED" ||
582
+ code === "PORTAL_CAPABILITY_MISSING"
583
+ )
584
+ missing.add("identity");
585
+ }
586
+ return [...missing];
587
+ }
588
+
589
+ function dependencyProblems(config: ConfigV2, configPath: string) {
590
+ return checkDependencies(config, resolveConfigV2(config, configPath));
591
+ }
592
+
593
+ /**
594
+ * Decides what `add <service>` would write without touching the disk. The strict schema and the dependency rules
595
+ * (Reference, section 2.1) run on the new config first, so a refused add changes nothing.
596
+ */
597
+ export async function planAddService(
598
+ service: ServiceName,
599
+ options: AddOptions,
600
+ ): Promise<ServiceEdit> {
601
+ const dir = path.resolve(options.dir);
602
+ const { config, resolved } = loadConfigV2(dir);
603
+ if (config[service] !== undefined) {
604
+ throw new ServiceEditError(
605
+ `${service} is already in ${path.basename(resolved.configPath)}.`,
606
+ `Edit the file, or run \`apitogo remove ${service}\` first. Its files are kept.`,
607
+ );
608
+ }
609
+
610
+ const context: Context = {
611
+ dir,
612
+ config,
613
+ resolved,
614
+ options,
615
+ files: [],
616
+ keep: [],
617
+ notes: [],
618
+ };
619
+ const section =
620
+ service === "portal"
621
+ ? await buildPortal(context)
622
+ : service === "gateway"
623
+ ? await buildGateway(context)
624
+ : service === "identity"
625
+ ? await buildIdentity(context)
626
+ : await buildPlans(context);
627
+
628
+ const next = reparse(resolved.configPath, { ...config, [service]: section });
629
+ if (!options.disabled) {
630
+ const before = new Set(
631
+ dependencyProblems(config, resolved.configPath).map(
632
+ (i) => i.code + i.path,
633
+ ),
634
+ );
635
+ const added = dependencyProblems(next, resolved.configPath).filter(
636
+ (issue) => !before.has(issue.code + issue.path),
637
+ );
638
+ if (added.length > 0) {
639
+ throw new ServiceEditError(
640
+ added.map((issue) => issue.message).join(" "),
641
+ added
642
+ .map((issue) => issue.hint)
643
+ .filter(Boolean)
644
+ .join(" ") || "Add the missing service first.",
645
+ missingServicesOf(added),
646
+ );
647
+ }
648
+ }
649
+
650
+ return {
651
+ service,
652
+ action: "add",
653
+ dir,
654
+ configPath: resolved.configPath,
655
+ config: next,
656
+ files: context.files,
657
+ keep: context.keep,
658
+ notes: context.notes,
659
+ };
660
+ }
661
+
662
+ /**
663
+ * Decides what `remove <service>` would do: the section goes, every file stays. Refuses when another enabled
664
+ * service would be left without something it needs.
665
+ */
666
+ export async function planRemoveService(
667
+ service: ServiceName,
668
+ options: { dir: string },
669
+ ): Promise<ServiceEdit> {
670
+ const dir = path.resolve(options.dir);
671
+ const { config, resolved } = loadConfigV2(dir);
672
+ if (config[service] === undefined) {
673
+ throw new ServiceEditError(
674
+ `${service} is not in ${path.basename(resolved.configPath)}.`,
675
+ );
676
+ }
677
+ const { [service]: removed, ...rest } = config;
678
+ const next = reparse(resolved.configPath, rest as ConfigV2);
679
+
680
+ const before = new Set(
681
+ dependencyProblems(config, resolved.configPath).map((i) => i.code + i.path),
682
+ );
683
+ const caused = dependencyProblems(next, resolved.configPath).filter(
684
+ (issue) => !before.has(issue.code + issue.path),
685
+ );
686
+ if (caused.length > 0) {
687
+ throw new ServiceEditError(
688
+ `${service} cannot be removed while something depends on it: ${caused.map((issue) => issue.message).join(" ")}`,
689
+ "Remove or change the dependent service first.",
690
+ );
691
+ }
692
+
693
+ const left: string[] = [];
694
+ 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)));
699
+ void removed;
700
+
701
+ return {
702
+ service,
703
+ action: "remove",
704
+ dir,
705
+ configPath: resolved.configPath,
706
+ config: next,
707
+ files: [],
708
+ keep: [],
709
+ notes: left.length > 0 ? [`Files are kept: ${left.join(", ")}.`] : [],
710
+ };
711
+ }
712
+
713
+ /** Replaces the config file atomically: a failure leaves the old file in place. */
714
+ async function writeConfig(
715
+ configPath: string,
716
+ config: ConfigV2,
717
+ ): Promise<void> {
718
+ const temporary = `${configPath}.${process.pid}.tmp`;
719
+ try {
720
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8");
721
+ await rename(temporary, configPath);
722
+ } catch (error) {
723
+ await rm(temporary, { force: true });
724
+ throw error;
725
+ }
726
+ }
727
+
728
+ /** Writes new files (never over existing ones), then replaces the config atomically. A failure leaves the old config. */
729
+ export async function applyServiceEdit(
730
+ edit: ServiceEdit,
731
+ ): Promise<{ created: string[] }> {
732
+ const created: string[] = [];
733
+ for (const file of edit.files) {
734
+ await mkdir(path.dirname(file.path), { recursive: true });
735
+ try {
736
+ await writeFile(file.path, file.content, { flag: "wx" });
737
+ created.push(file.path);
738
+ } catch (error) {
739
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
740
+ }
741
+ }
742
+ await writeConfig(edit.configPath, edit.config);
743
+ return { created };
744
+ }
745
+
746
+ /** The "will change" list shown before anything is written. */
747
+ export function formatServiceEdit(edit: ServiceEdit): string[] {
748
+ const verb = edit.action === "add" ? "Adds" : "Removes";
749
+ return [
750
+ `${verb} the ${edit.service} section in ${path.basename(edit.configPath)}.`,
751
+ ...(edit.files.length > 0
752
+ ? [
753
+ "Will create:",
754
+ ...edit.files.map((file) => ` ${rel(edit.dir, file.path)}`),
755
+ ]
756
+ : []),
757
+ ...(edit.keep.length > 0
758
+ ? [
759
+ `Already there, left alone: ${edit.keep.map((file) => rel(edit.dir, file)).join(", ")}`,
760
+ ]
761
+ : []),
762
+ ...edit.notes,
763
+ ];
764
+ }
765
+
766
+ /**
767
+ * Writes `project.id` into the config when it has none: `deploy` and `project create` link a folder to a platform
768
+ * project (Reference, section 3.1). An id that is already there is never replaced.
769
+ */
770
+ export async function setProjectId(
771
+ dir: string,
772
+ projectId: string,
773
+ ): Promise<boolean> {
774
+ const { config, resolved } = loadConfigV2(dir);
775
+ if (config.project?.id) return false;
776
+ const next = reparse(resolved.configPath, {
777
+ ...config,
778
+ project: { ...config.project, id: projectId },
779
+ });
780
+ await writeConfig(resolved.configPath, next);
781
+ return true;
782
+ }
783
+
784
+ /** Removes `project.id` when it is the given id: the project is gone, so the link would only point at nothing. */
785
+ export async function clearProjectId(
786
+ dir: string,
787
+ projectId: string,
788
+ ): Promise<boolean> {
789
+ const { config, resolved } = loadConfigV2(dir);
790
+ if (config.project?.id !== projectId) return false;
791
+ const { id: _id, ...project } = config.project;
792
+ const { project: _old, ...rest } = config;
793
+ const next = reparse(resolved.configPath, {
794
+ ...rest,
795
+ ...(Object.keys(project).length > 0 ? { project } : {}),
796
+ });
797
+ await writeConfig(resolved.configPath, next);
798
+ return true;
799
+ }