@lukoweb/apitogo 0.2.31 → 0.2.35
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.
- package/dist/cli/cli.js +440 -153
- package/dist/declarations/app/entry.server.d.ts +2 -2
- package/dist/declarations/app/main.d.ts +3 -1
- package/dist/declarations/cli/platform/client/platform-api.d.ts +0 -4
- package/dist/declarations/cli/platform/helpers/deploy-artifact-hash.d.ts +1 -1
- package/dist/declarations/cli/platform/helpers/package-files.d.ts +1 -1
- package/dist/declarations/cli/platform/helpers/zip.d.ts +1 -1
- package/dist/declarations/cli/platform/tools/infrastructure.d.ts +0 -4
- package/dist/declarations/config/effective-redirects.d.ts +6 -0
- package/dist/declarations/config/header-navigation-guard.d.ts +2 -0
- package/dist/declarations/config/resolve-modules.d.ts +1 -4
- package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +1 -2
- package/docs/cli/commands.md +19 -0
- package/docs/configuration/gateway.md +52 -0
- package/docs/guides/customer-account.md +11 -1
- package/docs/mcp/tools.md +101 -102
- package/package.json +2 -1
- package/src/app/demo.tsx +78 -77
- package/src/app/entry.client.tsx +115 -107
- package/src/app/entry.server.tsx +164 -160
- package/src/app/main.tsx +175 -167
- package/src/app/standalone.tsx +61 -60
- package/src/config/effective-redirects.ts +31 -0
- package/src/config/header-navigation-guard.ts +13 -0
- package/src/config/resolve-modules.ts +331 -354
- package/src/config/v2/home-redirect.ts +104 -98
- package/src/config/v2/index.ts +127 -126
- package/src/config/v2/ledger.ts +242 -235
- package/src/config/v2/resolve.ts +12 -4
- package/src/config/v2/rules.ts +716 -714
- package/src/config/v2/services.ts +799 -663
- package/src/config/v2/workspace.ts +17 -1
- package/src/config/validators/HeaderNavigationSchema.ts +42 -44
- package/src/lib/assets/powered-by-icon.png +0 -0
- package/src/lib/authentication/providers/dev-portal-auth-pages.tsx +667 -602
- package/src/lib/authentication/providers/dev-portal.tsx +476 -448
- package/src/lib/components/HeaderNavigation.tsx +176 -176
- package/src/lib/components/MobileTopNavigation.tsx +283 -283
- package/src/lib/components/navigation/PoweredByZudoku.tsx +42 -39
- package/src/vite/plugin-offroot-resolve.ts +68 -67
- 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(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
]
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
if (
|
|
537
|
-
throw new ServiceEditError(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
"
|
|
554
|
-
);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
)
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
+
}
|