create-ampless 0.2.0-alpha.5 → 0.2.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { spinner as spinner2, outro, log as log3 } from "@clack/prompts";
5
5
  import { existsSync as existsSync2 } from "fs";
6
- import { resolve as resolve4 } from "path";
6
+ import { basename as basename3, resolve as resolve4 } from "path";
7
7
 
8
8
  // src/prompts.ts
9
9
  import * as p from "@clack/prompts";
@@ -192,17 +192,24 @@ async function scaffold(sharedDir, themesRoot, destDir, opts) {
192
192
  }
193
193
 
194
194
  // src/args.ts
195
+ var VALID_THEMES = ["blog", "minimal", "landing", "corporate", "docs", "dads"];
196
+ var VALID_PLUGINS = ["seo", "rss", "webhook"];
195
197
  var STRING_FLAGS = /* @__PURE__ */ new Set([
198
+ "--site-name",
199
+ "--themes",
200
+ "--plugins",
196
201
  "--github-owner",
197
202
  "--github-token",
198
203
  "--aws-profile",
199
204
  "--aws-region",
200
205
  "--domain",
201
- "--subdomain"
206
+ "--subdomain",
207
+ "--iam-service-role"
202
208
  ]);
203
209
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
204
210
  "--deploy",
205
211
  "--github-private",
212
+ "--create-iam-role",
206
213
  "--skip-confirm",
207
214
  "--help",
208
215
  "-h"
