-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtemplates.ts
More file actions
294 lines (264 loc) · 9.28 KB
/
Copy pathtemplates.ts
File metadata and controls
294 lines (264 loc) · 9.28 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
import fs from "fs";
import path from "path";
import AdmZip from "adm-zip";
import { VERSION } from "../index.js";
import { parseEnvString, writeEnv } from "./env.js";
import { generateSecret } from "./secrets.js";
import { SEAMLESS_TEMPLATES_REF, SEAMLESS_TEMPLATES_REPO } from "./images.js";
export type TemplateKind = "web" | "api";
export type TemplateStatus = "stable" | "beta" | "coming-soon";
export interface RegistryEntry {
id: string;
kind: TemplateKind;
framework: string;
label: string;
// Short flag name for `seamless init --<alias>` (e.g. "oauth"). Optional.
alias?: string;
status: TemplateStatus;
path: string;
}
export interface Registry {
schemaVersion: number;
templates: RegistryEntry[];
}
// The command-line spellings for a template, longest-lived first. The id always
// works because it is what `seamless templates list` and the registry show; the
// alias is a shorter synonym some templates also declare.
export function templateFlags(entry: RegistryEntry): string[] {
return entry.alias ? [`--${entry.alias}`, `--${entry.id}`] : [`--${entry.id}`];
}
export function matchesTemplateFlag(entry: RegistryEntry, flag: string): boolean {
return entry.alias === flag || entry.id === flag;
}
export interface TemplateManifest {
id: string;
targetDir: string;
env?: {
fromExample?: string;
set?: Record<string, string>;
};
// How `seamless verify` conformance-tests this template (consumed by the verify
// command): which Playwright project drives it and which flow tags to run.
verify?: {
project?: string;
flows?: string[];
};
// Optional interactive setup the CLI runs for this template. `oauth` triggers the
// OAuth provider prompts and wires the chosen providers into the auth server.
setup?: {
oauth?: boolean;
};
requires?: { cliMin?: string };
}
// Values the CLI computes for a scaffold, used to resolve {{placeholders}} in a
// template manifest's env.set.
export interface ScaffoldContext {
authServerUrl: string;
apiUrl: string;
apiToken?: string;
jwksKid?: string;
// "true" when the app API should serve the admin console at /console, "false"
// when the console is hosted elsewhere (a standalone container) or omitted.
serveAdminConsole?: string;
// Connection string for a managed bundled database, with placeholder
// credentials. Empty for a local stack, whose starter falls back to its
// discrete DB_* values.
databaseUrl?: string;
}
// Build artifacts and local-only files that must never be copied into a scaffold,
// even if a local template checkout has them lying around.
const IGNORED_NAMES = new Set([
"node_modules",
".git",
"dist",
"build",
".next",
"out",
"coverage",
".DS_Store",
".env",
".env.local",
]);
// A resolved place to read templates from: either a local checkout (for development,
// via SEAMLESS_TEMPLATES_DIR) or the published monorepo at a pinned ref.
export interface TemplateSource {
registry: Registry;
readManifest(entry: RegistryEntry): Promise<TemplateManifest>;
copyInto(entry: RegistryEntry, destDir: string): Promise<void>;
}
export async function openTemplateSource(): Promise<TemplateSource> {
const localDir = process.env.SEAMLESS_TEMPLATES_DIR;
if (localDir) {
return openLocalSource(path.resolve(localDir));
}
return openRemoteSource(
SEAMLESS_TEMPLATES_REPO,
process.env.SEAMLESS_TEMPLATES_REF ?? SEAMLESS_TEMPLATES_REF,
);
}
function readRegistry(raw: string): Registry {
const registry = JSON.parse(raw) as Registry;
if (!registry || !Array.isArray(registry.templates)) {
throw new Error("Template registry is malformed (missing templates array).");
}
return registry;
}
function openLocalSource(dir: string): TemplateSource {
const registryPath = path.join(dir, "registry.json");
if (!fs.existsSync(registryPath)) {
throw new Error(
`SEAMLESS_TEMPLATES_DIR is set to ${dir}, but no registry.json was found there.`,
);
}
const registry = readRegistry(fs.readFileSync(registryPath, "utf-8"));
return {
registry,
async readManifest(entry) {
const manifestPath = path.join(dir, entry.path, "template.json");
return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as TemplateManifest;
},
async copyInto(entry, destDir) {
copyDir(path.join(dir, entry.path), destDir);
},
};
}
async function openRemoteSource(repo: string, ref: string): Promise<TemplateSource> {
const registryUrl = `https://raw.githubusercontent.com/${repo}/${ref}/registry.json`;
const res = await fetch(registryUrl);
if (!res.ok) {
throw new Error(
`Failed to fetch the template registry (${res.status}) from ${registryUrl}.`,
);
}
const registry = readRegistry(await res.text());
// The whole monorepo is downloaded once, lazily, and shared across every template
// a single scaffold pulls in.
let archive: { zip: AdmZip; root: string } | null = null;
const ensureArchive = async () => {
if (archive) return archive;
const url = `https://github.com/${repo}/archive/${ref}.zip`;
const zipRes = await fetch(url);
if (!zipRes.ok) {
throw new Error(`Failed to download templates (${zipRes.status}) from ${url}.`);
}
const zip = new AdmZip(Buffer.from(await zipRes.arrayBuffer()));
const first = zip.getEntries()[0];
if (!first) {
throw new Error("Downloaded templates archive was empty.");
}
// GitHub archives nest everything under a single top-level directory.
const root = first.entryName.split("/")[0];
archive = { zip, root };
return archive;
};
return {
registry,
async readManifest(entry) {
const { zip, root } = await ensureArchive();
const name = `${root}/${entry.path}/template.json`;
const found = zip.getEntry(name);
if (!found) {
throw new Error(`Template "${entry.id}" is missing template.json in the registry.`);
}
return JSON.parse(found.getData().toString("utf-8")) as TemplateManifest;
},
async copyInto(entry, destDir) {
const { zip, root } = await ensureArchive();
const prefix = `${root}/${entry.path}/`;
for (const e of zip.getEntries()) {
if (e.isDirectory || !e.entryName.startsWith(prefix)) continue;
const rel = e.entryName.slice(prefix.length);
if (rel.split("/").some((seg) => IGNORED_NAMES.has(seg))) continue;
const out = path.join(destDir, rel);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, e.getData());
}
},
};
}
function copyDir(src: string, dest: string) {
fs.mkdirSync(dest, { recursive: true });
for (const name of fs.readdirSync(src)) {
if (IGNORED_NAMES.has(name)) continue;
const from = path.join(src, name);
const to = path.join(dest, name);
const stat = fs.statSync(from);
if (stat.isDirectory()) {
copyDir(from, to);
} else if (stat.isFile()) {
fs.copyFileSync(from, to);
}
}
}
export function assertCliSupports(manifest: TemplateManifest, label: string) {
const min = manifest.requires?.cliMin;
if (min && compareSemver(VERSION, min) < 0) {
throw new Error(
`Template "${label}" requires seamless-cli >= ${min}, but this is ${VERSION}. Please upgrade.`,
);
}
}
// Copies the template's example env to .env (if declared), then applies the
// manifest's env.set, resolving {{placeholders}} against the scaffold context.
// This replaces the per-framework configure step.
export function applyTemplateEnv(
destDir: string,
manifest: TemplateManifest,
ctx: ScaffoldContext,
) {
const env = manifest.env ?? {};
const envPath = path.join(destDir, ".env");
let values: Record<string, string> = {};
if (env.fromExample) {
const examplePath = path.join(destDir, env.fromExample);
if (fs.existsSync(examplePath)) {
values = parseEnvString(fs.readFileSync(examplePath, "utf-8"));
}
} else if (fs.existsSync(envPath)) {
values = parseEnvString(fs.readFileSync(envPath, "utf-8"));
}
for (const [key, raw] of Object.entries(env.set ?? {})) {
values[key] = resolvePlaceholders(raw, ctx);
}
writeEnv(envPath, values);
}
function resolvePlaceholders(value: string, ctx: ScaffoldContext): string {
return value.replace(/\{\{\s*([\w:]+)\s*\}\}/g, (_match, token: string) =>
resolveToken(token, ctx),
);
}
function resolveToken(token: string, ctx: ScaffoldContext): string {
const secret = /^secret:(\d+)$/.exec(token);
if (secret) {
return generateSecret(Number(secret[1]));
}
const known: Record<string, string | undefined> = {
authServerUrl: ctx.authServerUrl,
apiUrl: ctx.apiUrl,
apiToken: ctx.apiToken,
jwksKid: ctx.jwksKid,
serveAdminConsole: ctx.serveAdminConsole,
databaseUrl: ctx.databaseUrl,
};
if (token in known) {
const resolved = known[token];
if (resolved == null) {
throw new Error(
`Template placeholder {{${token}}} has no value in this configuration.`,
);
}
return resolved;
}
throw new Error(`Unknown template placeholder {{${token}}}.`);
}
function compareSemver(a: string, b: string): number {
const norm = (v: string) =>
v.replace(/^v/, "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
const pa = norm(a);
const pb = norm(b);
for (let i = 0; i < 3; i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff < 0 ? -1 : 1;
}
return 0;
}