@lsts_tech/infra 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +58 -70
  2. package/dist/bin/init.d.ts +4 -3
  3. package/dist/bin/init.d.ts.map +1 -1
  4. package/dist/bin/init.js +619 -117
  5. package/dist/bin/init.js.map +1 -1
  6. package/dist/src/auth/index.d.ts +17 -0
  7. package/dist/src/auth/index.d.ts.map +1 -0
  8. package/dist/src/auth/index.js +18 -0
  9. package/dist/src/auth/index.js.map +1 -0
  10. package/dist/stacks/Dns.d.ts +24 -14
  11. package/dist/stacks/Dns.d.ts.map +1 -1
  12. package/dist/stacks/Dns.js +69 -18
  13. package/dist/stacks/Dns.js.map +1 -1
  14. package/dist/stacks/Pipeline.d.ts +7 -0
  15. package/dist/stacks/Pipeline.d.ts.map +1 -1
  16. package/dist/stacks/Pipeline.js +60 -7
  17. package/dist/stacks/Pipeline.js.map +1 -1
  18. package/docs/CLI.md +58 -15
  19. package/docs/CONFIGURATION.md +73 -30
  20. package/docs/EXAMPLES.md +5 -1
  21. package/examples/delegated-subdomain/infra.config.ts +102 -0
  22. package/examples/next-and-expo/infra.config.ts +33 -28
  23. package/examples/next-only/infra.config.ts +35 -22
  24. package/package.json +10 -4
  25. package/scripts/ensure-pipelines.sh +151 -43
  26. package/scripts/postdeploy-update-dns.sh +42 -11
  27. package/scripts/predeploy-checks.sh +38 -5
  28. package/templates/buildspec.yml +23 -0
  29. package/templates/ensure-pipelines.sh +157 -22
  30. package/templates/env.example +15 -0
  31. package/templates/infra.config.expo-web.ts +153 -0
  32. package/templates/infra.config.next-only.ts +159 -0
  33. package/templates/infra.config.ts +21 -4
  34. package/templates/pipelines.example.json +19 -0
  35. package/templates/private.example.json +13 -0
  36. package/templates/scaffold.gitignore +29 -0
  37. package/templates/scaffold.package.json +25 -0
  38. package/templates/scaffold.tsconfig.json +22 -0
  39. package/templates/secrets.schema.expo-web.json +8 -0
package/dist/bin/init.js CHANGED
@@ -1,82 +1,77 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @lsts_tech/infra — CLI Init Script
3
+ * @lsts_tech/infra — CLI
4
4
  *
5
- * Scaffolds project-specific configuration files for the infra package.
6
- * Run with: npx @lsts_tech/infra init
5
+ * Commands:
6
+ * - init: scaffold white-label infra files
7
+ * - doctor: validate AWS/domain/pipeline readiness before deploy
7
8
  */
8
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
9
- import { resolve, dirname, join } from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
11
+ import { dirname, join, resolve } from "node:path";
10
12
  import { fileURLToPath } from "node:url";
11
13
  const __filename = fileURLToPath(import.meta.url);
12
14
  const __dirname = dirname(__filename);
13
- const TEMPLATES_DIR = resolve(__dirname, "..", "..", "templates");
15
+ const PACKAGE_ROOT = resolve(__dirname, "..", "..");
16
+ const TEMPLATES_DIR = resolve(PACKAGE_ROOT, "templates");
17
+ const SCRIPTS_DIR = resolve(PACKAGE_ROOT, "scripts");
14
18
  const PIPELINE_DEFS = {
15
19
  production: { suffix: "prod", defaultBranch: "main" },
16
20
  dev: { suffix: "dev", defaultBranch: "develop" },
17
21
  mobile: { suffix: "mobile", defaultBranch: "mobile" },
18
22
  };