@@ -211,6 +218,7 @@ function parseDeployArgs(argv) {
211
218
  const out = {
212
219
  deploy: false,
213
220
  githubPrivate: false,
221
+ createIamRole: false,
214
222
  skipConfirm: false,
215
223
  help: false,
216
224
  unknown: []
@@ -232,6 +240,9 @@ function parseDeployArgs(argv) {
232
240
  case "--github-private":
233
241
  out.githubPrivate = true;
234
242
  break;
243
+ case "--create-iam-role":
244
+ out.createIamRole = true;
245
+ break;
235
246
  case "--skip-confirm":
236
247
  out.skipConfirm = true;
237
248
  break;
@@ -248,6 +259,31 @@ function parseDeployArgs(argv) {
248
259
  throw new Error(`Missing value for ${token}`);
249
260
  }
250
261
  switch (token) {
262
+ case "--site-name":
263
+ out.siteName = value;
264
+ break;
265
+ case "--themes": {
266
+ const themes = value.split(",").map((t) => t.trim()).filter(Boolean);
267
+ const invalid = themes.filter((t) => !VALID_THEMES.includes(t));
268
+ if (invalid.length > 0) {
269
+ throw new Error(
270
+ `Invalid theme(s): ${invalid.join(", ")}. Valid values: ${VALID_THEMES.join(", ")}`
271
+ );
272
+ }
273
+ out.themes = themes;
274
+ break;
275
+ }
276
+ case "--plugins": {
277
+ const plugins = value.split(",").map((p3) => p3.trim()).filter(Boolean);
278
+ const invalid = plugins.filter((p3) => !VALID_PLUGINS.includes(p3));
279
+ if (invalid.length > 0) {
280
+ throw new Error(
281
+ `Invalid plugin(s): ${invalid.join(", ")}. Valid values: ${VALID_PLUGINS.join(", ")}`
282
+ );
283
+ }
284
+ out.plugins = plugins;
285
+ break;
286
+ }
251
287
  case "--github-owner":
252
288
  out.githubOwner = value;
253
289
  break;
@@ -266,6 +302,9 @@ function parseDeployArgs(argv) {
266
302
  case "--subdomain":
267
303
  out.subdomain = value;
268
304
  break;
305
+ case "--iam-service-role":
306
+ out.iamServiceRole = value;
307
+ break;
269
308
  }
270
309
  continue;
271
310
  }
@@ -287,6 +326,13 @@ Usage:
287
326
  npx create-ampless@alpha <project-name> [options]
288
327
 
289
328
  Options:
329
+ --site-name <name> Site display name (default: "My Blog")
330
+ --themes <list> Comma-separated theme names to install
331
+ Valid: blog, minimal, landing, corporate, docs, dads
332
+ (default: blog)
333
+ --plugins <list> Comma-separated plugin names to install
334
+ Valid: seo, rss, webhook
335
+ (default: seo)
290
336
  --deploy Also create GitHub repo + Amplify Hosting app and
291
337
  kick off the first deploy after scaffolding
292
338
  --github-owner <login> GitHub owner (user or org). Defaults to the
@@ -298,26 +344,507 @@ Options:
298
344
  --aws-region <region> AWS region (defaults to aws config / env)
299
345
  --domain <name> Custom domain (apex or subdomain) to attach
300
346
  --subdomain <prefix> Subdomain prefix for the domain (default: apex)
301
- --skip-confirm Don't ask 'proceed?' before running
347
+ --iam-service-role <arn> Existing IAM role for Amplify Hosting (must trust
348
+ amplify.amazonaws.com and have
349
+ AdministratorAccess-Amplify attached)
350
+ --create-iam-role Let create-ampless provision the Amplify Hosting
351
+ service role (idempotent; defaults to role name
352
+ AmplifyDeployBackend)
353
+ --skip-confirm Skip all interactive prompts and use defaults /
354
+ flag values (for CI / automation)
302
355
  -h, --help Show this message
303
356
  `;
304
357
 
305
358
  // src/deploy-prompts.ts
306
359
  import * as p2 from "@clack/prompts";
307
- import { execa as execa2 } from "execa";
360
+ import { execa as execa3 } from "execa";
308
361
 
309
362
  // src/deploy.ts
310
- import { execa } from "execa";
363
+ import { execa as execa2 } from "execa";
311
364
  import { writeFile as writeFile2 } from "fs/promises";
312
- import { resolve as resolve3 } from "path";
365
+ import { basename as basename2, resolve as resolve3 } from "path";
313
366
  import { spinner, log } from "@clack/prompts";
367
+ import pc2 from "picocolors";
368
+
369
+ // src/preflight.ts
370
+ import { execa } from "execa";
371
+ import { basename } from "path";
314
372
  import pc from "picocolors";
373
+ var DEFAULT_AMPLIFY_ROLE_NAME = "AmplifyDeployBackend";
374
+ var AMPLIFY_BACKEND_POLICY_ARN = "arn:aws:iam::aws:policy/AdministratorAccess-Amplify";
375
+ var TRUST_POLICY_JSON = JSON.stringify({
376
+ Version: "2012-10-17",
377
+ Statement: [
378
+ {
379
+ Effect: "Allow",
380
+ Principal: { Service: "amplify.amazonaws.com" },
381
+ Action: "sts:AssumeRole"
382
+ }
383
+ ]
384
+ });
385
+ async function tryExec(cmd, args) {
386
+ try {
387
+ const r = await execa(cmd, args, { reject: false, stdio: "pipe" });
388
+ return {
389
+ exitCode: r.exitCode ?? 0,
390
+ stdout: r.stdout?.toString() ?? "",
391
+ stderr: r.stderr?.toString() ?? ""
392
+ };
393
+ } catch (err) {
394
+ const e = err;
395
+ return {
396
+ exitCode: 1,
397
+ stdout: e.stdout ?? "",
398
+ stderr: e.stderr ?? e.message ?? String(err)
399
+ };
400
+ }
401
+ }
402
+ function awsBaseArgs(opts) {
403
+ const out = [];
404
+ if (opts.awsProfile) out.push("--profile", opts.awsProfile);
405
+ if (opts.awsRegion) out.push("--region", opts.awsRegion);
406
+ return out;
407
+ }
408
+ function awsCmd(opts, extra) {
409
+ return [...awsBaseArgs(opts), ...extra, "--output", "json"];
410
+ }
411
+ function shortName(opts) {
412
+ return basename(opts.projectDir);
413
+ }
414
+ async function checkGhInstalled(problems) {
415
+ const r = await tryExec("gh", ["--version"]);
416
+ if (r.exitCode !== 0) {
417
+ problems.push({
418
+ id: "gh-not-installed",
419
+ message: "GitHub CLI (`gh`) is not installed.",
420
+ remediation: [
421
+ "Install it (macOS: `brew install gh`; other OS: https://cli.github.com/),",
422
+ "then run `gh auth login` to sign in."
423
+ ]
424
+ });
425
+ return false;
426
+ }
427
+ return true;
428
+ }
429
+ async function checkGhAuth(problems) {
430
+ const r = await tryExec("gh", ["auth", "status"]);
431
+ const combined = `${r.stdout}
432
+ ${r.stderr}`;
433
+ if (r.exitCode !== 0 || /not logged in/i.test(combined)) {
434
+ problems.push({
435
+ id: "gh-not-authenticated",
436
+ message: "GitHub CLI is not authenticated.",
437
+ remediation: ["Run `gh auth login` and choose a method that includes the `repo` scope."]
438
+ });
439
+ return false;
440
+ }
441
+ const scopesMatch = combined.match(/Token scopes?:\s*([^\n]+)/i);
442
+ if (!scopesMatch) {
443
+ problems.push({
444
+ id: "gh-scopes-unknown",
445
+ message: "Could not determine GitHub token scopes from `gh auth status`.",
446
+ remediation: ["Run `gh auth refresh -s repo` to ensure the `repo` scope is granted."]
447
+ });
448
+ return false;
449
+ }
450
+ const hasRepo = /(^|[^a-z])repo([^a-z]|$)/i.test(scopesMatch[1]);
451
+ if (!hasRepo) {
452
+ problems.push({
453
+ id: "gh-missing-repo-scope",
454
+ message: "GitHub token is missing the `repo` scope.",
455
+ remediation: ["Run `gh auth refresh -s repo` to add the `repo` scope to your token."]
456
+ });
457
+ return false;
458
+ }
459
+ return true;
460
+ }
461
+ async function checkAwsInstalled(problems) {
462
+ const r = await tryExec("aws", ["--version"]);
463
+ if (r.exitCode !== 0) {
464
+ problems.push({
465
+ id: "aws-not-installed",
466
+ message: "AWS CLI (`aws`) is not installed.",
467
+ remediation: [
468
+ "Install it (macOS: `brew install awscli`; other OS: https://aws.amazon.com/cli/),",
469
+ "then run `aws configure` to set credentials."
470
+ ]
471
+ });
472
+ return false;
473
+ }
474
+ return true;
475
+ }
476
+ async function checkAwsCreds(opts, problems) {
477
+ const r = await tryExec("aws", [...awsBaseArgs(opts), "sts", "get-caller-identity", "--output", "json"]);
478
+ if (r.exitCode !== 0) {
479
+ problems.push({
480
+ id: "aws-credentials-missing",
481
+ message: "AWS credentials are not configured (sts get-caller-identity failed).",
482
+ remediation: [
483
+ "Run `aws configure` (or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN),",
484
+ opts.awsProfile ? `or check that profile \`${opts.awsProfile}\` exists in ~/.aws/config.` : "or pass --aws-profile <name> if you use named profiles."
485
+ ]
486
+ });
487
+ return void 0;
488
+ }
489
+ try {
490
+ const parsed = JSON.parse(r.stdout);
491
+ const account = parsed.Account;
492
+ if (!account) {
493
+ problems.push({
494
+ id: "aws-identity-malformed",
495
+ message: "aws sts get-caller-identity did not return an Account field.",
496
+ remediation: ["Re-run `aws sts get-caller-identity` manually to inspect the response."]
497
+ });
498
+ return void 0;
499
+ }
500
+ let region = opts.awsRegion;
501
+ if (!region) {
502
+ const fromEnv = process.env.AWS_REGION?.trim() || process.env.AWS_DEFAULT_REGION?.trim();
503
+ if (fromEnv) region = fromEnv;
504
+ }
505
+ if (!region) {
506
+ const cfg = await tryExec("aws", [...awsBaseArgs(opts), "configure", "get", "region"]);
507
+ const trimmed = cfg.stdout.trim();
508
+ if (trimmed) region = trimmed;
509
+ }
510
+ if (!region) {
511
+ problems.push({
512
+ id: "aws-region-missing",
513
+ message: "AWS region is not set.",
514
+ remediation: [
515
+ "Pass --aws-region <name>, set AWS_REGION env var, or run `aws configure set region <name>`."
516
+ ]
517
+ });
518
+ return void 0;
519
+ }
520
+ return { account, region };
521
+ } catch (err) {
522
+ problems.push({
523
+ id: "aws-identity-parse-failed",
524
+ message: `Could not parse aws sts get-caller-identity response: ${err instanceof Error ? err.message : String(err)}`,
525
+ remediation: ["Re-run `aws sts get-caller-identity` manually to inspect the response."]
526
+ });
527
+ return void 0;
528
+ }
529
+ }
530
+ async function checkGithubRepoFree(opts, owner, problems) {
531
+ if (!owner) return;
532
+ const name = `${owner}/${shortName(opts)}`;
533
+ const r = await tryExec("gh", ["repo", "view", name]);
534
+ if (r.exitCode === 0) {
535
+ problems.push({
536
+ id: "github-repo-exists",
537
+ message: `GitHub repo ${name} already exists.`,
538
+ remediation: [
539
+ "Pick a different project name, or delete the existing repo first:",
540
+ ` gh repo delete ${name} --yes`
541
+ ]
542
+ });
543
+ }
544
+ }
545
+ async function checkAmplifyAppNameFree(opts, problems) {
546
+ const name = shortName(opts);
547
+ const r = await tryExec(
548
+ "aws",
549
+ awsCmd(opts, [
550
+ "amplify",
551
+ "list-apps",
552
+ "--query",
553
+ `apps[?name=='${name}']`
554
+ ])
555
+ );
556
+ if (r.exitCode !== 0) {
557
+ problems.push({
558
+ id: "amplify-list-apps-failed",
559
+ message: "Could not list Amplify Hosting apps to check for a name collision.",
560
+ remediation: [
561
+ "Verify your IAM identity has `amplify:ListApps` permission, then re-run.",
562
+ `Raw error: ${r.stderr.trim() || r.stdout.trim()}`
563
+ ]
564
+ });
565
+ return;
566
+ }
567
+ try {
568
+ const arr = JSON.parse(r.stdout);
569
+ if (Array.isArray(arr) && arr.length > 0) {
570
+ problems.push({
571
+ id: "amplify-app-name-taken",
572
+ message: `Amplify Hosting already has an app named "${name}" in this region.`,
573
+ remediation: [
574
+ "Pick a different project name, or delete the existing app first:",
575
+ ` aws amplify list-apps --query "apps[?name=='${name}'].appId" --output text${opts.awsProfile ? ` --profile ${opts.awsProfile}` : ""}${opts.awsRegion ? ` --region ${opts.awsRegion}` : ""}`,
576
+ " aws amplify delete-app --app-id <appId>"
577
+ ]
578
+ });
579
+ }
580
+ } catch {
581
+ }
582
+ }
583
+ async function checkCdkBootstrap(opts, identity, problems) {
584
+ const r = await tryExec(
585
+ "aws",
586
+ [
587
+ ...awsBaseArgs(opts),
588
+ "ssm",
589
+ "get-parameter",
590
+ "--name",
591
+ "/cdk-bootstrap/hnb659fds/version",
592
+ "--output",
593
+ "json"
594
+ ]
595
+ );
596
+ if (r.exitCode !== 0) {
597
+ problems.push({
598
+ id: "cdk-not-bootstrapped",
599
+ message: `CDK is not bootstrapped in region ${identity.region}.`,
600
+ remediation: [
601
+ `Run: npx cdk bootstrap aws://${identity.account}/${identity.region}`
602
+ ]
603
+ });
604
+ }
605
+ }
606
+ async function checkRoute53Zone(opts, problems) {
607
+ if (!opts.domain) return;
608
+ const registrable = extractRegistrableDomain(opts.domain);
609
+ const r = await tryExec(
610
+ "aws",
611
+ [
612
+ ...awsBaseArgs(opts),
613
+ "route53",
614
+ "list-hosted-zones-by-name",
615
+ "--dns-name",
616
+ registrable,
617
+ "--max-items",
618
+ "1",
619
+ "--output",
620
+ "json"
621
+ ]
622
+ );
623
+ if (r.exitCode !== 0) {
624
+ problems.push({
625
+ id: "route53-list-failed",
626
+ message: `Could not query Route 53 for zone ${registrable}.`,
627
+ remediation: [
628
+ "Either grant `route53:ListHostedZonesByName` to your IAM identity,",
629
+ "or be prepared to add the CNAME records Amplify prints manually after deploy.",
630
+ `Raw error: ${r.stderr.trim() || r.stdout.trim()}`
631
+ ]
632
+ });
633
+ return;
634
+ }
635
+ try {
636
+ const parsed = JSON.parse(r.stdout);
637
+ const zones = parsed.HostedZones ?? [];
638
+ const found = zones.some(
639
+ (z) => z.Name && z.Name.replace(/\.$/, "") === registrable
640
+ );
641
+ if (!found) {
642
+ problems.push({
643
+ id: "route53-zone-missing",
644
+ message: `No Route 53 hosted zone for ${registrable} found in this AWS account.`,
645
+ remediation: [
646
+ "Amplify will still issue an ACM certificate, but you will need to add",
647
+ "the verification CNAMEs at your DNS provider manually. If you want",
648
+ "auto-validation, create a hosted zone first:",
649
+ ` aws route53 create-hosted-zone --name ${registrable} --caller-reference $(date +%s)${opts.awsProfile ? ` --profile ${opts.awsProfile}` : ""}`
650
+ ]
651
+ });
652
+ }
653
+ } catch {
654
+ }
655
+ }
656
+ async function resolveIamServiceRole(opts, args, problems) {
657
+ if (args.explicitArn) {
658
+ const roleName = args.explicitArn.split("/").pop() ?? args.explicitArn;
659
+ const r = await tryExec(
660
+ "aws",
661
+ [
662
+ ...awsBaseArgs(opts),
663
+ "iam",
664
+ "get-role",
665
+ "--role-name",
666
+ roleName,
667
+ "--output",
668
+ "json"
669
+ ]
670
+ );
671
+ if (r.exitCode !== 0) {
672
+ problems.push({
673
+ id: "iam-service-role-not-found",
674
+ message: `IAM role ${args.explicitArn} not found or inaccessible.`,
675
+ remediation: [
676
+ "Verify the ARN is correct and the role exists:",
677
+ ` aws iam get-role --role-name ${roleName}${opts.awsProfile ? ` --profile ${opts.awsProfile}` : ""}`
678
+ ]
679
+ });
680
+ return { willCreate: false };
681
+ }
682
+ return { arn: args.explicitArn, willCreate: false };
683
+ }
684
+ if (args.willCreate) {
685
+ return { willCreate: true };
686
+ }
687
+ const found = await findExistingAmplifyServiceRole(opts);
688
+ if (found) {
689
+ return { arn: found, willCreate: false };
690
+ }
691
+ problems.push({
692
+ id: "iam-service-role-missing",
693
+ message: "No Amplify Hosting service role found.",
694
+ remediation: [
695
+ "Either pass --iam-service-role <arn> pointing at an existing role,",
696
+ "or let create-ampless build one for you with --create-iam-role.",
697
+ "",
698
+ "Alternatively, create one yourself:",
699
+ ` aws iam create-role --role-name ${DEFAULT_AMPLIFY_ROLE_NAME} \\`,
700
+ ` --assume-role-policy-document '${TRUST_POLICY_JSON}'`,
701
+ ` aws iam attach-role-policy --role-name ${DEFAULT_AMPLIFY_ROLE_NAME} \\`,
702
+ ` --policy-arn ${AMPLIFY_BACKEND_POLICY_ARN}`,
703
+ ` # then re-run with: --iam-service-role $(aws iam get-role --role-name ${DEFAULT_AMPLIFY_ROLE_NAME} --query Role.Arn --output text)`
704
+ ]
705
+ });
706
+ return { willCreate: false };
707
+ }
708
+ function trustPolicyAllowsAmplify(doc) {
709
+ let parsed = doc;
710
+ if (typeof doc === "string") {
711
+ try {
712
+ parsed = JSON.parse(decodeURIComponent(doc));
713
+ } catch {
714
+ try {
715
+ parsed = JSON.parse(doc);
716
+ } catch {
717
+ return false;
718
+ }
719
+ }
720
+ }
721
+ const stmts = parsed?.Statement;
722
+ const arr = Array.isArray(stmts) ? stmts : stmts ? [stmts] : [];
723
+ for (const stmt of arr) {
724
+ const s = stmt;
725
+ const svc = s.Principal?.Service;
726
+ if (typeof svc === "string" && svc === "amplify.amazonaws.com") return true;
727
+ if (Array.isArray(svc) && svc.includes("amplify.amazonaws.com")) return true;
728
+ }
729
+ return false;
730
+ }
731
+ async function findExistingAmplifyServiceRole(opts) {
732
+ let marker;
733
+ for (let page = 0; page < 20; page++) {
734
+ const args = [
735
+ ...awsBaseArgs(opts),
736
+ "iam",
737
+ "list-roles",
738
+ "--max-items",
739
+ "200",
740
+ "--output",
741
+ "json"
742
+ ];
743
+ if (marker) args.push("--starting-token", marker);
744
+ const r = await tryExec("aws", args);
745
+ if (r.exitCode !== 0) return void 0;
746
+ let parsed;
747
+ try {
748
+ parsed = JSON.parse(r.stdout);
749
+ } catch {
750
+ return void 0;
751
+ }
752
+ for (const role of parsed.Roles ?? []) {
753
+ if (!role.RoleName || !role.Arn) continue;
754
+ if (!trustPolicyAllowsAmplify(role.AssumeRolePolicyDocument)) continue;
755
+ const pr = await tryExec(
756
+ "aws",
757
+ [
758
+ ...awsBaseArgs(opts),
759
+ "iam",
760
+ "list-attached-role-policies",
761
+ "--role-name",
762
+ role.RoleName,
763
+ "--output",
764
+ "json"
765
+ ]
766
+ );
767
+ if (pr.exitCode !== 0) continue;
768
+ try {
769
+ const policies = JSON.parse(pr.stdout);
770
+ const attached = (policies.AttachedPolicies ?? []).some(
771
+ (p3) => p3.PolicyArn === AMPLIFY_BACKEND_POLICY_ARN
772
+ );
773
+ if (attached) return role.Arn;
774
+ } catch {
775
+ continue;
776
+ }
777
+ }
778
+ if (!parsed.IsTruncated) break;
779
+ marker = parsed.Marker;
780
+ if (!marker) break;
781
+ }
782
+ return void 0;
783
+ }
784
+ async function runPreflight(opts, extra = {}) {
785
+ const problems = [];
786
+ const hasGh = await checkGhInstalled(problems);
787
+ if (hasGh) await checkGhAuth(problems);
788
+ const hasAws = await checkAwsInstalled(problems);
789
+ const identity = hasAws ? await checkAwsCreds(opts, problems) : void 0;
790
+ if (hasGh && opts.githubOwner) {
791
+ await checkGithubRepoFree(opts, opts.githubOwner, problems);
792
+ }
793
+ if (identity) {
794
+ await checkAmplifyAppNameFree(opts, problems);
795
+ await checkCdkBootstrap(opts, identity, problems);
796
+ await checkRoute53Zone(opts, problems);
797
+ }
798
+ let willCreateIamRole = false;
799
+ let iamServiceRoleArn;
800
+ if (identity) {
801
+ const resolved = await resolveIamServiceRole(
802
+ opts,
803
+ { explicitArn: extra.iamServiceRoleArn, willCreate: extra.createIamRole === true },
804
+ problems
805
+ );
806
+ willCreateIamRole = resolved.willCreate;
807
+ iamServiceRoleArn = resolved.arn;
808
+ }
809
+ if (problems.length > 0) {
810
+ return { problems };
811
+ }
812
+ return {
813
+ problems,
814
+ resolution: {
815
+ iamServiceRoleArn,
816
+ willCreateIamRole,
817
+ awsAccount: identity.account,
818
+ awsRegion: identity.region
819
+ }
820
+ };
821
+ }
822
+ function formatPreflightReport(problems) {
823
+ const lines = [];
824
+ lines.push(`${pc.red("\u2717")} create-ampless --deploy: prerequisites missing`);
825
+ lines.push("");
826
+ for (const p3 of problems) {
827
+ lines.push(` ${pc.red("\u2717")} ${p3.message}`);
828
+ for (const r of p3.remediation) {
829
+ lines.push(r ? ` ${r}` : "");
830
+ }
831
+ lines.push("");
832
+ }
833
+ lines.push("Fix the items above and re-run.");
834
+ return lines.join("\n");
835
+ }
836
+ function printPreflightReport(problems) {
837
+ process.stderr.write(`${formatPreflightReport(problems)}
838
+ `);
839
+ }
840
+
841
+ // src/deploy.ts
315
842
  var AMPLIFY_BUILD_SPEC = `version: 1
316
843
  frontend:
317
844
  phases:
318
845
  preBuild:
319
846
  commands:
320
- - npm ci
847
+ - npm install
321
848
  build:
322
849
  commands:
323
850
  - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
@@ -336,7 +863,7 @@ async function resolveGithubToken(explicit, env = process.env) {
336
863
  const envToken = env.GITHUB_TOKEN;
337
864
  if (envToken && envToken.trim()) return envToken.trim();
338
865
  try {
339
- const { stdout } = await execa("gh", ["auth", "token"], { reject: false });
866
+ const { stdout } = await execa2("gh", ["auth", "token"], { reject: false });
340
867
  const trimmed = stdout?.trim();
341
868
  if (trimmed) return trimmed;
342
869
  } catch {
@@ -400,7 +927,7 @@ function splitDomain(domain, subdomain) {
400
927
  async function run(cmd, args, opts = {}) {
401
928
  const { step, ...execaOpts } = opts;
402
929
  try {
403
- const result = await execa(cmd, args, { stdio: "pipe", ...execaOpts });
930
+ const result = await execa2(cmd, args, { stdio: "pipe", ...execaOpts });
404
931
  return result.stdout?.toString() ?? "";
405
932
  } catch (err) {
406
933
  const e = err;
@@ -410,15 +937,6 @@ async function run(cmd, args, opts = {}) {
410
937
  ${detail}`);
