Repository navigation
Expand file tree
/
Copy pathinit.ts
More file actions
1033 lines (924 loc) · 34.4 KB
/
Copy pathinit.ts
File metadata and controls
1033 lines (924 loc) · 34.4 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
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import path from "path";
import fs from "fs";
import { confirm } from "@clack/prompts";
import kleur from "kleur";
import {
runProjectSetupPrompts,
runManagedTemplatePrompts,
ADMIN_MODES,
assertFullStackAdminMode,
AUTH_MODES,
type Preselect,
} from "../prompts/projectSetup.js";
import { generateAuthServer } from "../generators/auth/auth.js";
import {
generateDockerCompose,
LOCAL_AUTH_ISSUER,
} from "../generators/docker/docker.js";
import {
printManagedSuccessOutput,
printSuccessOutput,
} from "../core/output.js";
import { generateSeamlessConfig } from "../generators/config/config.js";
import {
generateAgentsFiles,
type AgentsTemplate,
} from "../generators/agents/agents.js";
import {
applyTemplateEnv,
assertCliSupports,
isFullStack,
layerOf,
matchesTemplateFlag,
openTemplateSource,
servesAdminConsole,
templateFlags,
type RegistryEntry,
type TemplateKind,
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 { generateAdminSource } from "../generators/admin/admin.js";
import { generateSecret } from "../core/secrets.js";
import {
appServiceTokenVar,
LEGACY_SERVICE_TOKEN_VAR,
legacyServiceTokenNote,
} from "../core/serviceToken.js";
import { fetchActiveJwksKid } from "../core/jwksKid.js";
import {
buildScaffoldDatabaseUrl,
getApplicationDatabase,
listApplications,
rotateServiceToken,
resolveAppInstanceUrl,
type PortalApp,
} from "../core/portal.js";
import { selectApplication } from "../prompts/appSelect.js";
import {
FALLBACK_JWKS_KID,
connectable,
issueServiceToken,
requireInstanceUrl,
resolveJwksKid,
} from "../core/managedConnect.js";
const AUTH_SERVER_URL = "http://localhost:5312";
const API_URL = "http://localhost:3000";
export interface InitOptions {
profileFlag?: string;
appId?: string;
local?: boolean;
// --web / --api / --mobile, the long form of the template flags. Resolved
// against the registry alongside them, and rejected when they name the wrong
// layer. Mobile is optional: absent, no mobile starter is placed.
web?: string;
api?: string;
mobile?: string;
// Which of those came as `--mobile <value>` rather than `--mobile=<value>`.
// Only that form can turn out to be a bare alias followed by the project name.
spaced?: Partial<Record<"web" | "api" | "mobile", true>>;
email?: string;
auth?: string;
admin?: string;
// The split every prompt below is written against: --yes answers the ordinary
// questions with the recommended option, --force answers the destructive ones
// (overwriting a non-empty directory, rotating a live service token), and
// neither answers managed-or-local, which --app and --local settle outright.
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 || opts.mobile) {
const { registry } = await openSource();
const selection = resolveTemplateSelection(
aliases,
opts,
registry.templates,
projectName,
);
Object.assign(answers, selection.preselect);
projectName = selection.projectName;
assertFullStackFlags(answers, 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 this takes --force rather than --yes.
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";
}
// Where the project's auth lives is settled for good here, so --yes will not pick:
// --app <id> means managed, --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.
//
// Both callers run this before rotating the service token: a database that is not
// provisioned yet is a warning rather than a failure, and finding that out after
// the rotation would mean reporting it against a half-wired project.
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: PortalApp[],
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, answers.mobileTemplateId],
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);
}
const databaseUrl = await resolveDatabaseUrl(client, app);
const serviceToken = await issueServiceToken(client, app, opts);
const authServerUrl = normalizeInstanceUrl(requireInstanceUrl(app));
const jwksKid = await resolveJwksKid(authServerUrl);
// 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,
// 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 = answers.apiTemplateId
? findEntry(source.registry.templates, answers.apiTemplateId)
: undefined;
const mobileEntry = answers.mobileTemplateId
? findEntry(source.registry.templates, answers.mobileTemplateId)
: undefined;
generateSeamlessConfig(root, {
projectName,
webFramework: webEntry.framework,
webFullStack: isFullStack(webEntry),
apiFramework: apiEntry?.framework,
mobileFramework: mobileEntry?.framework,
authMode: "managed",
adminMode: "image",
managed: {
instanceUrl: authServerUrl,
applicationId: app.id,
applicationName: app.name,
},
});
generateAgentsFiles(root, {
projectName: projectName || path.basename(root),
...agentsTemplates(selected, webEntry),
authMode: "managed",
adminMode: "image",
managed: { instanceUrl: authServerUrl, applicationName: app.name },
});
printManagedSuccessOutput({
projectName,
webFramework: webEntry.framework,
webFullStack: isFullStack(webEntry),
apiFramework: apiEntry?.framework ?? null,
mobileFramework: mobileEntry?.framework ?? null,
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, jwksKid);
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, answers.mobileTemplateId],
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) => layerOf(s.entry.kind) === "web");
if (
answers.adminMode === "api" &&
webSelection &&
isFullStack(webSelection.entry) &&
!servesAdminConsole(webSelection.manifest)
) {
const reason = `${webSelection.entry.label} in this templates release cannot serve the admin console`;
if (preselect.adminMode) {
throw new Error(`${reason}. Use --admin=none.`);
}
console.log(kleur.yellow(`${reason}, so none is included.`));
answers.adminMode = "none";
}
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);
}
// Before the compose file is written, because that file declares `build: ./admin`
// and a project whose build context never arrived cannot come up at all.
if (answers.adminMode === "source") {
console.log("Adding admin dashboard source...");
await generateAdminSource(root);
}
let sharedConfig: any = {};
if (answers.authMode === "local") {
sharedConfig = await generateAuthServer(
root,
oauthProviders,
answers.adminMode,
answers.ownerEmail,
answers.apiTemplateId === undefined,
);
}
// Both auth modes run the stack on compose, so the file is always written.
// Only the docker mode takes its shared config from here; a local auth server
// has already produced its own above.
const dockerShared = await generateDockerCompose(root, {
authMode: answers.authMode,
adminMode: answers.adminMode,
fullStack: answers.apiTemplateId === undefined,
oauth: oauthProviders,
ownerEmail: answers.ownerEmail,
});
if (answers.authMode === "docker") {
sharedConfig = dockerShared;
}
const ctx: ScaffoldContext = {
authServerUrl: AUTH_SERVER_URL,
// Both auth modes run the server in compose, signing as its service name.
authServerIssuer: LOCAL_AUTH_ISSUER,
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 = answers.apiTemplateId
? findEntry(source.registry.templates, answers.apiTemplateId)
: undefined;
const mobileEntry = answers.mobileTemplateId
? findEntry(source.registry.templates, answers.mobileTemplateId)
: undefined;
generateSeamlessConfig(root, {
projectName,
webFramework: webEntry.framework,
webFullStack: isFullStack(webEntry),
apiFramework: apiEntry?.framework,
mobileFramework: mobileEntry?.framework,
authMode: answers.authMode,
adminMode: answers.adminMode,
});
generateAgentsFiles(root, {
projectName: projectName || path.basename(root),
...agentsTemplates(selected, webEntry),
authMode: answers.authMode,
adminMode: answers.adminMode,
ownerEmail: answers.ownerEmail,
});
printSuccessOutput({
projectName,
root,
webFramework: webEntry.framework,
webFullStack: isFullStack(webEntry),
apiFramework: apiEntry?.framework ?? null,
mobileFramework: mobileEntry?.framework ?? null,
authMode: answers.authMode,
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: PortalApp[],
opts: InitOptions,
) {
const app = await selectApplication(apps, opts.appId);
const databaseUrl = await resolveDatabaseUrl(client, app);
const serviceToken = await issueServiceToken(client, app, opts);
const authServerUrl = normalizeInstanceUrl(requireInstanceUrl(app));
const jwksKid = await resolveJwksKid(authServerUrl);
const apiDir = path.join(root, "api");
if (!fs.existsSync(apiDir)) {
printManagedValues(authServerUrl, serviceToken, jwksKid);
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.
let serviceTokenVar: string;
try {
serviceTokenVar = 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, jwksKid);
throw err;
}
console.log(kleur.green(`Updated ${path.join("api", ".env")}.`));
if (serviceTokenVar === LEGACY_SERVICE_TOKEN_VAR) {
console.log(kleur.yellow(` ${legacyServiceTokenNote(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. Returns the
// variable the service token went into.
function wireApiEnv(
apiDir: string,
authServerUrl: string,
serviceToken: string,
databaseUrl: string,
): string {
const envPath = path.join(apiDir, ".env");
const values = fs.existsSync(envPath) ? parseEnv(envPath) : {};
const serviceTokenVar = appServiceTokenVar(values);
values.AUTH_SERVER_URL = authServerUrl;
values[serviceTokenVar] = serviceToken;
values.JWKS_KID = values.JWKS_KID || FALLBACK_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);
return serviceTokenVar;
}
function printManagedValues(
authServerUrl: string,
serviceToken: string,
jwksKid: string,
) {
console.log(kleur.green("\nManaged connection values:\n"));
console.log(kleur.dim(" AUTH_SERVER_URL ") + authServerUrl);
console.log(kleur.dim(" SEAMLESS_SERVICE_TOKEN ") + serviceToken);
console.log(kleur.dim(" JWKS_KID ") + jwksKid);
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, SEAMLESS_SERVICE_TOKEN, and JWKS_KID on your backend (a",
),
);
console.log(
kleur.dim(
"backend scaffolded before seamless-cli 0.24.0 reads API_SERVICE_TOKEN instead).\n" +
"Point your frontend at your backend, not the auth server, then sign in to verify.\n",
),
);
}
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. An
// undefined id is a layer that was not chosen (mobile is optional).
async function resolveSelectedTemplates(
source: TemplateSource,
templateIds: Array<string | undefined>,
root: string,
): Promise<SelectedTemplate[]> {
const selected: SelectedTemplate[] = [];
for (const id of templateIds) {
if (!id) continue;
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;
}
// Each chosen template as the root AGENTS.md describes it, by layer and by the
// directory its manifest placed it in.
function agentsTemplates(selected: SelectedTemplate[], webEntry: RegistryEntry) {
const byLayer = (layer: "web" | "api" | "mobile"): AgentsTemplate | undefined => {
const s = selected.find((t) => layerOf(t.entry.kind) === layer);
return s
? {
id: s.entry.id,
label: s.entry.label,
framework: s.entry.framework,
dir: s.manifest.targetDir,
hasGuide: fs.existsSync(path.join(s.dir, "AGENTS.md")),
}
: undefined;
};
return {
web: { ...byLayer("web")!, fullStack: isFullStack(webEntry) },
api: byLayer("api"),
mobile: byLayer("mobile"),
};
}
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;
mobileTemplateId?: 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;
}
const TEMPLATE_KIND_NOUN = { web: "a web", api: "an API", mobile: "a mobile" } as const;
// 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.
//
// A layer flag whose name is also a registry alias (`--mobile` for Expo) is
// ambiguous when a word follows it: `--mobile expo` names the template, while
// `--mobile my-app` is the bare alias followed by the project name. The word
// counts as the project name only when it is no template flag at all and no
// project name was given, so a template of the wrong layer is still an error.
function resolveTemplateSelection(
aliases: string[],
opts: InitOptions,
templates: RegistryEntry[],
projectName: string | undefined,
): { preselect: TemplatePreselect; projectName: string | undefined } {
const selectable = templates.filter((t) => t.status !== "coming-soon");
const isTemplateFlag = (flag: string) =>
selectable.some((t) => matchesTemplateFlag(t, flag));
const values: Partial<Record<"web" | "api" | "mobile", string>> = {};
const bare = [...aliases];
for (const kind of ["web", "api", "mobile"] as const) {
const value = opts[kind];
if (!value) continue;
if (
opts.spaced?.[kind] &&
projectName === undefined &&
isTemplateFlag(kind) &&
!isTemplateFlag(value.replace(/^--+/, ""))
) {
bare.push(kind);
projectName = value;
continue;
}
values[kind] = value;
}
for (const kind of ["web", "api", "mobile"] as const) {
if (bare.includes(kind) && !isTemplateFlag(kind)) {
throw new Error(
`--${kind} needs a template: --${kind}=<id|alias>. Run \`seamless templates list\` to see them.`,
);
}
}
const preselect = resolveTemplateAliases(bare, templates);
for (const kind of ["web", "api", "mobile"] as const) {
const value = values[kind];
if (!value) continue;
const named = value.replace(/^--+/, "");
const key = preselectKey(kind);
const id = resolveTemplateAliases([named], templates)[key];
if (!id) {
throw new Error(
`--${kind} expects ${TEMPLATE_KIND_NOUN[kind]} template, but "${value}" is not one. Run \`seamless templates list\` to see each template's kind.`,
);
}
const existing = preselect[key];
if (existing && existing !== id) {
throw new Error(
`Conflicting ${kind} template flags: --${kind}=${value} cannot combine with --${existing}.`,
);
}
preselect[key] = id;
}
return { preselect, projectName };
}
// A full-stack template fills the web layer, so --nextjs and --web=nextjs land
// in the same slot and a second web template is a conflict like any other.
function preselectKey(kind: TemplateKind): keyof TemplatePreselect {
return `${layerOf(kind)}TemplateId`;
}
// Checked with the other flags, before a directory is created: a full-stack
// template serves /auth itself, so an api template beside it, or an admin
// console container that cannot call it, is a contradiction to report rather than resolve.
function assertFullStackFlags(
answers: Preselect,
templates: RegistryEntry[],
): void {
const web = templates.find((t) => t.id === answers.webTemplateId);
if (!isFullStack(web)) return;
if (answers.apiTemplateId) {
throw new Error(
`${web!.label} serves its own /auth routes, so it cannot be combined with an api template. Drop the api flag.`,
);
}
assertFullStackAdminMode(answers.adminMode, web!.label);
}
// 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