19
- const FILES_TO_SCAFFOLD = [
20
- {
21
- source: "sst.config.ts",
22
- target: "sst.config.ts",
23
- description: "SST app entrypoint",
24
- },
25
- {
26
- source: "sst-env.d.ts",
27
- target: "sst-env.d.ts",
28
- description: "SST type stubs",
29
- },
30
- {
31
- source: "infra.config.ts",
32
- target: "infra.config.ts",
33
- description: "Infrastructure configuration",
34
- },
35
- {
36
- source: "env.example",
37
- target: ".env.example",
38
- description: "Infrastructure environment template",
39
- },
40
- {
41
- source: "secrets.schema.json",
42
- target: "schemas/secrets.schema.json",
43
- description: "Secrets schema definition",
44
- },
45
- {
46
- source: "ensure-pipelines.sh",
47
- target: "scripts/ensure-pipelines.sh",
48
- description: "Pipeline management script",
49
- },
50
- {
51
- source: "buildspec.yml",
52
- target: "buildspec.yml",
53
- description: "CodeBuild build specification",
54
- },
55
- ];
23
+ const PROFILE_TEMPLATES = {
24
+ "next-only": "infra.config.next-only.ts",
25
+ "next-expo": "infra.config.ts",
26
+ "expo-web": "infra.config.expo-web.ts",
27
+ };
28
+ function readPackageVersion() {
29
+ try {
30
+ const raw = readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8");
31
+ const parsed = JSON.parse(raw);
32
+ if (parsed.version && parsed.version.trim().length > 0) {
33
+ return parsed.version.trim();
34
+ }
35
+ }
36
+ catch {
37
+ // best-effort fallback
38
+ }
39
+ return "1.0.1";
40
+ }
56
41
  function printHelp() {
57
42
  console.log(`
58
- @lsts_tech/infra — init
43
+ @lsts_tech/infra — CLI
59
44
 
60
45
  Usage:
61
- npx @lsts_tech/infra init [options]
46
+ npx @lsts_tech/infra <command> [options]
47
+
48
+ Commands:
49
+ init Scaffold environment-driven infra files
50
+ doctor Validate AWS/domain/pipeline readiness
62
51
 
63
- Options:
64
- --provider <name> Cloud provider to target (v1.0.0 supports: aws)
65
- --project <slug> Project/app prefix (default: myapp)
66
- --app-name <name> SST app name (default: --project)
67
- --domain <domain> Root domain (default: example.com)
68
- --repo <owner/repo> GitHub repo for pipelines (default: myorg/myrepo)
69
- --pipelines <list> Comma list: production,dev,mobile or 'none' (default: production,dev)
70
- --with-expo Enable Expo web site defaults in scaffolded config
71
- --infra-path <path> Infra path from monorepo root (default: packages/infra)
72
- --target <path> Directory to scaffold into (default: current directory)
73
- --force Overwrite existing files
74
- --help Show this help
52
+ Init options:
53
+ --provider <name> Cloud provider (v1 supports: aws)
54
+ --project <slug> Project/app prefix (default: myapp)
55
+ --app-name <name> SST app name (default: --project)
56
+ --domain <domain> Root domain (default: example.com)
57
+ --repo <owner/repo> GitHub repo for pipelines (default: myorg/myrepo)
58
+ --pipelines <list> CSV: production,dev,mobile or 'none' (default: production,dev)
59
+ --profile <name> next-only | next-expo | expo-web (default: next-only)
60
+ --with-expo Legacy shorthand for --profile next-expo
61
+ --pipeline-permissions <mode> admin | least-privilege (default: admin)
62
+ --infra-path <path> Infra path from monorepo root (default: packages/infra)
63
+ --target <path> Directory to scaffold into (default: current directory)
64
+ --force Overwrite existing files
65
+
66
+ Doctor options:
67
+ --target <path> Infra directory to inspect (default: current directory)
68
+ --region <aws-region> AWS region hint for checks (default: AWS_REGION or us-east-1)
69
+ --strict Fail if warnings exist
75
70
 
76
71
  Examples:
77
72
  npx @lsts_tech/infra init --project acme --domain acme.com --repo acme/web
78
- npx @lsts_tech/infra init --project acme --pipelines production,dev,mobile --with-expo
79
- npx @lsts_tech/infra init --target packages/infra --force
73
+ npx @lsts_tech/infra init --project acme --profile expo-web --pipelines production,mobile
74
+ npx @lsts_tech/infra doctor --target packages/infra --strict
80
75
  `);
81
76
  }
82
77
  function parseArgs(args) {
@@ -163,28 +158,23 @@ function parsePipelines(raw) {
163
158
  }
164
159
  return deduped;
165
160
  }