411
938
  }
412
939
  }
413
- async function ensureCommandExists(cmd) {
414
- try {
415
- await execa(cmd, ["--version"], { stdio: "ignore" });
416
- } catch {
417
- const hint = cmd === "gh" ? "Install with `brew install gh` (or see https://cli.github.com/) then run `gh auth login`." : "Install with `brew install awscli` (or see https://aws.amazon.com/cli/) then run `aws configure`.";
418
- throw new Error(`Required command not found: ${cmd}
419
- ${hint}`);
420
- }
421
- }
422
940
  function awsArgs(opts, extra) {
423
941
  const base = [];
424
942
  if (opts.awsProfile) base.push("--profile", opts.awsProfile);
@@ -436,9 +954,12 @@ async function gitInitCommit(dir) {
436
954
  { cwd: dir, step: "git commit" }
437
955
  );
438
956
  }
957
+ function shortName2(opts) {
958
+ return basename2(opts.projectDir);
959
+ }
439
960
  async function ghRepoCreate(opts) {
440
961
  const visibility = opts.githubPrivate ? "--private" : "--public";
441
- const name = `${opts.githubOwner}/${opts.projectName}`;
962
+ const name = `${opts.githubOwner}/${shortName2(opts)}`;
442
963
  const out = await run(
443
964
  "gh",
444
965
  [
@@ -462,23 +983,27 @@ async function ghRepoCreate(opts) {
462
983
  if (match && match.length > 0) return match[match.length - 1].replace(/[.,]$/, "");
463
984
  return `https://github.com/${name}`;
464
985
  }
465
- async function amplifyCreateApp(opts, repoUrl) {
986
+ async function amplifyCreateApp(opts, repoUrl, iamServiceRoleArn) {
987
+ const cmd = [
988
+ "amplify",
989
+ "create-app",
990
+ "--name",
991
+ shortName2(opts),
992
+ "--repository",
993
+ repoUrl,
994
+ "--access-token",
995
+ opts.githubToken,
996
+ "--platform",
997
+ "WEB_COMPUTE",
998
+ "--build-spec",
999
+ AMPLIFY_BUILD_SPEC
1000
+ ];
1001
+ if (iamServiceRoleArn) {
1002
+ cmd.push("--iam-service-role-arn", iamServiceRoleArn);
1003
+ }
466
1004
  const out = await run(
467
1005
  "aws",
468
- awsArgs(opts, [
469
- "amplify",
470
- "create-app",
471
- "--name",
472
- opts.projectName,
473
- "--repository",
474
- repoUrl,
475
- "--access-token",
476
- opts.githubToken,
477
- "--platform",
478
- "WEB_COMPUTE",
479
- "--build-spec",
480
- AMPLIFY_BUILD_SPEC
481
- ]),
1006
+ awsArgs(opts, cmd),
482
1007
  { step: "aws amplify create-app" }
483
1008
  );
484
1009
  const parsed = JSON.parse(out);
@@ -552,7 +1077,7 @@ async function amplifyCreateDomain(opts, appId) {
552
1077
  const route53Zone = await findRoute53Zone(opts, registrable);
553
1078
  if (route53Zone) {
554
1079
  log.info(
555
- `Route 53 hosted zone detected for ${pc.cyan(registrable)} \u2014 Amplify will auto-create DNS records once ACM validates.`
1080
+ `Route 53 hosted zone detected for ${pc2.cyan(registrable)} \u2014 Amplify will auto-create DNS records once ACM validates.`
556
1081
  );
557
1082
  }
558
1083
  const out = await run(
@@ -598,9 +1123,92 @@ async function amplifyCreateDomain(opts, appId) {
598
1123
  verification: route53Zone ? void 0 : verification.length > 0 ? verification : void 0
599
1124
  };
600
1125
  }
1126
+ async function provisionIamServiceRole(opts) {
1127
+ const roleName = DEFAULT_AMPLIFY_ROLE_NAME;
1128
+ const trustPolicy = JSON.stringify({
1129
+ Version: "2012-10-17",
1130
+ Statement: [
1131
+ {
1132
+ Effect: "Allow",
1133
+ Principal: { Service: "amplify.amazonaws.com" },
1134
+ Action: "sts:AssumeRole"
1135
+ }
1136
+ ]
1137
+ });
1138
+ const createArgs = [
1139
+ "iam",
1140
+ "create-role",
1141
+ "--role-name",
1142
+ roleName,
1143
+ "--assume-role-policy-document",
1144
+ trustPolicy,
1145
+ "--description",
1146
+ "Amplify Hosting service role created by create-ampless"
1147
+ ];
1148
+ try {
1149
+ await run("aws", awsArgs(opts, createArgs), { step: "aws iam create-role" });
1150
+ } catch (err) {
1151
+ const msg = err instanceof Error ? err.message : String(err);
1152
+ if (!/EntityAlreadyExists/i.test(msg) && !/already exists/i.test(msg)) {
1153
+ throw err;
1154
+ }
1155
+ }
1156
+ await run(
1157
+ "aws",
1158
+ awsArgs(opts, [
1159
+ "iam",
1160
+ "attach-role-policy",
1161
+ "--role-name",
1162
+ roleName,
1163
+ "--policy-arn",
1164
+ AMPLIFY_BACKEND_POLICY_ARN
1165
+ ]),
1166
+ { step: "aws iam attach-role-policy" }
1167
+ );
1168
+ const out = await run(
1169
+ "aws",
1170
+ awsArgs(opts, ["iam", "get-role", "--role-name", roleName]),
1171
+ { step: "aws iam get-role" }
1172
+ );
1173
+ const parsed = JSON.parse(out);
1174
+ const arn = parsed.Role?.Arn;
1175
+ if (!arn) {
1176
+ throw new Error(`aws iam get-role for ${roleName}: missing Role.Arn in response`);
1177
+ }
1178
+ return arn;
1179
+ }
1180
+ var PreflightError = class extends Error {
1181
+ result;
1182
+ constructor(result) {
1183
+ super("create-ampless --deploy: pre-flight failed");
1184
+ this.name = "PreflightError";
1185
+ this.result = result;
1186
+ }
1187
+ };
601
1188
  async function runDeploy(opts) {
602
- await ensureCommandExists("gh");
603
- await ensureCommandExists("aws");
1189
+ const pre = await runPreflight(opts, {
1190
+ iamServiceRoleArn: opts.iamServiceRoleArn,
1191
+ createIamRole: opts.createIamRole === true
1192
+ });
1193
+ if (pre.problems.length > 0) {
1194
+ printPreflightReport(pre.problems);
1195
+ throw new PreflightError(pre);
1196
+ }
1197
+ const resolution = pre.resolution;
1198
+ let serviceRoleArn = resolution.iamServiceRoleArn;
1199
+ if (resolution.willCreateIamRole && !serviceRoleArn) {
1200
+ const s0 = spinner();
1201
+ s0.start(`Provisioning IAM service role ${DEFAULT_AMPLIFY_ROLE_NAME}`);
1202
+ try {
1203
+ serviceRoleArn = await provisionIamServiceRole(opts);
1204
+ s0.stop(`IAM role: ${serviceRoleArn}`);
1205
+ } catch (err) {
1206
+ s0.stop("IAM role provisioning: failed");
1207
+ throw new Error(
1208
+ `Failed to provision IAM service role: ${err instanceof Error ? err.message : String(err)}`
1209
+ );
1210
+ }
1211
+ }
604
1212
  await writeFile2(resolve3(opts.projectDir, "amplify.yml"), AMPLIFY_BUILD_SPEC, "utf-8");
605
1213
  const created = {};
606
1214
  const fail = (step, cause) => {
@@ -613,7 +1221,7 @@ async function runDeploy(opts) {
613
1221
  }
614
1222
  if (created.repoUrl) {
615
1223
  cleanup.push(
616
- ` - GitHub repo: ${created.repoUrl} (delete: gh repo delete ${opts.githubOwner}/${opts.projectName} --yes)`
1224
+ ` - GitHub repo: ${created.repoUrl} (delete: gh repo delete ${opts.githubOwner}/${shortName2(opts)} --yes)`
617
1225
  );
618
1226
  }
619
1227
  const cleanupBlock = cleanup.length > 0 ? `
@@ -646,7 +1254,7 @@ ${msg}${cleanupBlock}`);
646
1254
  s = spinner();
647
1255
  s.start("Creating Amplify Hosting app");
648
1256
  try {
649
- app = await amplifyCreateApp(opts, created.repoUrl);
1257
+ app = await amplifyCreateApp(opts, created.repoUrl, serviceRoleArn);
650
1258
  created.appId = app.appId;
651
1259
  s.stop(`Amplify app: ${app.appId}`);
652
1260
  } catch (err) {
@@ -671,7 +1279,7 @@ ${msg}${cleanupBlock}`);
671
1279
  s.stop("Amplify start-job: failed");
672
1280
  fail("aws amplify start-job", err);
673
1281
  }
674
- const amplifyAppUrl = `https://main.${app.appId}.${app.defaultDomain}`;
1282
+ const amplifyAppUrl = `https://main.${app.defaultDomain}`;
675
1283
  const result = {
676
1284
  githubRepoUrl: created.repoUrl,
677
1285
  amplifyAppId: app.appId,
@@ -698,7 +1306,7 @@ ${msg}`);
698
1306
  // src/deploy-prompts.ts
699
1307
  async function detectGithubLogin() {
700
1308
  try {
701
- const { stdout } = await execa2("gh", ["api", "user", "--jq", ".login"], { reject: false });
1309
+ const { stdout } = await execa3("gh", ["api", "user", "--jq", ".login"], { reject: false });
702
1310
  const trimmed = stdout?.trim();
703
1311
  return trimmed || void 0;
704
1312
  } catch {
@@ -709,7 +1317,7 @@ async function detectAwsRegion() {
709
1317
  if (process.env.AWS_REGION?.trim()) return process.env.AWS_REGION.trim();
710
1318
  if (process.env.AWS_DEFAULT_REGION?.trim()) return process.env.AWS_DEFAULT_REGION.trim();
711
1319
  try {
712
- const { stdout } = await execa2("aws", ["configure", "get", "region"], { reject: false });
1320
+ const { stdout } = await execa3("aws", ["configure", "get", "region"], { reject: false });
713
1321
  const trimmed = stdout?.trim();
714
1322
  return trimmed || void 0;
715
1323
  } catch {
@@ -836,12 +1444,30 @@ async function gatherDeployOptions(args, projectDir, projectName) {
836
1444
  awsRegion,
837
1445
  domain,
838
1446
  subdomain,
1447
+ iamServiceRoleArn: args.iamServiceRole,
1448
+ createIamRole: args.createIamRole,
839
1449
  skipConfirm: args.skipConfirm
840
1450
  };
841
1451
  }
842
1452
 
843
1453
  // src/index.ts
844
- import pc2 from "picocolors";
1454
+ import pc3 from "picocolors";
1455
+ function buildNonInteractiveOpts(args) {
1456
+ const projectName = args.projectName ?? (() => {
1457
+ const b = basename3(process.cwd());
1458
+ return b && b !== "/" ? b : "my-ampless-site";
1459
+ })();
1460
+ const siteName = args.siteName ?? "My Blog";
1461
+ const themes = args.themes ?? ["blog"];
1462
+ const plugins = args.plugins ?? ["seo"];
1463
+ return {
1464
+ projectName,
1465
+ siteName,
1466
+ themes,
1467
+ defaultTheme: themes[0],
1468
+ plugins
1469
+ };
1470
+ }
845
1471
  async function main() {
846
1472
  const args = parseDeployArgs(process.argv.slice(2));
847
1473
  if (args.help) {
@@ -851,7 +1477,12 @@ async function main() {
851
1477
  for (const flag of args.unknown) {
852
1478
  log3.warn(`Unknown argument ignored: ${flag}`);
853
1479
  }
854
- const opts = await runPrompts(args.projectName);
1480
+ let opts;
1481
+ if (args.skipConfirm) {
1482
+ opts = buildNonInteractiveOpts(args);
1483
+ } else {
1484
+ opts = await runPrompts(args.projectName);
1485
+ }
855
1486
  if (!opts) return;
856
1487
  const destDir = resolve4(process.cwd(), opts.projectName);
857
1488
  if (existsSync2(destDir)) {
@@ -875,17 +1506,17 @@ async function main() {
875
1506
  try {
876
1507
  const result = await runDeploy(deployOpts);
877
1508
  const lines = [
878
- `${pc2.green("\u2714")} Project deployed`,
1509
+ `${pc3.green("\u2714")} Project deployed`,
879
1510
  ``,
880
- ` GitHub: ${pc2.cyan(result.githubRepoUrl)}`,
881
- ` Amplify app: ${pc2.cyan(result.amplifyAppId)}`,
882
- ` Amplify URL: ${pc2.cyan(result.amplifyAppUrl)}`
1511
+ ` GitHub: ${pc3.cyan(result.githubRepoUrl)}`,
1512
+ ` Amplify app: ${pc3.cyan(result.amplifyAppId)}`,
1513
+ ` Amplify URL: ${pc3.cyan(result.amplifyAppUrl)}`
883
1514
  ];
884
1515
  if (result.domainUrl) {
885
- lines.push(` Custom domain: ${pc2.cyan(result.domainUrl)}`);
1516
+ lines.push(` Custom domain: ${pc3.cyan(result.domainUrl)}`);
886
1517
  }
887
1518
  if (result.domainVerification && result.domainVerification.length > 0) {
888
- lines.push("", ` ${pc2.bold("Add these DNS records to verify the domain:")}`);
1519
+ lines.push("", ` ${pc3.bold("Add these DNS records to verify the domain:")}`);
889
1520
  for (const v of result.domainVerification) {
890
1521
  lines.push(` ${v.cname} CNAME ${v.target}`);
891
1522
  }
@@ -893,23 +1524,26 @@ async function main() {
893
1524
  lines.push(
894
1525
  "",
895
1526
  ` First build is now running in Amplify Hosting.`,
896
- ` Watch it at ${pc2.cyan(`https://console.aws.amazon.com/amplify/home#/${result.amplifyAppId}`)}`
1527
+ ` Watch it at ${pc3.cyan(`https://console.aws.amazon.com/amplify/home#/${result.amplifyAppId}`)}`
897
1528
  );
898
1529
  outro(lines.join("\n"));
899
1530
  } catch (err) {
1531
+ if (err instanceof PreflightError) {
1532
+ process.exit(1);
1533
+ }
900
1534
  log3.error(err instanceof Error ? err.message : String(err));
901
1535
  process.exit(1);
902
1536
  }
903
1537
  return;
904
1538
  }
905
1539
  outro(
906
- `${pc2.green("\u2714")} Project created at ${pc2.bold(opts.projectName)}
1540
+ `${pc3.green("\u2714")} Project created at ${pc3.bold(opts.projectName)}
907
1541
 
908
1542
  Next steps:
909
- ${pc2.cyan("cd")} ${opts.projectName}
910
- ${pc2.cyan("npm install")}
911
- ${pc2.cyan("npx ampx sandbox")} ${pc2.dim("# start Amplify backend")}
912
- ${pc2.cyan("npm run dev")} ${pc2.dim("# start Next.js")}`
1543
+ ${pc3.cyan("cd")} ${opts.projectName}
1544
+ ${pc3.cyan("npm install")}
1545
+ ${pc3.cyan("npx ampx sandbox")} ${pc3.dim("# start Amplify backend")}
1546
+ ${pc3.cyan("npm run dev")} ${pc3.dim("# start Next.js")}`
913
1547
  );
914
1548
  }
915
1549
  main().catch((err) => {
@@ -1,6 +1,17 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { dirname, resolve } from 'node:path'
1
3
  import { a, defineData, type ClientSchema } from '@aws-amplify/backend'
2
4
  import { amplessSchemaModels, defaultAuthorizationModes } from '@ampless/backend'
3
5
 
6
+ // AppSync's `a.handler.custom({ entry })` paths are resolved by CDK
7
+ // relative to the file that called `a.handler.custom`. When the call
8
+ // originates inside `@ampless/backend` (via amplessSchemaModels), the
9
+ // CDK synth tries to resolve `./list-published-posts.js` relative to
10
+ // `node_modules/@ampless/backend/dist/index.js` and fails with
11
+ // `UnresolvedEntryPathError`. Anchor the resolver paths at THIS file's
12
+ // directory so the result is unambiguous.
13
+ const __dirname = dirname(fileURLToPath(import.meta.url))
14
+
4
15
  // Ampless's built-in models (Post / Page / Media / Taxonomy / PostTag /
5
16
  // KvStore) plus the three public-read custom queries
6
17
  // (listPublishedPosts / getPublishedPost / listPostsByTag).
@@ -8,19 +19,20 @@ import { amplessSchemaModels, defaultAuthorizationModes } from '@ampless/backend
8
19
  // Add project-specific models alongside the spread:
9
20
  //
10
21
  // const schema = a.schema({
11
- // ...amplessSchemaModels(a),
22
+ // ...amplessSchemaModels(a, { resolverPaths }),
12
23
  // MyCustomModel: a
13
24
  // .model({ siteId: a.string().required(), foo: a.string() })
14
25
  // .identifier(['siteId', 'foo'])
15
26
  // .authorization((allow) => [allow.groups(['ampless-admin'])]),
16
27
  // })
17
- //
18
- // The three AppSync JS resolvers (`*.js` in this directory) stay
19
- // user-owned — AppSync resolves `entry: './...'` paths at synth time
20
- // relative to this file. If you relocate them, pass new paths via
21
- // `amplessSchemaModels(a, { resolverPaths: { ... } })`.
28
+ const resolverPaths = {
29
+ listPublishedPosts: resolve(__dirname, 'list-published-posts.js'),
30
+ getPublishedPost: resolve(__dirname, 'get-published-post.js'),
31
+ listPostsByTag: resolve(__dirname, 'list-posts-by-tag.js'),
32
+ }
33
+
22
34
  const schema = a.schema({
23
- ...amplessSchemaModels(a),
35
+ ...amplessSchemaModels(a, { resolverPaths }),
24
36
  })
25
37
 
26
38
  export type Schema = ClientSchema<typeof schema>
@@ -3,7 +3,7 @@ frontend:
3
3
  phases:
4
4
  preBuild:
5
5
  commands:
6
- - npm ci
6
+ - npm install
7
7
  build:
8
8
  commands:
9
9
  - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ampless",
3
- "version": "0.2.0-alpha.5",
3
+ "version": "0.2.0-alpha.6",
4
4
  "description": "Create a new ampless project",
5
5
  "license": "MIT",
6
6
  "type": "module",