-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathinit.ts
More file actions
908 lines (809 loc) · 29.9 KB
/
Copy pathinit.ts
File metadata and controls
908 lines (809 loc) · 29.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
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
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
535
536
537
538
539
540
541
542
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
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
import path from "path";
import fs from "fs";
import { confirm } from "@clack/prompts";
import kleur from "kleur";
import {
runProjectSetupPrompts,
runManagedTemplatePrompts,
ADMIN_MODES,
AUTH_MODES,
type Preselect,
} from "../prompts/projectSetup.js";
import { generateAuthServer } from "../generators/auth/auth.js";
import { generateDockerCompose } from "../generators/docker/docker.js";
import {
printManagedSuccessOutput,
printSuccessOutput,
} from "../core/output.js";
import { generateSeamlessConfig } from "../generators/config/config.js";
import {
applyTemplateEnv,
assertCliSupports,
matchesTemplateFlag,
openTemplateSource,
templateFlags,
type RegistryEntry,
type ScaffoldContext,
type TemplateManifest,
type TemplateSource,
} from "../core/templates.js";
import { runOAuthSetupPrompts } from "../prompts/oauthSetup.js";
import {
chooseExistingDirectoryAction,
chooseScaffoldTarget,
confirmLocalFallback,
type ExistingDirectoryAction,
type ScaffoldTarget,
} from "../prompts/initMode.js";
import { CancelledError, orCancel } from "../core/cancel.js";
import { requireInteractive, warnOnUnusableWidth } from "../core/tty.js";
import type { CollectedOAuthProvider } from "../core/oauthProviders.js";
import {
createPortalClient,
ReauthRequiredError,
type AuthClient,
} from "../core/authClient.js";
import { getPortalSession, normalizeInstanceUrl } from "../core/config.js";
import { parseEnv, writeEnv } from "../core/env.js";
import { generateSecret } from "../core/secrets.js";
import {
buildScaffoldDatabaseUrl,
getApplicationDatabase,
listApplications,
rotateServiceToken,
type PortalApp,
} from "../core/portal.js";
import { selectApplication } from "../prompts/appSelect.js";
const AUTH_SERVER_URL = "http://localhost:5312";
const API_URL = "http://localhost:3000";
// Managed auth instances resolve signing keys from the token header and do not
// expose a per-application JWKS kid, so the scaffolded backend uses the SDK's
// default. This matches the portal's own "Get connected" guidance.
const MANAGED_JWKS_KID = "dev-main";
export interface InitOptions {
profileFlag?: string;
appId?: string;
local?: boolean;
// --web / --api, the long form of the template flags. Resolved against the
// registry alongside them, and rejected when they name the wrong layer.
web?: string;
api?: string;
email?: string;
auth?: string;
admin?: string;
// --yes: answer every remaining question with the recommended option rather
// than prompting. It never stands in for a destructive confirmation; those
// take --force.
yes?: boolean;
force?: boolean;
}
export async function runCLI(
projectName?: string,
aliases: string[] = [],
opts: InitOptions = {},
) {
const cwd = process.cwd();
// Managed connect now reads the portal session, so a profile no longer selects
// anything here. Warn rather than silently ignoring it.
// TODO(#125): drop the flag one minor version after release.
if (opts.profileFlag) {
console.log(
kleur.yellow(
"init no longer takes --profile; managed connect uses your portal session. Run: seamless login",
),
);
}
if (!opts.yes) {
warnOnUnusableWidth((message) => console.log(kleur.yellow(message)));
}
const openSource = lazyTemplateSource();
// Every flag is validated against the registry and the known values before a
// directory is created and before the overwrite confirmation runs, so a bad
// flag can never reach a destructive prompt on its way to an error. The
// registry is only fetched when a template flag needs resolving, which keeps
// the integrate-an-existing-project path from paying for one it never reads.
const answers = resolveAnswerFlags(opts);
if (aliases.length > 0 || opts.web || opts.api) {
const { registry } = await openSource();
Object.assign(
answers,
resolveTemplateSelection(aliases, opts, registry.templates),
);
}
let root = cwd;
// Only ever set to a directory mkdir just created, never one that already
// existed, so discarding it can never take a developer's own files with it.
let created: string | null = null;
if (projectName) {
root = path.join(cwd, projectName);
if (fs.existsSync(root)) {
throw new Error(`Directory already exists: ${projectName}`);
}
fs.mkdirSync(root);
created = root;
console.log(`Creating project in ${root}`);
}
// Ctrl-C inside a prompt comes back as a CancelledError and unwinds through
// the catch below, but during a download or a git clone it arrives as a real
// signal that would otherwise end the process with the husk still on disk.
const onInterrupt = () => {
discard(created);
process.exit(130);
};
if (created) process.on("SIGINT", onInterrupt);
try {
await scaffold(root, projectName, answers, openSource, opts);
} catch (err) {
// Anything short of a completed scaffold leaves nothing behind, so a retry
// is not blocked by "Directory already exists" from a half-built attempt.
discard(created);
throw err;
} finally {
if (created) process.off("SIGINT", onInterrupt);
}
}
type OpenSource = () => Promise<TemplateSource>;
// Opens the template source at most once per run. Validating flags up front and
// scaffolding both need the registry, and the remote source refetches it on
// every open.
function lazyTemplateSource(): OpenSource {
let pending: Promise<TemplateSource> | null = null;
return () => (pending ??= openTemplateSource());
}
function discard(dir: string | null): void {
if (!dir) return;
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// The original failure is what the developer needs to see; a cleanup error
// on top of it would only bury the cause.
}
}
async function scaffold(
root: string,
projectName: string | undefined,
preselect: Preselect,
openSource: OpenSource,
opts: InitOptions,
) {
// --local forces the self-hosted stack without asking the control plane
// anything.
let client: AuthClient | null = null;
let fallbackReason: "no-session" | "unreachable" | null = null;
if (!opts.local) {
const resolution = await resolveManagedClient();
client = resolution.client;
if (!client) fallbackReason = resolution.reason;
}
// `--app` is an explicit managed intent. Silently scaffolding local and ignoring
// the flag would be surprising, so fail with an actionable message instead.
if (!client && opts.appId) {
throw new Error(
fallbackReason === "unreachable"
? "Could not reach the Seamless control plane to connect a managed instance. Check your connection, or re-run with --local to scaffold a self-hosted stack."
: "--app was given but you are not logged in. Run `seamless login` to connect a managed instance, or drop --app to scaffold a local project.",
);
}
// Whether managed is even possible is settled before the first prompt, so a
// session with nothing to connect never costs a round of questions first.
const allApps = client ? await listApplications(client) : [];
const apps = connectable(allApps);
const canConnect = client !== null && apps.length > 0;
const isEmpty = fs.readdirSync(root).length === 0;
if (!isEmpty) {
// --app is explicit managed intent, so it skips the question and integrates.
const action = opts.appId
? "integrate"
: await resolveExistingDirectoryAction(canConnect, opts);
if (action === "integrate") {
await integrateExistingProject(root, client!, apps, opts);
return;
}
}
if (canConnect) {
const target = opts.appId
? "managed"
: await resolveScaffoldTarget(apps.length, opts);
if (target === "managed") {
await scaffoldManaged(
root,
projectName,
preselect,
openSource,
client!,
apps,
opts,
);
return;
}
} else if (client) {
console.log(kleur.yellow(noConnectableMessage(allApps.length > 0)));
} else if (fallbackReason === "unreachable") {
// Falling back to local changes where the project's auth lives, so --yes
// does not get to make that call on its own; --local says it outright.
if (opts.yes) {
throw new Error(
"Could not reach the Seamless control plane, and --yes will not silently scaffold a local stack instead. Re-run with --local to scaffold self-hosted.",
);
}
requireInteractive(
"Could not reach the Seamless control plane. Scaffold a local stack instead?",
"Pass --local to scaffold a self-hosted stack.",
);
await confirmLocalFallback();
} else if (fallbackReason === "no-session") {
console.log(
kleur.yellow(
"Not logged in; scaffolding a local project. Run `seamless login` first to connect a managed instance.",
),
);
}
await scaffoldLocal(root, projectName, preselect, openSource, opts);
}
// Writing starter files over a directory someone already has work in is the one
// destructive step in a scaffold, so --yes is deliberately not enough to reach
// it. --force is, and it says nothing about the integrate-or-scaffold question,
// which --app answers instead.
async function resolveExistingDirectoryAction(
canConnect: boolean,
opts: InitOptions,
): Promise<ExistingDirectoryAction> {
if (!opts.yes) {
requireInteractive(
"This directory is not empty. What would you like to do?",
"Pass --yes --force to scaffold here anyway, or --app <id> to connect the existing project to a managed application.",
);
return chooseExistingDirectoryAction(canConnect);
}
if (!opts.force) {
throw new Error(
"This directory is not empty, and starter files overwrite anything with the same name. Re-run with --force to scaffold here anyway, or with --app <id> to connect the existing project to a managed application.",
);
}
console.log(
kleur.yellow("Scaffolding into a directory that is not empty (--force)."),
);
return "scaffold";
}
// Managed or local decides where the project's auth lives for good, so --yes
// alone will not pick: --app <id> means managed and --local means self-hosted.
async function resolveScaffoldTarget(
appCount: number,
opts: InitOptions,
): Promise<ScaffoldTarget> {
if (!opts.yes) {
requireInteractive(
"How should this project get its auth?",
"Pass --app <id> to connect a managed application, or --local to scaffold a self-hosted stack.",
);
return chooseScaffoldTarget(appCount);
}
throw new Error(
"You are logged in, so --yes will not guess between a managed application and a local stack. Pass --app <id> to connect one of your managed applications, or --local to scaffold a self-hosted stack.",
);
}
// The bundled database as a connection string with placeholder credentials, or
// an empty string when the control plane has not provisioned one yet. Never
// requests ?reveal=true, so no live credential reaches this machine.
async function resolveDatabaseUrl(
client: AuthClient,
app: PortalApp,
): Promise<string> {
const database = await getApplicationDatabase(client, app.id);
if (!database) {
console.log(
kleur.yellow(
`No managed database is available for "${app.name}" yet. Set DATABASE_URL in api/.env once it finishes provisioning.`,
),
);
return "";
}
return buildScaffoldDatabaseUrl(database);
}
// The old message told an account whose only application was still provisioning
// that it had none and should create one.
function noConnectableMessage(hasProvisioning: boolean): string {
return hasProvisioning
? "Your managed applications are still provisioning, so there is nothing to connect to yet. Scaffolding a local project instead."
: "Your account has no managed applications yet (create one at https://dashboard.seamlessauth.com). Scaffolding a local project instead.";
}
type ManagedResolution =
| { client: AuthClient; reason: null }
| { client: null; reason: "no-session" | "unreachable" };
// Resolves an authenticated control-plane client from the portal session. A
// missing session ("no-session") and an unreachable/errored control plane
// ("unreachable") both yield a null client so init can fall back to local (or,
// with --app, error). Instance profiles are deliberately not consulted: a session
// for an auth instance the developer administers says nothing about whether they
// have a managed account, and sending its token to the control plane would fail.
async function resolveManagedClient(): Promise<ManagedResolution> {
try {
return { client: await createPortalClient(), reason: null };
} catch (err) {
if (err instanceof ReauthRequiredError) {
return { client: null, reason: "no-session" };
}
return { client: null, reason: "unreachable" };
}
}
async function scaffoldManaged(
root: string,
projectName: string | undefined,
preselect: TemplatePreselect,
openSource: OpenSource,
client: AuthClient,
apps: ConnectableApp[],
opts: InitOptions,
) {
const source = await openSource();
const answers = await runManagedTemplatePrompts(
source.registry.templates,
preselect,
opts.yes,
);
const selected = await resolveSelectedTemplates(
source,
answers.webTemplateId,
answers.apiTemplateId,
root,
);
// Resolve the target application before writing files, so a cancelled selection
// or an authorization failure leaves nothing behind.
const app = await selectApplication(apps, opts.appId);
// Copy templates before rotating the service token. Rotation invalidates the
// app's previous token (see rotateServiceToken), so it runs as late as possible;
// copyInto is the likeliest step to fail, so it happens first, before rotation.
for (const { entry, dir } of selected) {
console.log(`Adding ${entry.label} starter...`);
await source.copyInto(entry, dir);
}
// Read before rotating: a database that is not provisioned yet is a warning,
// not a failure, and finding that out after the token is rotated would mean
// reporting it against a half-wired project.
const databaseUrl = await resolveDatabaseUrl(client, app);
const serviceToken = await issueServiceToken(client, app, opts);
const authServerUrl = normalizeInstanceUrl(app.domain);
// Everything past rotation is guarded: if it throws, the freshly issued token is
// printed so a deployed app can be re-wired rather than left bricked (the control
// plane never re-shows it).
try {
const ctx: ScaffoldContext = {
authServerUrl,
apiUrl: API_URL,
apiToken: serviceToken,
jwksKid: MANAGED_JWKS_KID,
// A managed instance hosts its own dashboard, so the app API does not proxy
// the console. Keeps the template's SERVE_ADMIN_CONSOLE gate off.
serveAdminConsole: "false",
databaseUrl,
};
for (const { manifest, dir } of selected) {
applyTemplateEnv(dir, manifest, ctx);
}
const webEntry = findEntry(source.registry.templates, answers.webTemplateId);
const apiEntry = findEntry(source.registry.templates, answers.apiTemplateId);
generateSeamlessConfig(root, {
projectName,
webFramework: webEntry.framework,
apiFramework: apiEntry.framework,
authMode: "managed",
adminMode: "image",
managed: {
instanceUrl: authServerUrl,
applicationId: app.id,
applicationName: app.name,
},
});
printManagedSuccessOutput({
projectName,
webFramework: webEntry.framework,
apiFramework: apiEntry.framework,
authServerUrl,
appName: app.name,
databaseUrl,
});
} catch (err) {
console.error(
kleur.red(
"\nScaffolding failed after a new service token was issued. The token below is valid — set it on your backend to recover:",
),
);
printManagedValues(authServerUrl, serviceToken);
throw err;
}
}
async function scaffoldLocal(
root: string,
projectName: string | undefined,
preselect: Preselect,
openSource: OpenSource,
opts: InitOptions,
) {
const source = await openSource();
const answers = await runProjectSetupPrompts(
source.registry.templates,
preselect,
getPortalSession()?.email,
opts.yes,
);
const selected = await resolveSelectedTemplates(
source,
answers.webTemplateId,
answers.apiTemplateId,
root,
);
// Templates can opt into OAuth setup (manifest setup.oauth). Collect providers now,
// before scaffolding, so the auth server can be wired up with them below.
// Provider credentials are per-provider secrets with no flag form, so --yes
// scaffolds the starter with none configured rather than asking.
const webSelection = selected.find((s) => s.entry.kind === "web");
let oauthProviders: CollectedOAuthProvider[] = [];
if (webSelection?.manifest.setup?.oauth) {
if (opts.yes) {
console.log(
kleur.yellow(
"Skipping OAuth provider setup (--yes). Add providers later with `seamless config oauth-providers add`.",
),
);
} else {
requireInteractive(
"Which OAuth providers would you like to configure?",
"Pass --yes to scaffold with none configured, then add them with `seamless config oauth-providers add`.",
);
oauthProviders = await runOAuthSetupPrompts();
}
}
for (const { entry, dir } of selected) {
console.log(`Adding ${entry.label} starter...`);
await source.copyInto(entry, dir);
}
let sharedConfig: any = {};
if (answers.authMode === "local") {
sharedConfig = await generateAuthServer(
{ root },
"local",
oauthProviders,
answers.adminMode,
answers.ownerEmail,
);
}
if (answers.useDocker) {
const dockerShared = await generateDockerCompose(root, {
authMode: answers.authMode,
adminMode: answers.adminMode,
oauth: oauthProviders,
ownerEmail: answers.ownerEmail,
});
if (answers.authMode === "docker") {
sharedConfig = dockerShared;
}
}
const ctx: ScaffoldContext = {
authServerUrl: AUTH_SERVER_URL,
apiUrl: API_URL,
apiToken: sharedConfig.apiToken,
jwksKid: sharedConfig.kid,
serveAdminConsole: answers.adminMode === "api" ? "true" : "false",
// The local stack runs its own postgres from the compose file, which the
// starter reaches through its discrete DB_* values.
databaseUrl: "",
};
for (const { manifest, dir } of selected) {
applyTemplateEnv(dir, manifest, ctx);
}
const webEntry = findEntry(source.registry.templates, answers.webTemplateId);
const apiEntry = findEntry(source.registry.templates, answers.apiTemplateId);
generateSeamlessConfig(root, {
projectName,
webFramework: webEntry.framework,
apiFramework: apiEntry.framework,
authMode: answers.authMode,
adminMode: answers.adminMode,
});
printSuccessOutput({
projectName,
root,
webFramework: webEntry.framework,
apiFramework: apiEntry.framework,
authMode: answers.authMode,
useDocker: answers.useDocker,
adminMode: answers.adminMode,
ownerEmail: answers.ownerEmail,
});
printOAuthNextSteps(oauthProviders);
}
// Existing-project integration: wire the managed credentials into an already
// scaffolded repo without re-generating source. Kept intentionally small, it
// updates api/.env when an api directory exists and otherwise prints the values
// to paste in by hand.
async function integrateExistingProject(
root: string,
client: AuthClient,
apps: ConnectableApp[],
opts: InitOptions,
) {
const app = await selectApplication(apps, opts.appId);
// Read before rotating: a database that is not provisioned yet is a warning,
// not a failure, and finding that out after the token is rotated would mean
// reporting it against a half-wired project.
const databaseUrl = await resolveDatabaseUrl(client, app);
const serviceToken = await issueServiceToken(client, app, opts);
const authServerUrl = normalizeInstanceUrl(app.domain);
const apiDir = path.join(root, "api");
if (!fs.existsSync(apiDir)) {
printManagedValues(authServerUrl, serviceToken);
return;
}
// The token is already rotated (old one invalidated); if the write fails, print
// it so the app can be re-wired by hand rather than left bricked.
try {
wireApiEnv(apiDir, authServerUrl, serviceToken, databaseUrl);
} catch (err) {
console.error(
kleur.red(
"\nFailed to write api/.env after issuing a new service token. Set it by hand to recover:",
),
);
printManagedValues(authServerUrl, serviceToken);
throw err;
}
console.log(kleur.green(`Updated ${path.join("api", ".env")}.`));
console.log(
kleur.dim(" Set your web app's auth server URL to: ") +
kleur.cyan(authServerUrl),
);
}
// Merges the managed auth values into an existing api/.env, preserving any keys
// already there and keeping (or generating) a cookie signing key.
function wireApiEnv(
apiDir: string,
authServerUrl: string,
serviceToken: string,
databaseUrl: string,
) {
const envPath = path.join(apiDir, ".env");
const values = fs.existsSync(envPath) ? parseEnv(envPath) : {};
values.AUTH_SERVER_URL = authServerUrl;
values.API_SERVICE_TOKEN = serviceToken;
values.JWKS_KID = values.JWKS_KID || MANAGED_JWKS_KID;
values.COOKIE_SIGNING_KEY =
values.COOKIE_SIGNING_KEY || generateSecret(32);
// Never overwritten: an existing project may already hold a working
// connection string, credentials and all, and this one carries placeholders.
if (databaseUrl && !values.DATABASE_URL) {
values.DATABASE_URL = databaseUrl;
}
fs.mkdirSync(apiDir, { recursive: true });
writeEnv(envPath, values);
}
function printManagedValues(authServerUrl: string, serviceToken: string) {
console.log(kleur.green("\nManaged connection values:\n"));
console.log(kleur.dim(" AUTH_SERVER_URL ") + authServerUrl);
console.log(kleur.dim(" API_SERVICE_TOKEN ") + serviceToken);
console.log(kleur.dim(" JWKS_KID ") + MANAGED_JWKS_KID);
console.log(
kleur.yellow(
"\nCopy the service token now. The control plane will not show it again.",
),
);
console.log(
kleur.dim(
"Set AUTH_SERVER_URL and API_SERVICE_TOKEN on your backend, and the auth",
),
);
console.log(
kleur.dim("server URL on your frontend, then sign in to verify.\n"),
);
}
// Issues the app's service token, confirming first when one already exists so a
// scaffold does not silently break an app that is already deployed. Declining
// cancels the whole command, so nothing is left half-wired.
async function issueServiceToken(
client: AuthClient,
app: PortalApp,
opts: InitOptions,
): Promise<string> {
if (app.hasServiceToken) {
// Rotation breaks whatever is running on the old token, so it is a
// destructive confirmation like the overwrite one: --yes does not answer it,
// --force does.
if (opts.yes) {
if (!opts.force) {
throw new Error(
`"${app.name}" already has a service token, and issuing a new one invalidates it (breaking anything already deployed with it). Re-run with --force to rotate it anyway.`,
);
}
console.log(
kleur.yellow(
`Rotating the existing service token for "${app.name}" (--force).`,
),
);
} else {
requireInteractive(
`"${app.name}" already has a service token. Issue a new one?`,
"Pass --force to rotate it, which invalidates the existing token.",
);
const proceed = orCancel(
await confirm({
message: `"${app.name}" already has a service token. Issuing a new one invalidates the existing token. Continue?`,
initialValue: false,
}),
);
if (!proceed) {
throw new CancelledError("Cancelled. No token was issued.");
}
}
}
return rotateServiceToken(client, app.id);
}
type ConnectableApp = PortalApp & { domain: string };
// listApplications reports applications that have not finished provisioning too,
// because `seamless apps list` has to show them. Managed connect needs an auth
// server to point at, so it keeps considering only the ones that have one.
function connectable(apps: PortalApp[]): ConnectableApp[] {
return apps.filter((app): app is ConnectableApp => !!app.domain);
}
interface SelectedTemplate {
entry: RegistryEntry;
manifest: TemplateManifest;
dir: string;
}
// Resolves the chosen templates (reading their manifests and asserting CLI
// support) before any files are written, so every prompt finishes first.
async function resolveSelectedTemplates(
source: TemplateSource,
webTemplateId: string,
apiTemplateId: string,
root: string,
): Promise<SelectedTemplate[]> {
const selected: SelectedTemplate[] = [];
for (const id of [webTemplateId, apiTemplateId]) {
const entry = findEntry(source.registry.templates, id);
const manifest = await source.readManifest(entry);
assertCliSupports(manifest, entry.label);
const dir = path.join(root, manifest.targetDir);
selected.push({ entry, manifest, dir });
}
return selected;
}
function findEntry(templates: RegistryEntry[], id: string): RegistryEntry {
const entry = templates.find((t) => t.id === id);
if (!entry) {
throw new Error(`Selected template "${id}" is not in the registry.`);
}
return entry;
}
// Summarizes the OAuth wiring after scaffolding: which providers are ready and which
// still need credentials, plus the redirect URI to register with each provider.
function printOAuthNextSteps(providers: CollectedOAuthProvider[]) {
if (providers.length === 0) return;
const ready = providers
.filter((p) => p.clientId && p.clientSecret)
.map((p) => p.catalog.label);
const pending = providers
.filter((p) => !p.clientId || !p.clientSecret)
.map((p) => p.catalog.label);
console.log("\nOAuth providers");
if (ready.length) {
console.log(` Enabled: ${ready.join(", ")}`);
}
if (pending.length) {
console.log(` Needs credentials before use: ${pending.join(", ")}`);
console.log(
" Add the client id/secret in the auth environment (OAUTH_PROVIDERS and the",
);
console.log(' matching *_CLIENT_SECRET), then set that provider\'s "enabled" to true.');
}
console.log(
" Register this redirect URI with each provider: http://localhost:5173/oauth/callback",
);
}
export interface TemplatePreselect {
webTemplateId?: string;
apiTemplateId?: string;
}
// The non-template answers a flag can supply. Validated here so an unusable
// value is reported before anything is created, and so the prompts only ever
// see a value they would have accepted themselves.
function resolveAnswerFlags(opts: InitOptions): Preselect {
const answers: Preselect = {};
if (opts.email !== undefined) {
if (!opts.email.includes("@")) {
throw new Error(`--email must be an email address, got "${opts.email}".`);
}
answers.ownerEmail = opts.email;
}
if (opts.auth !== undefined) {
answers.authMode = oneOf(opts.auth, AUTH_MODES, "--auth");
}
if (opts.admin !== undefined) {
answers.adminMode = oneOf(opts.admin, ADMIN_MODES, "--admin");
}
return answers;
}
function oneOf<T extends string>(value: string, allowed: T[], flag: string): T {
if (!(allowed as string[]).includes(value)) {
throw new Error(
`Unknown value "${value}" for ${flag}. Expected one of: ${allowed.join(", ")}.`,
);
}
return value as T;
}
// Combines the template flags: the bare `--<id>` / `--<alias>` form, which infers
// the layer from the registry, and the explicit `--web=` / `--api=` form, which
// names it. Both accept either spelling, and disagreeing about a layer is an
// error rather than a silent last-one-wins.
function resolveTemplateSelection(
aliases: string[],
opts: InitOptions,
templates: RegistryEntry[],
): TemplatePreselect {
const preselect = resolveTemplateAliases(aliases, templates);
for (const [flag, kind] of [
["web", "web"],
["api", "api"],
] as const) {
const value = opts[flag];
if (!value) continue;
const named = value.replace(/^--+/, "");
const { webTemplateId, apiTemplateId } = resolveTemplateAliases(
[named],
templates,
);
const id = kind === "web" ? webTemplateId : apiTemplateId;
if (!id) {
throw new Error(
`--${flag} expects a ${kind} template, but "${value}" is not one. Run \`seamless templates list\` to see which templates are ${kind}.`,
);
}
const existing = kind === "web" ? preselect.webTemplateId : preselect.apiTemplateId;
if (existing && existing !== id) {
throw new Error(
`Conflicting ${kind} template flags: --${flag}=${value} cannot combine with --${existing}.`,
);
}
if (kind === "web") preselect.webTemplateId = id;
else preselect.apiTemplateId = id;
}
return preselect;
}
// Resolves `--<alias>` and `--<id>` flags (e.g. --oauth, --react-oauth) to specific
// templates from the registry, so a matching layer's prompt can be skipped. Both
// spellings live in the registry, so no per-flag code is needed here. Unknown or
// conflicting flags are hard errors.
export function resolveTemplateAliases(
aliases: string[],
templates: RegistryEntry[],
): TemplatePreselect {
const preselect: TemplatePreselect = {};
for (const alias of aliases) {
const entry = templates.find(
(t) => matchesTemplateFlag(t, alias) && t.status !== "coming-soon",
);
if (!entry) {
const available = templates
.filter((t) => t.status !== "coming-soon")
.flatMap((t) => templateFlags(t))
.join(", ");
throw new Error(
`Unknown option "--${alias}". Available template flags: ${available || "(none)"}. Run \`seamless templates list\` for details.`,
);
}
if (entry.kind === "web") {
if (preselect.webTemplateId && preselect.webTemplateId !== entry.id) {
throw new Error(
`Conflicting web template flags: --${alias} cannot combine with another web example.`,
);
}
preselect.webTemplateId = entry.id;
} else if (entry.kind === "api") {
if (preselect.apiTemplateId && preselect.apiTemplateId !== entry.id) {
throw new Error(
`Conflicting api template flags: --${alias} cannot combine with another api example.`,
);
}
preselect.apiTemplateId = entry.id;
}
}
return preselect;
}