166
- function buildMapEntries(options, kind) {
167
- if (options.pipelines.length === 0) {
168
- return " # no pipelines configured";
169
- }
170
- return options.pipelines
171
- .map((pipeline) => {
172
- const def = PIPELINE_DEFS[pipeline];
173
- const name = `${options.project}-${def.suffix}`;
174
- if (kind === "stage") {
175
- return ` ["${name}"]="${pipeline}"`;
176
- }
177
- if (kind === "repo") {
178
- return ` ["${name}"]="${options.repo}"`;
179
- }
180
- const branchFlag = pipeline === "production"
181
- ? "branch-prod"
182
- : pipeline === "dev"
183
- ? "branch-dev"
184
- : "branch-mobile";
185
- return ` ["${name}"]="__${branchFlag.toUpperCase().replace(/-/g, "_")}__"`;
186
- })
187
- .join("\n");
161
+ function parseProfile(raw) {
162
+ const normalized = raw.trim().toLowerCase();
163
+ if (normalized === "next" || normalized === "next-only")
164
+ return "next-only";
165
+ if (normalized === "next-expo" || normalized === "next+expo")
166
+ return "next-expo";
167
+ if (normalized === "expo" || normalized === "expo-web")
168
+ return "expo-web";
169
+ throw new Error(`Invalid profile: ${raw}. Allowed: next-only,next-expo,expo-web`);
170
+ }
171
+ function parsePipelinePermissionsMode(raw) {
172
+ const normalized = raw.trim().toLowerCase();
173
+ if (normalized === "admin")
174
+ return "admin";
175
+ if (normalized === "least-privilege" || normalized === "least_privilege")
176
+ return "least-privilege";
177
+ throw new Error(`Invalid pipeline permissions mode: ${raw}. Allowed: admin,least-privilege`);
188
178
  }
189
179
  function applyTemplate(content, replacements) {
190
180
  let output = content;
@@ -193,7 +183,130 @@ function applyTemplate(content, replacements) {
193
183
  }
194
184
  return output;
195
185
  }
186
+ function getScaffoldFiles(profile) {
187
+ const files = [
188
+ {
189
+ sourceDir: "templates",
190
+ source: "sst.config.ts",
191
+ target: "sst.config.ts",
192
+ description: "SST app entrypoint",
193
+ templated: true,
194
+ },
195
+ {
196
+ sourceDir: "templates",
197
+ source: "sst-env.d.ts",
198
+ target: "sst-env.d.ts",
199
+ description: "SST type stubs",
200
+ templated: false,
201
+ },
202
+ {
203
+ sourceDir: "templates",
204
+ source: PROFILE_TEMPLATES[profile],
205
+ target: "infra.config.ts",
206
+ description: "Infrastructure configuration",
207
+ templated: true,
208
+ },
209
+ {
210
+ sourceDir: "templates",
211
+ source: "env.example",
212
+ target: ".env.example",
213
+ description: "Infrastructure environment template",
214
+ templated: true,
215
+ },
216
+ {
217
+ sourceDir: "templates",
218
+ source: profile === "expo-web" ? "secrets.schema.expo-web.json" : "secrets.schema.json",
219
+ target: "schemas/secrets.schema.json",
220
+ description: "Secrets schema definition",
221
+ templated: false,
222
+ },
223
+ {
224
+ sourceDir: "templates",
225
+ source: "ensure-pipelines.sh",
226
+ target: "scripts/ensure-pipelines.sh",
227
+ description: "Pipeline management script",
228
+ executable: true,
229
+ templated: true,
230
+ },
231
+ {
232
+ sourceDir: "scripts",
233
+ source: "predeploy-checks.sh",
234
+ target: "scripts/predeploy-checks.sh",
235
+ description: "Pre-deploy checks",
236
+ executable: true,
237
+ templated: false,
238
+ },
239
+ {
240
+ sourceDir: "scripts",
241
+ source: "postdeploy-update-dns.sh",
242
+ target: "scripts/postdeploy-update-dns.sh",
243
+ description: "Post-deploy DNS sync script",
244
+ executable: true,
245
+ templated: false,
246
+ },
247
+ {
248
+ sourceDir: "scripts",
249
+ source: "sst-deploy.sh",
250
+ target: "scripts/sst-deploy.sh",
251
+ description: "CI-safe SST deploy wrapper",
252
+ executable: true,
253
+ templated: false,
254
+ },
255
+ {
256
+ sourceDir: "scripts",
257
+ source: "ensure-secrets.sh",
258
+ target: "scripts/ensure-secrets.sh",
259
+ description: "Secret bootstrap script",
260
+ executable: true,
261
+ templated: false,
262
+ },
263
+ {
264
+ sourceDir: "templates",
265
+ source: "buildspec.yml",
266
+ target: "buildspec.yml",
267
+ description: "CodeBuild build specification",
268
+ templated: true,
269
+ },
270
+ {
271
+ sourceDir: "templates",
272
+ source: "scaffold.package.json",
273
+ target: "package.json",
274
+ description: "Infra package manifest",
275
+ templated: true,
276
+ },
277
+ {
278
+ sourceDir: "templates",
279
+ source: "scaffold.tsconfig.json",
280
+ target: "tsconfig.json",
281
+ description: "Infra TypeScript configuration",
282
+ templated: true,
283
+ },
284
+ {
285
+ sourceDir: "templates",
286
+ source: "scaffold.gitignore",
287
+ target: ".gitignore",
288
+ description: "Infra gitignore template",
289
+ templated: false,
290
+ },
291
+ {
292
+ sourceDir: "templates",
293
+ source: "pipelines.example.json",
294
+ target: "config/pipelines.example.json",
295
+ description: "Runtime pipeline config example",
296
+ templated: true,
297
+ },
298
+ {
299
+ sourceDir: "templates",
300
+ source: "private.example.json",
301
+ target: "config/private.example.json",
302
+ description: "Private local config example",
303
+ templated: true,
304
+ },
305
+ ];
306
+ return files;
307
+ }
196
308
  function buildReplacements(options) {
309
+ const infraVersion = readPackageVersion();
197
310
  return {
198
311
  "__PROVIDER__": options.provider,
199
312
  "__PROJECT_PREFIX__": options.project,
@@ -201,40 +314,390 @@ function buildReplacements(options) {
201
314
  "__ROOT_DOMAIN__": options.domain,
202
315
  "__PIPELINE_REPO__": options.repo,
203
316
  "__PIPELINES_DEFAULT__": options.pipelines.join(","),
204
- "__ENABLE_EXPO_SITE__": options.withExpo ? "true" : "false",
317
+ "__ENABLE_EXPO_SITE__": options.profile === "next-expo" || options.profile === "expo-web" ? "true" : "false",
318
+ "__PROFILE__": options.profile,
205
319
  "__INFRA_PATH__": options.infraPath,
206
- "__PIPELINE_STAGE_MAP__": buildMapEntries(options, "stage"),
207
- "__PIPELINE_REPO_MAP__": buildMapEntries(options, "repo"),
208
- "__PIPELINE_BRANCH_MAP__": buildMapEntries(options, "branch"),
320
+ "__CREATE_PIPELINES_DEFAULT__": "false",
321
+ "__PIPELINE_PERMISSIONS_MODE__": options.pipelinePermissionsMode,
322
+ "__INFRA_VERSION__": `^${infraVersion}`,
209
323
  };
210
324
  }
211
- function main() {
212
- const parsed = parseArgs(process.argv.slice(2));
213
- if (!parsed.command || parsed.command === "help" || parsed.flags.help) {
214
- printHelp();
215
- process.exit(0);
325
+ function commandExists(name) {
326
+ const check = spawnSync("bash", ["-lc", `command -v ${name}`], {
327
+ stdio: "ignore",
328
+ });
329
+ return check.status === 0;
330
+ }
331
+ function parseEnvFile(envPath) {
332
+ if (!existsSync(envPath)) {
333
+ return {};
216
334
  }
217
- if (parsed.command !== "init") {
218
- console.error(`Unknown command: ${parsed.command}`);
219
- printHelp();
335
+ const raw = readFileSync(envPath, "utf-8");
336
+ const output = {};
337
+ for (const line of raw.split(/\r?\n/)) {
338
+ const trimmed = line.trim();
339
+ if (!trimmed || trimmed.startsWith("#")) {
340
+ continue;
341
+ }
342
+ const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
343
+ if (!match) {
344
+ continue;
345
+ }
346
+ const key = match[1];
347
+ let value = match[2].trim();
348
+ if ((value.startsWith("\"") && value.endsWith("\"")) ||
349
+ (value.startsWith("'") && value.endsWith("'"))) {
350
+ value = value.slice(1, -1);
351
+ }
352
+ output[key] = value;
353
+ }
354
+ return output;
355
+ }
356
+ function runCommand(binary, args, cwd) {
357
+ return spawnSync(binary, args, {
358
+ cwd,
359
+ encoding: "utf-8",
360
+ stdio: ["ignore", "pipe", "pipe"],
361
+ });
362
+ }
363
+ function runAwsJson(args, cwd) {
364
+ const result = runCommand("aws", [...args, "--output", "json"], cwd);
365
+ if (result.status !== 0) {
366
+ return null;
367
+ }
368
+ try {
369
+ return JSON.parse(result.stdout || "{}");
370
+ }
371
+ catch {
372
+ return null;
373
+ }
374
+ }
375
+ function domainCandidates(domain) {
376
+ const labels = domain
377
+ .toLowerCase()
378
+ .replace(/\.$/, "")
379
+ .split(".")
380
+ .filter(Boolean);
381
+ if (labels.length < 2) {
382
+ return [domain.replace(/\.$/, "")];
383
+ }
384
+ const candidates = [];
385
+ for (let index = 0; index <= labels.length - 2; index++) {
386
+ candidates.push(labels.slice(index).join("."));
387
+ }
388
+ return Array.from(new Set(candidates));
389
+ }
390
+ function findHostedZone(domain, cwd) {
391
+ for (const candidate of domainCandidates(domain)) {
392
+ const zones = runAwsJson(["route53", "list-hosted-zones-by-name", "--dns-name", candidate], cwd);
393
+ const hostedZones = Array.isArray(zones?.HostedZones) ? zones.HostedZones : [];
394
+ for (const zone of hostedZones) {
395
+ const zoneName = String(zone?.Name ?? "").replace(/\.$/, "").toLowerCase();
396
+ if (zoneName !== candidate.toLowerCase()) {
397
+ continue;
398
+ }
399
+ const zoneIdRaw = String(zone?.Id ?? "");
400
+ const zoneId = zoneIdRaw.replace("/hostedzone/", "");
401
+ return {
402
+ requested: domain,
403
+ matchedCandidate: candidate,
404
+ zoneName,
405
+ zoneId,
406
+ };
407
+ }
408
+ }
409
+ return null;
410
+ }
411
+ function isLikelyRepo(repo) {
412
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo);
413
+ }
414
+ function isBoolTrue(value, fallback = false) {
415
+ if (!value)
416
+ return fallback;
417
+ const normalized = value.trim().toLowerCase();
418
+ if (["1", "true", "yes", "y"].includes(normalized))
419
+ return true;
420
+ if (["0", "false", "no", "n"].includes(normalized))
421
+ return false;
422
+ return fallback;
423
+ }
424
+ function wildcardMatch(certDomain, domain) {
425
+ const normalizedCert = certDomain.toLowerCase();
426
+ const normalizedDomain = domain.toLowerCase();
427
+ if (normalizedCert === normalizedDomain) {
428
+ return true;
429
+ }
430
+ if (!normalizedCert.startsWith("*.")) {
431
+ return false;
432
+ }
433
+ const certSuffix = normalizedCert.slice(1); // ".example.com"
434
+ if (!normalizedDomain.endsWith(certSuffix)) {
435
+ return false;
436
+ }
437
+ // Wildcard should match one additional label at minimum.
438
+ const certLabelCount = normalizedCert.split(".").length;
439
+ const domainLabelCount = normalizedDomain.split(".").length;
440
+ return domainLabelCount >= certLabelCount;
441
+ }
442
+ function addDoctorResult(results, status, label, detail) {
443
+ results.push({ status, label, detail });
444
+ const icon = status === "PASS" ? "✅" : status === "WARN" ? "⚠️" : "❌";
445
+ console.log(`${icon} ${label}: ${detail}`);
446
+ }
447
+ function runDoctor(flags) {
448
+ const targetRaw = readFlag(flags, "target", ".");
449
+ const targetDir = resolve(process.cwd(), targetRaw);
450
+ const strict = readBool(flags, "strict", false);
451
+ const region = readFlag(flags, "region", process.env.AWS_REGION ?? "us-east-1");
452
+ const envFromFile = parseEnvFile(join(targetDir, ".env"));
453
+ const env = {
454
+ ...envFromFile,
455
+ ...Object.fromEntries(Object.entries(process.env)
456
+ .filter((entry) => typeof entry[1] === "string")
457
+ .map(([key, value]) => [key, value])),
458
+ };
459
+ const rootDomain = env.INFRA_ROOT_DOMAIN ?? env.DOMAIN_ROOT ?? "";
460
+ const pipelineRepo = env.INFRA_PIPELINE_REPO ?? "";
461
+ const pipelinePrefix = env.INFRA_PIPELINE_PREFIX ?? "myapp";
462
+ let selectedPipelines = [];
463
+ try {
464
+ selectedPipelines = parsePipelines(env.INFRA_PIPELINES ?? "production,dev");
465
+ }
466
+ catch (error) {
467
+ console.error(error instanceof Error ? error.message : String(error));
220
468
  process.exit(1);
469
+ return;
470
+ }
471
+ const enableExpo = isBoolTrue(env.INFRA_ENABLE_EXPO_SITE, false) || env.INFRA_PROFILE === "expo-web";
472
+ const webStageMap = {
473
+ production: env.INFRA_WEB_DOMAIN_PRODUCTION ?? rootDomain,
474
+ dev: env.INFRA_WEB_DOMAIN_DEV ?? (rootDomain ? `dev.${rootDomain}` : ""),
475
+ mobile: env.INFRA_WEB_DOMAIN_MOBILE ?? (rootDomain ? `api.${rootDomain}` : ""),
476
+ };
477
+ const expoStageMap = {
478
+ production: env.INFRA_EXPO_DOMAIN_PRODUCTION ?? (rootDomain ? `mobile.${rootDomain}` : ""),
479
+ dev: env.INFRA_EXPO_DOMAIN_DEV ?? (rootDomain ? `dev.mobile.${rootDomain}` : ""),
480
+ mobile: env.INFRA_EXPO_DOMAIN_MOBILE ?? (rootDomain ? `preview.mobile.${rootDomain}` : ""),
481
+ };
482
+ const results = [];
483
+ console.log("\n🩺 @lsts_tech/infra doctor\n");
484
+ console.log(`target : ${targetDir}`);
485
+ console.log(`region : ${region}`);
486
+ if (!existsSync(targetDir)) {
487
+ console.error(`Target directory does not exist: ${targetDir}`);
488
+ process.exit(1);
489
+ }
490
+ if (!rootDomain) {
491
+ addDoctorResult(results, "FAIL", "Root domain", "INFRA_ROOT_DOMAIN (or DOMAIN_ROOT) is required.");
492
+ }
493
+ else {
494
+ addDoctorResult(results, "PASS", "Root domain", rootDomain);
495
+ }
496
+ if (!pipelineRepo) {
497
+ addDoctorResult(results, "WARN", "Pipeline repo", "INFRA_PIPELINE_REPO is not set.");
498
+ }
499
+ else if (!isLikelyRepo(pipelineRepo)) {
500
+ addDoctorResult(results, "WARN", "Pipeline repo", `Unexpected format: ${pipelineRepo} (expected owner/repo).`);
501
+ }
502
+ else {
503
+ addDoctorResult(results, "PASS", "Pipeline repo", pipelineRepo);
504
+ }
505
+ const permissionsMode = env.INFRA_PIPELINE_PERMISSIONS_MODE ?? "admin";
506
+ if (permissionsMode !== "admin" && permissionsMode !== "least-privilege") {
507
+ addDoctorResult(results, "FAIL", "Pipeline permissions mode", `Invalid value '${permissionsMode}'. Allowed: admin, least-privilege.`);
508
+ }
509
+ else {
510
+ addDoctorResult(results, "PASS", "Pipeline permissions mode", permissionsMode);
221
511
  }
222
- const providerRaw = readFlag(parsed.flags, "provider", "aws").toLowerCase();
512
+ if (!commandExists("aws")) {
513
+ addDoctorResult(results, "FAIL", "AWS CLI", "aws CLI is not installed or not in PATH.");
514
+ }
515
+ else {
516
+ const sts = runAwsJson(["sts", "get-caller-identity"], targetDir);
517
+ if (!sts?.Account) {
518
+ addDoctorResult(results, "FAIL", "AWS auth", "Unable to call sts:get-caller-identity with current credentials.");
519
+ }
520
+ else {
521
+ addDoctorResult(results, "PASS", "AWS auth", `Authenticated as account ${String(sts.Account)} (${String(sts.Arn ?? "unknown")})`);
522
+ }
523
+ }
524
+ const stageDomainPairs = [];
525
+ for (const stage of ["production", "dev", "mobile"]) {
526
+ if (webStageMap[stage]) {
527
+ stageDomainPairs.push({ stage, kind: "web", domain: webStageMap[stage] });
528
+ }
529
+ if (enableExpo && expoStageMap[stage]) {
530
+ stageDomainPairs.push({ stage, kind: "expo", domain: expoStageMap[stage] });
531
+ }
532
+ }
533
+ const duplicates = new Map();
534
+ for (const entry of stageDomainPairs) {
535
+ duplicates.set(entry.domain, (duplicates.get(entry.domain) ?? 0) + 1);
536
+ if (!rootDomain || entry.domain.endsWith(rootDomain)) {
537
+ addDoctorResult(results, "PASS", `Stage domain (${entry.kind}:${entry.stage})`, entry.domain);
538
+ }
539
+ else {
540
+ addDoctorResult(results, "WARN", `Stage domain (${entry.kind}:${entry.stage})`, `${entry.domain} does not end with root domain ${rootDomain}.`);
541
+ }
542
+ }
543
+ for (const [domain, count] of duplicates.entries()) {
544
+ if (count > 1) {
545
+ addDoctorResult(results, "WARN", "Domain overlap", `${domain} is used by multiple stage mappings. Confirm this is intentional.`);
546
+ }
547
+ }
548
+ if (commandExists("aws") && rootDomain) {
549
+ const checkedDomains = Array.from(new Set(stageDomainPairs.map((entry) => entry.domain))).filter(Boolean);
550
+ for (const domain of checkedDomains) {
551
+ const zone = findHostedZone(domain, targetDir);
552
+ if (!zone) {
553
+ addDoctorResult(results, "FAIL", `Hosted zone (${domain})`, "No matching Route53 hosted zone found for domain or parent domains.");
554
+ continue;
555
+ }
556
+ if (zone.matchedCandidate === domain.toLowerCase()) {
557
+ addDoctorResult(results, "PASS", `Hosted zone (${domain})`, `Resolved exact zone ${zone.zoneName} (${zone.zoneId}).`);
558
+ }
559
+ else {
560
+ addDoctorResult(results, "WARN", `Hosted zone (${domain})`, `Falling back to parent zone ${zone.zoneName} (${zone.zoneId}) via candidate ${zone.matchedCandidate}.`);
561
+ }
562
+ }
563
+ }
564
+ const certMap = {
565
+ [webStageMap.production]: env.INFRA_WEB_CERT_ARN_PRODUCTION,
566
+ [webStageMap.dev]: env.INFRA_WEB_CERT_ARN_DEV,
567
+ [webStageMap.mobile]: env.INFRA_WEB_CERT_ARN_MOBILE,
568
+ };
569
+ if (enableExpo) {
570
+ certMap[expoStageMap.production] = env.INFRA_EXPO_CERT_ARN_PRODUCTION;
571
+ certMap[expoStageMap.dev] = env.INFRA_EXPO_CERT_ARN_DEV;
572
+ certMap[expoStageMap.mobile] = env.INFRA_EXPO_CERT_ARN_MOBILE;
573
+ }
574
+ const acmList = commandExists("aws")
575
+ ? runAwsJson([
576
+ "acm",
577
+ "list-certificates",
578
+ "--region",
579
+ "us-east-1",
580
+ "--certificate-statuses",
581
+ "ISSUED",
582
+ "PENDING_VALIDATION",
583
+ ], targetDir)
584
+ : null;
585
+ const acmDomains = Array.isArray(acmList?.CertificateSummaryList)
586
+ ? acmList.CertificateSummaryList
587
+ .map((item) => item?.DomainName)
588
+ .filter((value) => typeof value === "string" && value.length > 0)
589
+ : [];
590
+ for (const domain of Array.from(new Set(Object.keys(certMap))).filter(Boolean)) {
591
+ const explicitArn = certMap[domain];
592
+ if (explicitArn && explicitArn.trim().length > 0) {
593
+ addDoctorResult(results, "PASS", `ACM (${domain})`, `Using explicit cert ARN (${explicitArn}).`);
594
+ continue;
595
+ }
596
+ const hasMatch = acmDomains.some((certDomain) => wildcardMatch(certDomain, domain));
597
+ if (hasMatch) {
598
+ addDoctorResult(results, "PASS", `ACM (${domain})`, "Found existing ISSUED/PENDING_VALIDATION certificate in us-east-1.");
599
+ }
600
+ else {
601
+ addDoctorResult(results, "WARN", `ACM (${domain})`, "No existing certificate found; SST will request one during deploy.");
602
+ }
603
+ }
604
+ if (commandExists("aws")) {
605
+ const explicitConnectionArn = env.INFRA_CODESTAR_CONNECTION_ARN ?? env.CODESTAR_CONNECTION_ARN;
606
+ if (explicitConnectionArn) {
607
+ const connection = runAwsJson(["codestar-connections", "get-connection", "--connection-arn", explicitConnectionArn], targetDir);
608
+ const status = connection?.Connection?.ConnectionStatus;
609
+ if (status === "AVAILABLE") {
610
+ addDoctorResult(results, "PASS", "CodeStar connection", `${explicitConnectionArn} is AVAILABLE.`);
611
+ }
612
+ else if (status) {
613
+ addDoctorResult(results, "WARN", "CodeStar connection", `${explicitConnectionArn} status is ${status}.`);
614
+ }
615
+ else {
616
+ addDoctorResult(results, "FAIL", "CodeStar connection", `Unable to fetch ${explicitConnectionArn}.`);
617
+ }
618
+ }
619
+ else {
620
+ const list = runAwsJson(["codestar-connections", "list-connections", "--provider-type-filter", "GitHub"], targetDir);
621
+ const connections = Array.isArray(list?.Connections) ? list.Connections : [];
622
+ const available = connections.filter((connection) => connection.ConnectionStatus === "AVAILABLE");
623
+ if (available.length > 0) {
624
+ addDoctorResult(results, "PASS", "CodeStar connection", `Found ${available.length} AVAILABLE GitHub connection(s).`);
625
+ }
626
+ else if (connections.length > 0) {
627
+ addDoctorResult(results, "WARN", "CodeStar connection", "Connections exist but none are AVAILABLE.");
628
+ }
629
+ else {
630
+ addDoctorResult(results, "WARN", "CodeStar connection", "No GitHub CodeStar connection found.");
631
+ }
632
+ }
633
+ }
634
+ if (selectedPipelines.length === 0) {
635
+ addDoctorResult(results, "PASS", "Pipeline selection", "No pipelines selected (INFRA_PIPELINES=none).");
636
+ }
637
+ else {
638
+ addDoctorResult(results, "PASS", "Pipeline selection", `Selected: ${selectedPipelines.join(",")}`);
639
+ }
640
+ const branchMap = {
641
+ production: env.INFRA_PIPELINE_BRANCH_PROD ?? PIPELINE_DEFS.production.defaultBranch,
642
+ dev: env.INFRA_PIPELINE_BRANCH_DEV ?? PIPELINE_DEFS.dev.defaultBranch,
643
+ mobile: env.INFRA_PIPELINE_BRANCH_MOBILE ?? PIPELINE_DEFS.mobile.defaultBranch,
644
+ };
645
+ for (const stage of selectedPipelines) {
646
+ const branch = branchMap[stage];
647
+ if (!branch || branch.trim().length === 0) {
648
+ addDoctorResult(results, "FAIL", `Pipeline branch (${stage})`, "Branch is empty.");
649
+ continue;
650
+ }
651
+ addDoctorResult(results, "PASS", `Pipeline branch (${stage})`, branch);
652
+ }
653
+ const createPipelines = isBoolTrue(env.INFRA_CREATE_PIPELINES, false);
654
+ if (createPipelines) {
655
+ addDoctorResult(results, "WARN", "Pipeline mutation mode", "INFRA_CREATE_PIPELINES=true. Production deploys will create/update pipelines.");
656
+ }
657
+ else {
658
+ addDoctorResult(results, "PASS", "Pipeline mutation mode", "INFRA_CREATE_PIPELINES=false (safe default).");
659
+ }
660
+ const failures = results.filter((result) => result.status === "FAIL").length;
661
+ const warnings = results.filter((result) => result.status === "WARN").length;
662
+ console.log("\nSummary");
663
+ console.log(` Failures : ${failures}`);
664
+ console.log(` Warnings : ${warnings}`);
665
+ console.log(` Result : ${failures > 0 || (strict && warnings > 0) ? "FAILED" : "PASSED"}`);
666
+ if (failures > 0 || (strict && warnings > 0)) {
667
+ process.exit(1);
668
+ }
669
+ }
670
+ function runInit(flags) {
671
+ const providerRaw = readFlag(flags, "provider", "aws").toLowerCase();
223
672
  if (providerRaw !== "aws") {
224
- console.error(`Unsupported provider: ${providerRaw}. v1.0.0 currently supports only aws.`);
673
+ console.error(`Unsupported provider: ${providerRaw}. v1 currently supports only aws.`);
225
674
  process.exit(1);
226
675
  }
227
- const project = toSlug(readFlag(parsed.flags, "project", "myapp"));
228
- const appName = readFlag(parsed.flags, "app-name", project);
229
- const domain = readFlag(parsed.flags, "domain", "example.com");
230
- const repo = readFlag(parsed.flags, "repo", "myorg/myrepo");
231
- const infraPath = readFlag(parsed.flags, "infra-path", "packages/infra");
232
- const withExpo = readBool(parsed.flags, "with-expo", false);
233
- const force = readBool(parsed.flags, "force", false);
234
- const branchProd = readFlag(parsed.flags, "branch-prod", PIPELINE_DEFS.production.defaultBranch);
235
- const branchDev = readFlag(parsed.flags, "branch-dev", PIPELINE_DEFS.dev.defaultBranch);
236
- const branchMobile = readFlag(parsed.flags, "branch-mobile", PIPELINE_DEFS.mobile.defaultBranch);
237
- const pipelinesRaw = readFlag(parsed.flags, "pipelines", "production,dev");
676
+ const project = toSlug(readFlag(flags, "project", "myapp"));
677
+ const appName = readFlag(flags, "app-name", project);
678
+ const domain = readFlag(flags, "domain", "example.com");
679
+ const repo = readFlag(flags, "repo", "myorg/myrepo");
680
+ const infraPath = readFlag(flags, "infra-path", "packages/infra");
681
+ const withExpo = readBool(flags, "with-expo", false);
682
+ const force = readBool(flags, "force", false);
683
+ const profileFlag = readFlag(flags, "profile", "");
684
+ let profile;
685
+ try {
686
+ profile = profileFlag
687
+ ? parseProfile(profileFlag)
688
+ : withExpo
689
+ ? "next-expo"
690
+ : "next-only";
691
+ }
692
+ catch (error) {
693
+ console.error(error instanceof Error ? error.message : String(error));
694
+ process.exit(1);
695
+ return;
696
+ }
697
+ const branchProd = readFlag(flags, "branch-prod", PIPELINE_DEFS.production.defaultBranch);
698
+ const branchDev = readFlag(flags, "branch-dev", PIPELINE_DEFS.dev.defaultBranch);
699
+ const branchMobile = readFlag(flags, "branch-mobile", PIPELINE_DEFS.mobile.defaultBranch);
700
+ const pipelinesRaw = readFlag(flags, "pipelines", profile === "expo-web" ? "production,dev,mobile" : "production,dev");
238
701
  let pipelines;
239
702
  try {
240
703
  pipelines = parsePipelines(pipelinesRaw);
@@ -244,7 +707,17 @@ function main() {
244
707
  process.exit(1);
245
708
  return;
246
709
  }
247
- const targetRaw = readFlag(parsed.flags, "target", ".");
710
+ const pipelinePermissionsModeRaw = readFlag(flags, "pipeline-permissions", "admin");
711
+ let pipelinePermissionsMode;
712
+ try {
713
+ pipelinePermissionsMode = parsePipelinePermissionsMode(pipelinePermissionsModeRaw);
714
+ }
715
+ catch (error) {
716
+ console.error(error instanceof Error ? error.message : String(error));
717
+ process.exit(1);
718
+ return;
719
+ }
720
+ const targetRaw = readFlag(flags, "target", ".");
248
721
  const targetDir = resolve(process.cwd(), targetRaw);
249
722
  const options = {
250
723
  provider: "aws",
@@ -252,11 +725,12 @@ function main() {
252
725
  domain,
253
726
  repo,
254
727
  pipelines,
255
- withExpo,
728
+ profile,
256
729
  appName,
257
730
  infraPath,
258
731
  targetDir,
259
732
  force,
733
+ pipelinePermissionsMode,
260
734
  };
261
735
  const replacements = buildReplacements(options);
262
736
  replacements["__BRANCH_PROD__"] = branchProd;
@@ -267,17 +741,25 @@ function main() {
267
741
  console.log(` targetDir : ${options.targetDir}`);
268
742
  console.log(` project : ${options.project}`);
269
743
  console.log(` appName : ${options.appName}`);
744
+ console.log(` profile : ${options.profile}`);
270
745
  console.log(` domain : ${options.domain}`);
271
746
  console.log(` repo : ${options.repo}`);
272
747
  console.log(` pipelines : ${options.pipelines.join(",") || "none"}`);
273
- console.log(` expo site : ${options.withExpo ? "enabled" : "disabled"}`);
748
+ console.log(` permissions: ${options.pipelinePermissionsMode}`);
274
749
  console.log("");
275
750
  let created = 0;
276
751
  let overwritten = 0;
277
752
  let skipped = 0;
278
- for (const file of FILES_TO_SCAFFOLD) {
279
- const sourcePath = join(TEMPLATES_DIR, file.source);
753
+ const files = getScaffoldFiles(profile);
754
+ for (const file of files) {
755
+ const root = file.sourceDir === "templates" ? TEMPLATES_DIR : SCRIPTS_DIR;
756
+ const sourcePath = join(root, file.source);
280
757
  const targetPath = join(options.targetDir, file.target);
758
+ if (!existsSync(sourcePath)) {
759
+ console.warn(` ⚠️ ${file.target} — source missing (${sourcePath}), skipped`);
760
+ skipped++;
761
+ continue;
762
+ }
281
763
  const exists = existsSync(targetPath);
282
764
  if (exists && !options.force) {
283
765
  console.log(` ⏭ ${file.target} — already exists (skipped)`);
@@ -289,9 +771,9 @@ function main() {
289
771
  mkdirSync(targetParent, { recursive: true });
290
772
  }
291
773
  const raw = readFileSync(sourcePath, "utf-8");
292
- const content = applyTemplate(raw, replacements);
774
+ const content = file.templated ? applyTemplate(raw, replacements) : raw;
293
775
  writeFileSync(targetPath, content, "utf-8");
294
- if (file.target.endsWith(".sh")) {
776
+ if (file.executable || file.target.endsWith(".sh")) {
295
777
  chmodSync(targetPath, 0o755);
296
778
  }
297
779
  if (exists) {
@@ -305,11 +787,31 @@ function main() {
305
787
  }
306
788
  console.log(`\n📦 Done! Created ${created}, overwritten ${overwritten}, skipped ${skipped}.\n`);
307
789
  console.log("Next steps:");
308
- console.log(" 1. Review .env.example and copy values into your deployment environment");
309
- console.log(" 2. Set your SST secrets (npx sst secrets set <Name> <value> --stage <stage>)");
310
- console.log(" 3. Run your first deploy (npx sst deploy --stage dev)");
311
- console.log(" 4. Optionally ensure pipelines (APPROVE=true bash scripts/ensure-pipelines.sh)");
790
+ console.log(" 1. Copy .env.example to .env and set project values");
791
+ console.log(" 2. Review config/pipelines.example.json and create config/pipelines.json if needed");
792
+ console.log(" 3. Set SST secrets (npx sst secret set <Name> <value> --stage <stage>)");
793
+ console.log(" 4. Validate setup (npx @lsts_tech/infra doctor --target .)");
794
+ console.log(" 5. Deploy app infra (npx sst deploy --stage dev)");
795
+ console.log(" 6. Create pipelines explicitly (APPROVE=true bash scripts/ensure-pipelines.sh)");
312
796
  console.log("");
313
797
  }
798
+ function main() {
799
+ const parsed = parseArgs(process.argv.slice(2));
800
+ if (!parsed.command || parsed.command === "help" || parsed.flags.help) {
801
+ printHelp();
802
+ process.exit(0);
803
+ }
804
+ if (parsed.command === "init") {
805
+ runInit(parsed.flags);
806
+ return;
807
+ }
808
+ if (parsed.command === "doctor") {
809
+ runDoctor(parsed.flags);
810
+ return;
811
+ }
812
+ console.error(`Unknown command: ${parsed.command}`);
813
+ printHelp();
814
+ process.exit(1);
815
+ }
314
816
  main();
315
817
  //# sourceMappingURL=init.js.map