create-ampless 0.2.0-alpha.5 → 0.2.0-alpha.7
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
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { spinner as spinner2, outro, log as log3 } from "@clack/prompts";
|
|
5
|
-
import { existsSync as
|
|
6
|
-
import { resolve as
|
|
5
|
+
import { existsSync as existsSync4 } from "fs";
|
|
6
|
+
import { basename as basename3, resolve as resolve5 } from "path";
|
|
7
7
|
|
|
8
8
|
// src/prompts.ts
|
|
9
9
|
import * as p from "@clack/prompts";
|
|
@@ -192,17 +192,25 @@ 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",
|
|
211
|
+
"--mount",
|
|
205
212
|
"--github-private",
|
|
213
|
+
"--create-iam-role",
|
|
206
214
|
"--skip-confirm",
|
|
207
215
|
"--help",
|
|
208
216
|
"-h"
|
|
@@ -210,7 +218,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
|
210
218
|
function parseDeployArgs(argv) {
|
|
211
219
|
const out = {
|
|
212
220
|
deploy: false,
|
|
221
|
+
mount: false,
|
|
213
222
|
githubPrivate: false,
|
|
223
|
+
createIamRole: false,
|
|
214
224
|
skipConfirm: false,
|
|
215
225
|
help: false,
|
|
216
226
|
unknown: []
|
|
@@ -229,9 +239,15 @@ function parseDeployArgs(argv) {
|
|
|
229
239
|
case "--deploy":
|
|
230
240
|
out.deploy = true;
|
|
231
241
|
break;
|
|
242
|
+
case "--mount":
|
|
243
|
+
out.mount = true;
|
|
244
|
+
break;
|
|
232
245
|
case "--github-private":
|
|
233
246
|
out.githubPrivate = true;
|
|
234
247
|
break;
|
|
248
|
+
case "--create-iam-role":
|
|
249
|
+
out.createIamRole = true;
|
|
250
|
+
break;
|
|
235
251
|
case "--skip-confirm":
|
|
236
252
|
out.skipConfirm = true;
|
|
237
253
|
break;
|
|
@@ -248,6 +264,31 @@ function parseDeployArgs(argv) {
|
|
|
248
264
|
throw new Error(`Missing value for ${token}`);
|
|
249
265
|
}
|
|
250
266
|
switch (token) {
|
|
267
|
+
case "--site-name":
|
|
268
|
+
out.siteName = value;
|
|
269
|
+
break;
|
|
270
|
+
case "--themes": {
|
|
271
|
+
const themes = value.split(",").map((t) => t.trim()).filter(Boolean);
|
|
272
|
+
const invalid = themes.filter((t) => !VALID_THEMES.includes(t));
|
|
273
|
+
if (invalid.length > 0) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Invalid theme(s): ${invalid.join(", ")}. Valid values: ${VALID_THEMES.join(", ")}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
out.themes = themes;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case "--plugins": {
|
|
282
|
+
const plugins = value.split(",").map((p3) => p3.trim()).filter(Boolean);
|
|
283
|
+
const invalid = plugins.filter((p3) => !VALID_PLUGINS.includes(p3));
|
|
284
|
+
if (invalid.length > 0) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Invalid plugin(s): ${invalid.join(", ")}. Valid values: ${VALID_PLUGINS.join(", ")}`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
out.plugins = plugins;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
251
292
|
case "--github-owner":
|
|
252
293
|
out.githubOwner = value;
|
|
253
294
|
break;
|
|
@@ -266,6 +307,9 @@ function parseDeployArgs(argv) {
|
|
|
266
307
|
case "--subdomain":
|
|
267
308
|
out.subdomain = value;
|
|
268
309
|
break;
|
|
310
|
+
case "--iam-service-role":
|
|
311
|
+
out.iamServiceRole = value;
|
|
312
|
+
break;
|
|
269
313
|
}
|
|
270
314
|
continue;
|
|
271
315
|
}
|
|
@@ -285,10 +329,24 @@ var HELP_TEXT = `create-ampless \u2014 scaffold an ampless project
|
|
|
285
329
|
|
|
286
330
|
Usage:
|
|
287
331
|
npx create-ampless@alpha <project-name> [options]
|
|
332
|
+
npx create-ampless@alpha --mount [options] # in an existing project dir
|
|
288
333
|
|
|
289
334
|
Options:
|
|
335
|
+
--site-name <name> Site display name (default: "My Blog")
|
|
336
|
+
--themes <list> Comma-separated theme names to install
|
|
337
|
+
Valid: blog, minimal, landing, corporate, docs, dads
|
|
338
|
+
(default: blog)
|
|
339
|
+
--plugins <list> Comma-separated plugin names to install
|
|
340
|
+
Valid: seo, rss, webhook
|
|
341
|
+
(default: seo)
|
|
290
342
|
--deploy Also create GitHub repo + Amplify Hosting app and
|
|
291
343
|
kick off the first deploy after scaffolding
|
|
344
|
+
--mount Skip scaffolding and mount the CURRENT directory
|
|
345
|
+
onto a new GitHub repo + Amplify Hosting app.
|
|
346
|
+
Use after you've scaffolded and tested locally
|
|
347
|
+
with 'npx ampx sandbox' and now want to publish.
|
|
348
|
+
Implies --deploy. Scaffold flags (--site-name,
|
|
349
|
+
--themes, --plugins) are ignored.
|
|
292
350
|
--github-owner <login> GitHub owner (user or org). Defaults to the
|
|
293
351
|
authenticated 'gh' user
|
|
294
352
|
--github-private Create a private repo (default: public)
|
|
@@ -298,26 +356,557 @@ Options:
|
|
|
298
356
|
--aws-region <region> AWS region (defaults to aws config / env)
|
|
299
357
|
--domain <name> Custom domain (apex or subdomain) to attach
|
|
300
358
|
--subdomain <prefix> Subdomain prefix for the domain (default: apex)
|
|
301
|
-
--
|
|
359
|
+
--iam-service-role <arn> Existing IAM role for Amplify Hosting (must trust
|
|
360
|
+
amplify.amazonaws.com and have
|
|
361
|
+
AdministratorAccess-Amplify attached)
|
|
362
|
+
--create-iam-role Let create-ampless provision the Amplify Hosting
|
|
363
|
+
service role (idempotent; defaults to role name
|
|
364
|
+
AmplifyDeployBackend)
|
|
365
|
+
--skip-confirm Skip all interactive prompts and use defaults /
|
|
366
|
+
flag values (for CI / automation)
|
|
302
367
|
-h, --help Show this message
|
|
303
368
|
`;
|
|
304
369
|
|
|
305
370
|
// src/deploy-prompts.ts
|
|
306
371
|
import * as p2 from "@clack/prompts";
|
|
307
|
-
import { execa as
|
|
372
|
+
import { execa as execa3 } from "execa";
|
|
308
373
|
|
|
309
374
|
// src/deploy.ts
|
|
310
|
-
import { execa } from "execa";
|
|
375
|
+
import { execa as execa2 } from "execa";
|
|
376
|
+
import { existsSync as existsSync3 } from "fs";
|
|
311
377
|
import { writeFile as writeFile2 } from "fs/promises";
|
|
312
|
-
import { resolve as
|
|
378
|
+
import { basename as basename2, resolve as resolve4 } from "path";
|
|
313
379
|
import { spinner, log } from "@clack/prompts";
|
|
380
|
+
import pc2 from "picocolors";
|
|
381
|
+
|
|
382
|
+
// src/preflight.ts
|
|
383
|
+
import { execa } from "execa";
|
|
384
|
+
import { basename } from "path";
|
|
314
385
|
import pc from "picocolors";
|
|
386
|
+
var DEFAULT_AMPLIFY_ROLE_NAME = "AmplifyDeployBackend";
|
|
387
|
+
var AMPLIFY_BACKEND_POLICY_ARN = "arn:aws:iam::aws:policy/AdministratorAccess-Amplify";
|
|
388
|
+
var TRUST_POLICY_JSON = JSON.stringify({
|
|
389
|
+
Version: "2012-10-17",
|
|
390
|
+
Statement: [
|
|
391
|
+
{
|
|
392
|
+
Effect: "Allow",
|
|
393
|
+
Principal: { Service: "amplify.amazonaws.com" },
|
|
394
|
+
Action: "sts:AssumeRole"
|
|
395
|
+
}
|
|
396
|
+
]
|
|
397
|
+
});
|
|
398
|
+
async function tryExec(cmd, args) {
|
|
399
|
+
try {
|
|
400
|
+
const r = await execa(cmd, args, { reject: false, stdio: "pipe" });
|
|
401
|
+
return {
|
|
402
|
+
exitCode: r.exitCode ?? 0,
|
|
403
|
+
stdout: r.stdout?.toString() ?? "",
|
|
404
|
+
stderr: r.stderr?.toString() ?? ""
|
|
405
|
+
};
|
|
406
|
+
} catch (err) {
|
|
407
|
+
const e = err;
|
|
408
|
+
return {
|
|
409
|
+
exitCode: 1,
|
|
410
|
+
stdout: e.stdout ?? "",
|
|
411
|
+
stderr: e.stderr ?? e.message ?? String(err)
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function awsBaseArgs(opts) {
|
|
416
|
+
const out = [];
|
|
417
|
+
if (opts.awsProfile) out.push("--profile", opts.awsProfile);
|
|
418
|
+
if (opts.awsRegion) out.push("--region", opts.awsRegion);
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
function awsCmd(opts, extra) {
|
|
422
|
+
return [...awsBaseArgs(opts), ...extra, "--output", "json"];
|
|
423
|
+
}
|
|
424
|
+
function shortName(opts) {
|
|
425
|
+
return basename(opts.projectDir);
|
|
426
|
+
}
|
|
427
|
+
async function checkGhInstalled(problems) {
|
|
428
|
+
const r = await tryExec("gh", ["--version"]);
|
|
429
|
+
if (r.exitCode !== 0) {
|
|
430
|
+
problems.push({
|
|
431
|
+
id: "gh-not-installed",
|
|
432
|
+
message: "GitHub CLI (`gh`) is not installed.",
|
|
433
|
+
remediation: [
|
|
434
|
+
"Install it (macOS: `brew install gh`; other OS: https://cli.github.com/),",
|
|
435
|
+
"then run `gh auth login` to sign in."
|
|
436
|
+
]
|
|
437
|
+
});
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
async function checkGhAuth(problems) {
|
|
443
|
+
const r = await tryExec("gh", ["auth", "status"]);
|
|
444
|
+
const combined = `${r.stdout}
|
|
445
|
+
${r.stderr}`;
|
|
446
|
+
if (r.exitCode !== 0 || /not logged in/i.test(combined)) {
|
|
447
|
+
problems.push({
|
|
448
|
+
id: "gh-not-authenticated",
|
|
449
|
+
message: "GitHub CLI is not authenticated.",
|
|
450
|
+
remediation: ["Run `gh auth login` and choose a method that includes the `repo` scope."]
|
|
451
|
+
});
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
const scopesMatch = combined.match(/Token scopes?:\s*([^\n]+)/i);
|
|
455
|
+
if (!scopesMatch) {
|
|
456
|
+
problems.push({
|
|
457
|
+
id: "gh-scopes-unknown",
|
|
458
|
+
message: "Could not determine GitHub token scopes from `gh auth status`.",
|
|
459
|
+
remediation: ["Run `gh auth refresh -s repo` to ensure the `repo` scope is granted."]
|
|
460
|
+
});
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
const hasRepo = /(^|[^a-z])repo([^a-z]|$)/i.test(scopesMatch[1]);
|
|
464
|
+
if (!hasRepo) {
|
|
465
|
+
problems.push({
|
|
466
|
+
id: "gh-missing-repo-scope",
|
|
467
|
+
message: "GitHub token is missing the `repo` scope.",
|
|
468
|
+
remediation: ["Run `gh auth refresh -s repo` to add the `repo` scope to your token."]
|
|
469
|
+
});
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
async function checkAwsInstalled(problems) {
|
|
475
|
+
const r = await tryExec("aws", ["--version"]);
|
|
476
|
+
if (r.exitCode !== 0) {
|
|
477
|
+
problems.push({
|
|
478
|
+
id: "aws-not-installed",
|
|
479
|
+
message: "AWS CLI (`aws`) is not installed.",
|
|
480
|
+
remediation: [
|
|
481
|
+
"Install it (macOS: `brew install awscli`; other OS: https://aws.amazon.com/cli/),",
|
|
482
|
+
"then run `aws configure` to set credentials."
|
|
483
|
+
]
|
|
484
|
+
});
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
async function checkAwsCreds(opts, problems) {
|
|
490
|
+
const r = await tryExec("aws", [...awsBaseArgs(opts), "sts", "get-caller-identity", "--output", "json"]);
|
|
491
|
+
if (r.exitCode !== 0) {
|
|
492
|
+
problems.push({
|
|
493
|
+
id: "aws-credentials-missing",
|
|
494
|
+
message: "AWS credentials are not configured (sts get-caller-identity failed).",
|
|
495
|
+
remediation: [
|
|
496
|
+
"Run `aws configure` (or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN),",
|
|
497
|
+
opts.awsProfile ? `or check that profile \`${opts.awsProfile}\` exists in ~/.aws/config.` : "or pass --aws-profile <name> if you use named profiles."
|
|
498
|
+
]
|
|
499
|
+
});
|
|
500
|
+
return void 0;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const parsed = JSON.parse(r.stdout);
|
|
504
|
+
const account = parsed.Account;
|
|
505
|
+
if (!account) {
|
|
506
|
+
problems.push({
|
|
507
|
+
id: "aws-identity-malformed",
|
|
508
|
+
message: "aws sts get-caller-identity did not return an Account field.",
|
|
509
|
+
remediation: ["Re-run `aws sts get-caller-identity` manually to inspect the response."]
|
|
510
|
+
});
|
|
511
|
+
return void 0;
|
|
512
|
+
}
|
|
513
|
+
let region = opts.awsRegion;
|
|
514
|
+
if (!region) {
|
|
515
|
+
const fromEnv = process.env.AWS_REGION?.trim() || process.env.AWS_DEFAULT_REGION?.trim();
|
|
516
|
+
if (fromEnv) region = fromEnv;
|
|
517
|
+
}
|
|
518
|
+
if (!region) {
|
|
519
|
+
const cfg = await tryExec("aws", [...awsBaseArgs(opts), "configure", "get", "region"]);
|
|
520
|
+
const trimmed = cfg.stdout.trim();
|
|
521
|
+
if (trimmed) region = trimmed;
|
|
522
|
+
}
|
|
523
|
+
if (!region) {
|
|
524
|
+
problems.push({
|
|
525
|
+
id: "aws-region-missing",
|
|
526
|
+
message: "AWS region is not set.",
|
|
527
|
+
remediation: [
|
|
528
|
+
"Pass --aws-region <name>, set AWS_REGION env var, or run `aws configure set region <name>`."
|
|
529
|
+
]
|
|
530
|
+
});
|
|
531
|
+
return void 0;
|
|
532
|
+
}
|
|
533
|
+
return { account, region };
|
|
534
|
+
} catch (err) {
|
|
535
|
+
problems.push({
|
|
536
|
+
id: "aws-identity-parse-failed",
|
|
537
|
+
message: `Could not parse aws sts get-caller-identity response: ${err instanceof Error ? err.message : String(err)}`,
|
|
538
|
+
remediation: ["Re-run `aws sts get-caller-identity` manually to inspect the response."]
|
|
539
|
+
});
|
|
540
|
+
return void 0;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async function checkGithubRepoFree(opts, owner, problems) {
|
|
544
|
+
if (!owner) return;
|
|
545
|
+
const name = `${owner}/${shortName(opts)}`;
|
|
546
|
+
const r = await tryExec("gh", ["repo", "view", name]);
|
|
547
|
+
if (r.exitCode === 0) {
|
|
548
|
+
problems.push({
|
|
549
|
+
id: "github-repo-exists",
|
|
550
|
+
message: `GitHub repo ${name} already exists.`,
|
|
551
|
+
remediation: [
|
|
552
|
+
"Pick a different project name, or delete the existing repo first:",
|
|
553
|
+
` gh repo delete ${name} --yes`
|
|
554
|
+
]
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async function checkAmplifyAppNameFree(opts, problems) {
|
|
559
|
+
const name = shortName(opts);
|
|
560
|
+
const r = await tryExec(
|
|
561
|
+
"aws",
|
|
562
|
+
awsCmd(opts, [
|
|
563
|
+
"amplify",
|
|
564
|
+
"list-apps",
|
|
565
|
+
"--query",
|
|
566
|
+
`apps[?name=='${name}']`
|
|
567
|
+
])
|
|
568
|
+
);
|
|
569
|
+
if (r.exitCode !== 0) {
|
|
570
|
+
problems.push({
|
|
571
|
+
id: "amplify-list-apps-failed",
|
|
572
|
+
message: "Could not list Amplify Hosting apps to check for a name collision.",
|
|
573
|
+
remediation: [
|
|
574
|
+
"Verify your IAM identity has `amplify:ListApps` permission, then re-run.",
|
|
575
|
+
`Raw error: ${r.stderr.trim() || r.stdout.trim()}`
|
|
576
|
+
]
|
|
577
|
+
});
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const arr = JSON.parse(r.stdout);
|
|
582
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
583
|
+
problems.push({
|
|
584
|
+
id: "amplify-app-name-taken",
|
|
585
|
+
message: `Amplify Hosting already has an app named "${name}" in this region.`,
|
|
586
|
+
remediation: [
|
|
587
|
+
"Pick a different project name, or delete the existing app first:",
|
|
588
|
+
` aws amplify list-apps --query "apps[?name=='${name}'].appId" --output text${opts.awsProfile ? ` --profile ${opts.awsProfile}` : ""}${opts.awsRegion ? ` --region ${opts.awsRegion}` : ""}`,
|
|
589
|
+
" aws amplify delete-app --app-id <appId>"
|
|
590
|
+
]
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
} catch {
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async function checkCdkBootstrap(opts, identity, problems) {
|
|
597
|
+
const r = await tryExec(
|
|
598
|
+
"aws",
|
|
599
|
+
[
|
|
600
|
+
...awsBaseArgs(opts),
|
|
601
|
+
"ssm",
|
|
602
|
+
"get-parameter",
|
|
603
|
+
"--name",
|
|
604
|
+
"/cdk-bootstrap/hnb659fds/version",
|
|
605
|
+
"--output",
|
|
606
|
+
"json"
|
|
607
|
+
]
|
|
608
|
+
);
|
|
609
|
+
if (r.exitCode !== 0) {
|
|
610
|
+
problems.push({
|
|
611
|
+
id: "cdk-not-bootstrapped",
|
|
612
|
+
message: `CDK is not bootstrapped in region ${identity.region}.`,
|
|
613
|
+
remediation: [
|
|
614
|
+
`Run: npx cdk bootstrap aws://${identity.account}/${identity.region}`
|
|
615
|
+
]
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function checkRoute53Zone(opts, problems) {
|
|
620
|
+
if (!opts.domain) return;
|
|
621
|
+
const registrable = extractRegistrableDomain(opts.domain);
|
|
622
|
+
const r = await tryExec(
|
|
623
|
+
"aws",
|
|
624
|
+
[
|
|
625
|
+
...awsBaseArgs(opts),
|
|
626
|
+
"route53",
|
|
627
|
+
"list-hosted-zones-by-name",
|
|
628
|
+
"--dns-name",
|
|
629
|
+
registrable,
|
|
630
|
+
"--max-items",
|
|
631
|
+
"1",
|
|
632
|
+
"--output",
|
|
633
|
+
"json"
|
|
634
|
+
]
|
|
635
|
+
);
|
|
636
|
+
if (r.exitCode !== 0) {
|
|
637
|
+
problems.push({
|
|
638
|
+
id: "route53-list-failed",
|
|
639
|
+
message: `Could not query Route 53 for zone ${registrable}.`,
|
|
640
|
+
remediation: [
|
|
641
|
+
"Either grant `route53:ListHostedZonesByName` to your IAM identity,",
|
|
642
|
+
"or be prepared to add the CNAME records Amplify prints manually after deploy.",
|
|
643
|
+
`Raw error: ${r.stderr.trim() || r.stdout.trim()}`
|
|
644
|
+
]
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const parsed = JSON.parse(r.stdout);
|
|
650
|
+
const zones = parsed.HostedZones ?? [];
|
|
651
|
+
const found = zones.some(
|
|
652
|
+
(z) => z.Name && z.Name.replace(/\.$/, "") === registrable
|
|
653
|
+
);
|
|
654
|
+
if (!found) {
|
|
655
|
+
problems.push({
|
|
656
|
+
id: "route53-zone-missing",
|
|
657
|
+
message: `No Route 53 hosted zone for ${registrable} found in this AWS account.`,
|
|
658
|
+
remediation: [
|
|
659
|
+
"Amplify will still issue an ACM certificate, but you will need to add",
|
|
660
|
+
"the verification CNAMEs at your DNS provider manually. If you want",
|
|
661
|
+
"auto-validation, create a hosted zone first:",
|
|
662
|
+
` aws route53 create-hosted-zone --name ${registrable} --caller-reference $(date +%s)${opts.awsProfile ? ` --profile ${opts.awsProfile}` : ""}`
|
|
663
|
+
]
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
} catch {
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async function resolveIamServiceRole(opts, args, problems) {
|
|
670
|
+
if (args.explicitArn) {
|
|
671
|
+
const roleName = args.explicitArn.split("/").pop() ?? args.explicitArn;
|
|
672
|
+
const r = await tryExec(
|
|
673
|
+
"aws",
|
|
674
|
+
[
|
|
675
|
+
...awsBaseArgs(opts),
|
|
676
|
+
"iam",
|
|
677
|
+
"get-role",
|
|
678
|
+
"--role-name",
|
|
679
|
+
roleName,
|
|
680
|
+
"--output",
|
|
681
|
+
"json"
|
|
682
|
+
]
|
|
683
|
+
);
|
|
684
|
+
if (r.exitCode !== 0) {
|
|
685
|
+
problems.push({
|
|
686
|
+
id: "iam-service-role-not-found",
|
|
687
|
+
message: `IAM role ${args.explicitArn} not found or inaccessible.`,
|
|
688
|
+
remediation: [
|
|
689
|
+
"Verify the ARN is correct and the role exists:",
|
|
690
|
+
` aws iam get-role --role-name ${roleName}${opts.awsProfile ? ` --profile ${opts.awsProfile}` : ""}`
|
|
691
|
+
]
|
|
692
|
+
});
|
|
693
|
+
return { willCreate: false };
|
|
694
|
+
}
|
|
695
|
+
return { arn: args.explicitArn, willCreate: false };
|
|
696
|
+
}
|
|
697
|
+
if (args.willCreate) {
|
|
698
|
+
return { willCreate: true };
|
|
699
|
+
}
|
|
700
|
+
const found = await findExistingAmplifyServiceRole(opts);
|
|
701
|
+
if (found) {
|
|
702
|
+
return { arn: found, willCreate: false };
|
|
703
|
+
}
|
|
704
|
+
problems.push({
|
|
705
|
+
id: "iam-service-role-missing",
|
|
706
|
+
message: "No Amplify Hosting service role found.",
|
|
707
|
+
remediation: [
|
|
708
|
+
"Either pass --iam-service-role <arn> pointing at an existing role,",
|
|
709
|
+
"or let create-ampless build one for you with --create-iam-role.",
|
|
710
|
+
"",
|
|
711
|
+
"Alternatively, create one yourself:",
|
|
712
|
+
` aws iam create-role --role-name ${DEFAULT_AMPLIFY_ROLE_NAME} \\`,
|
|
713
|
+
` --assume-role-policy-document '${TRUST_POLICY_JSON}'`,
|
|
714
|
+
` aws iam attach-role-policy --role-name ${DEFAULT_AMPLIFY_ROLE_NAME} \\`,
|
|
715
|
+
` --policy-arn ${AMPLIFY_BACKEND_POLICY_ARN}`,
|
|
716
|
+
` # then re-run with: --iam-service-role $(aws iam get-role --role-name ${DEFAULT_AMPLIFY_ROLE_NAME} --query Role.Arn --output text)`
|
|
717
|
+
]
|
|
718
|
+
});
|
|
719
|
+
return { willCreate: false };
|
|
720
|
+
}
|
|
721
|
+
function trustPolicyAllowsAmplify(doc) {
|
|
722
|
+
let parsed = doc;
|
|
723
|
+
if (typeof doc === "string") {
|
|
724
|
+
try {
|
|
725
|
+
parsed = JSON.parse(decodeURIComponent(doc));
|
|
726
|
+
} catch {
|
|
727
|
+
try {
|
|
728
|
+
parsed = JSON.parse(doc);
|
|
729
|
+
} catch {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const stmts = parsed?.Statement;
|
|
735
|
+
const arr = Array.isArray(stmts) ? stmts : stmts ? [stmts] : [];
|
|
736
|
+
for (const stmt of arr) {
|
|
737
|
+
const s = stmt;
|
|
738
|
+
const svc = s.Principal?.Service;
|
|
739
|
+
if (typeof svc === "string" && svc === "amplify.amazonaws.com") return true;
|
|
740
|
+
if (Array.isArray(svc) && svc.includes("amplify.amazonaws.com")) return true;
|
|
741
|
+
}
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
async function findExistingAmplifyServiceRole(opts) {
|
|
745
|
+
let marker;
|
|
746
|
+
for (let page = 0; page < 20; page++) {
|
|
747
|
+
const args = [
|
|
748
|
+
...awsBaseArgs(opts),
|
|
749
|
+
"iam",
|
|
750
|
+
"list-roles",
|
|
751
|
+
"--max-items",
|
|
752
|
+
"200",
|
|
753
|
+
"--output",
|
|
754
|
+
"json"
|
|
755
|
+
];
|
|
756
|
+
if (marker) args.push("--starting-token", marker);
|
|
757
|
+
const r = await tryExec("aws", args);
|
|
758
|
+
if (r.exitCode !== 0) return void 0;
|
|
759
|
+
let parsed;
|
|
760
|
+
try {
|
|
761
|
+
parsed = JSON.parse(r.stdout);
|
|
762
|
+
} catch {
|
|
763
|
+
return void 0;
|
|
764
|
+
}
|
|
765
|
+
for (const role of parsed.Roles ?? []) {
|
|
766
|
+
if (!role.RoleName || !role.Arn) continue;
|
|
767
|
+
if (!trustPolicyAllowsAmplify(role.AssumeRolePolicyDocument)) continue;
|
|
768
|
+
const pr = await tryExec(
|
|
769
|
+
"aws",
|
|
770
|
+
[
|
|
771
|
+
...awsBaseArgs(opts),
|
|
772
|
+
"iam",
|
|
773
|
+
"list-attached-role-policies",
|
|
774
|
+
"--role-name",
|
|
775
|
+
role.RoleName,
|
|
776
|
+
"--output",
|
|
777
|
+
"json"
|
|
778
|
+
]
|
|
779
|
+
);
|
|
780
|
+
if (pr.exitCode !== 0) continue;
|
|
781
|
+
try {
|
|
782
|
+
const policies = JSON.parse(pr.stdout);
|
|
783
|
+
const attached = (policies.AttachedPolicies ?? []).some(
|
|
784
|
+
(p3) => p3.PolicyArn === AMPLIFY_BACKEND_POLICY_ARN
|
|
785
|
+
);
|
|
786
|
+
if (attached) return role.Arn;
|
|
787
|
+
} catch {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (!parsed.IsTruncated) break;
|
|
792
|
+
marker = parsed.Marker;
|
|
793
|
+
if (!marker) break;
|
|
794
|
+
}
|
|
795
|
+
return void 0;
|
|
796
|
+
}
|
|
797
|
+
async function runPreflight(opts, extra = {}) {
|
|
798
|
+
const problems = [];
|
|
799
|
+
const hasGh = await checkGhInstalled(problems);
|
|
800
|
+
if (hasGh) await checkGhAuth(problems);
|
|
801
|
+
const hasAws = await checkAwsInstalled(problems);
|
|
802
|
+
const identity = hasAws ? await checkAwsCreds(opts, problems) : void 0;
|
|
803
|
+
if (hasGh && opts.githubOwner && !extra.mountMode) {
|
|
804
|
+
await checkGithubRepoFree(opts, opts.githubOwner, problems);
|
|
805
|
+
}
|
|
806
|
+
if (identity) {
|
|
807
|
+
await checkAmplifyAppNameFree(opts, problems);
|
|
808
|
+
await checkCdkBootstrap(opts, identity, problems);
|
|
809
|
+
await checkRoute53Zone(opts, problems);
|
|
810
|
+
}
|
|
811
|
+
let willCreateIamRole = false;
|
|
812
|
+
let iamServiceRoleArn;
|
|
813
|
+
if (identity) {
|
|
814
|
+
const resolved = await resolveIamServiceRole(
|
|
815
|
+
opts,
|
|
816
|
+
{ explicitArn: extra.iamServiceRoleArn, willCreate: extra.createIamRole === true },
|
|
817
|
+
problems
|
|
818
|
+
);
|
|
819
|
+
willCreateIamRole = resolved.willCreate;
|
|
820
|
+
iamServiceRoleArn = resolved.arn;
|
|
821
|
+
}
|
|
822
|
+
if (problems.length > 0) {
|
|
823
|
+
return { problems };
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
problems,
|
|
827
|
+
resolution: {
|
|
828
|
+
iamServiceRoleArn,
|
|
829
|
+
willCreateIamRole,
|
|
830
|
+
awsAccount: identity.account,
|
|
831
|
+
awsRegion: identity.region
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function formatPreflightReport(problems) {
|
|
836
|
+
const lines = [];
|
|
837
|
+
lines.push(`${pc.red("\u2717")} create-ampless --deploy: prerequisites missing`);
|
|
838
|
+
lines.push("");
|
|
839
|
+
for (const p3 of problems) {
|
|
840
|
+
lines.push(` ${pc.red("\u2717")} ${p3.message}`);
|
|
841
|
+
for (const r of p3.remediation) {
|
|
842
|
+
lines.push(r ? ` ${r}` : "");
|
|
843
|
+
}
|
|
844
|
+
lines.push("");
|
|
845
|
+
}
|
|
846
|
+
lines.push("Fix the items above and re-run.");
|
|
847
|
+
return lines.join("\n");
|
|
848
|
+
}
|
|
849
|
+
function printPreflightReport(problems) {
|
|
850
|
+
process.stderr.write(`${formatPreflightReport(problems)}
|
|
851
|
+
`);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/mount.ts
|
|
855
|
+
import { existsSync as existsSync2 } from "fs";
|
|
856
|
+
import { resolve as resolve3 } from "path";
|
|
857
|
+
function validateMountableProject(destDir) {
|
|
858
|
+
const required = ["package.json", "cms.config.ts"];
|
|
859
|
+
for (const f of required) {
|
|
860
|
+
if (!existsSync2(resolve3(destDir, f))) {
|
|
861
|
+
return `Not an ampless project (missing ${f} in ${destDir})`;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const amplifyFiles = ["amplify/backend.ts", "amplify/data/resource.ts"];
|
|
865
|
+
if (!amplifyFiles.some((f) => existsSync2(resolve3(destDir, f)))) {
|
|
866
|
+
return `Not an ampless project (missing amplify/ in ${destDir})`;
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
var MOUNT_DEFAULT_GITIGNORE = `# Dependencies
|
|
871
|
+
node_modules/
|
|
872
|
+
|
|
873
|
+
# Next.js
|
|
874
|
+
.next/
|
|
875
|
+
.amplify/
|
|
876
|
+
|
|
877
|
+
# Amplify outputs (regenerated on every deploy / sandbox)
|
|
878
|
+
amplify_outputs.json
|
|
879
|
+
|
|
880
|
+
# Env / OS noise
|
|
881
|
+
.env
|
|
882
|
+
.env.local
|
|
883
|
+
.env.*.local
|
|
884
|
+
.DS_Store
|
|
885
|
+
|
|
886
|
+
# Logs
|
|
887
|
+
npm-debug.log*
|
|
888
|
+
yarn-debug.log*
|
|
889
|
+
yarn-error.log*
|
|
890
|
+
`;
|
|
891
|
+
function originPointsAt(origin, owner, name) {
|
|
892
|
+
const candidates = [
|
|
893
|
+
`https://github.com/${owner}/${name}`,
|
|
894
|
+
`https://github.com/${owner}/${name}.git`,
|
|
895
|
+
`git@github.com:${owner}/${name}`,
|
|
896
|
+
`git@github.com:${owner}/${name}.git`,
|
|
897
|
+
`ssh://git@github.com/${owner}/${name}`,
|
|
898
|
+
`ssh://git@github.com/${owner}/${name}.git`
|
|
899
|
+
];
|
|
900
|
+
return candidates.includes(origin);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/deploy.ts
|
|
315
904
|
var AMPLIFY_BUILD_SPEC = `version: 1
|
|
316
905
|
frontend:
|
|
317
906
|
phases:
|
|
318
907
|
preBuild:
|
|
319
908
|
commands:
|
|
320
|
-
- npm
|
|
909
|
+
- npm install
|
|
321
910
|
build:
|
|
322
911
|
commands:
|
|
323
912
|
- npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
|
|
@@ -336,7 +925,7 @@ async function resolveGithubToken(explicit, env = process.env) {
|
|
|
336
925
|
const envToken = env.GITHUB_TOKEN;
|
|
337
926
|
if (envToken && envToken.trim()) return envToken.trim();
|
|
338
927
|
try {
|
|
339
|
-
const { stdout } = await
|
|
928
|
+
const { stdout } = await execa2("gh", ["auth", "token"], { reject: false });
|
|
340
929
|
const trimmed = stdout?.trim();
|
|
341
930
|
if (trimmed) return trimmed;
|
|
342
931
|
} catch {
|
|
@@ -400,7 +989,7 @@ function splitDomain(domain, subdomain) {
|
|
|
400
989
|
async function run(cmd, args, opts = {}) {
|
|
401
990
|
const { step, ...execaOpts } = opts;
|
|
402
991
|
try {
|
|
403
|
-
const result = await
|
|
992
|
+
const result = await execa2(cmd, args, { stdio: "pipe", ...execaOpts });
|
|
404
993
|
return result.stdout?.toString() ?? "";
|
|
405
994
|
} catch (err) {
|
|
406
995
|
const e = err;
|
|
@@ -410,15 +999,6 @@ async function run(cmd, args, opts = {}) {
|
|
|
410
999
|
${detail}`);
|
|
411
1000
|
}
|
|
412
1001
|
}
|
|
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
1002
|
function awsArgs(opts, extra) {
|
|
423
1003
|
const base = [];
|
|
424
1004
|
if (opts.awsProfile) base.push("--profile", opts.awsProfile);
|
|
@@ -427,24 +1007,108 @@ function awsArgs(opts, extra) {
|
|
|
427
1007
|
base.push("--output", "json");
|
|
428
1008
|
return base;
|
|
429
1009
|
}
|
|
430
|
-
async function
|
|
431
|
-
await
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
1010
|
+
async function isGitRepo(dir) {
|
|
1011
|
+
const r = await execa2("git", ["rev-parse", "--git-dir"], { cwd: dir, reject: false });
|
|
1012
|
+
return r.exitCode === 0;
|
|
1013
|
+
}
|
|
1014
|
+
async function isDirty(dir) {
|
|
1015
|
+
const r = await execa2("git", ["status", "--porcelain"], { cwd: dir, reject: false });
|
|
1016
|
+
return (r.stdout?.toString() ?? "").trim().length > 0;
|
|
1017
|
+
}
|
|
1018
|
+
async function currentBranch(dir) {
|
|
1019
|
+
const r = await execa2("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
1020
|
+
cwd: dir,
|
|
1021
|
+
reject: false
|
|
1022
|
+
});
|
|
1023
|
+
if (r.exitCode !== 0) return null;
|
|
1024
|
+
const name = r.stdout?.toString().trim();
|
|
1025
|
+
return name && name !== "HEAD" ? name : null;
|
|
1026
|
+
}
|
|
1027
|
+
async function ensureGitignore(dir) {
|
|
1028
|
+
const target = resolve4(dir, ".gitignore");
|
|
1029
|
+
if (existsSync3(target)) return;
|
|
1030
|
+
await writeFile2(target, MOUNT_DEFAULT_GITIGNORE, "utf-8");
|
|
1031
|
+
}
|
|
1032
|
+
async function gitInitOrReuse(dir, mount) {
|
|
1033
|
+
const repo = await isGitRepo(dir);
|
|
1034
|
+
if (!repo) {
|
|
1035
|
+
await run("git", ["init", "-b", "main"], { cwd: dir, step: "git init" });
|
|
1036
|
+
}
|
|
1037
|
+
const dirty = await isDirty(dir);
|
|
1038
|
+
if (dirty) {
|
|
1039
|
+
await run("git", ["add", "."], { cwd: dir, step: "git add" });
|
|
1040
|
+
const message = mount && repo ? "Prepare for create-ampless --mount" : "Initial scaffold (create-ampless)";
|
|
1041
|
+
await run("git", ["commit", "-m", message], { cwd: dir, step: "git commit" });
|
|
1042
|
+
}
|
|
1043
|
+
if (mount) {
|
|
1044
|
+
const branch = await currentBranch(dir);
|
|
1045
|
+
if (branch && branch !== "main") {
|
|
1046
|
+
log.warn(
|
|
1047
|
+
`Current branch is "${branch}" \u2014 Amplify Hosting will be wired to the "main" branch. Push from "main" later, or rename your branch with \`git branch -m main\`.`
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
function shortName2(opts) {
|
|
1053
|
+
return basename2(opts.projectDir);
|
|
1054
|
+
}
|
|
1055
|
+
async function ghRepoExists(name, token) {
|
|
1056
|
+
const r = await execa2("gh", ["repo", "view", name], {
|
|
1057
|
+
reject: false,
|
|
1058
|
+
env: { ...process.env, GH_TOKEN: token }
|
|
1059
|
+
});
|
|
1060
|
+
return r.exitCode === 0;
|
|
1061
|
+
}
|
|
1062
|
+
async function getOriginUrl(dir) {
|
|
1063
|
+
const r = await execa2("git", ["remote", "get-url", "origin"], {
|
|
1064
|
+
cwd: dir,
|
|
1065
|
+
reject: false
|
|
1066
|
+
});
|
|
1067
|
+
if (r.exitCode !== 0) return null;
|
|
1068
|
+
const url = r.stdout?.toString().trim();
|
|
1069
|
+
return url ? url : null;
|
|
1070
|
+
}
|
|
1071
|
+
function repoHttpsUrl(owner, name) {
|
|
1072
|
+
return `https://github.com/${owner}/${name}`;
|
|
438
1073
|
}
|
|
439
1074
|
async function ghRepoCreate(opts) {
|
|
1075
|
+
const owner = opts.githubOwner;
|
|
1076
|
+
const name = shortName2(opts);
|
|
1077
|
+
const fullName = `${owner}/${name}`;
|
|
440
1078
|
const visibility = opts.githubPrivate ? "--private" : "--public";
|
|
441
|
-
const
|
|
1079
|
+
const exists = await ghRepoExists(fullName, opts.githubToken);
|
|
1080
|
+
if (exists) {
|
|
1081
|
+
const origin = await getOriginUrl(opts.projectDir);
|
|
1082
|
+
if (origin && !originPointsAt(origin, owner, name)) {
|
|
1083
|
+
throw new Error(
|
|
1084
|
+
`Existing 'origin' remote points at ${origin}, but --mount expects ${fullName}.
|
|
1085
|
+
Remove or rename the existing remote (\`git remote remove origin\`) and re-run.`
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
if (!origin) {
|
|
1089
|
+
await run(
|
|
1090
|
+
"git",
|
|
1091
|
+
["remote", "add", "origin", `https://github.com/${fullName}.git`],
|
|
1092
|
+
{ cwd: opts.projectDir, step: "git remote add origin" }
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
await run(
|
|
1096
|
+
"git",
|
|
1097
|
+
["push", "-u", "origin", "HEAD:main"],
|
|
1098
|
+
{
|
|
1099
|
+
cwd: opts.projectDir,
|
|
1100
|
+
step: "git push origin main",
|
|
1101
|
+
env: { ...process.env, GH_TOKEN: opts.githubToken }
|
|
1102
|
+
}
|
|
1103
|
+
);
|
|
1104
|
+
return repoHttpsUrl(owner, name);
|
|
1105
|
+
}
|
|
442
1106
|
const out = await run(
|
|
443
1107
|
"gh",
|
|
444
1108
|
[
|
|
445
1109
|
"repo",
|
|
446
1110
|
"create",
|
|
447
|
-
|
|
1111
|
+
fullName,
|
|
448
1112
|
"--source",
|
|
449
1113
|
opts.projectDir,
|
|
450
1114
|
"--push",
|
|
@@ -460,25 +1124,29 @@ async function ghRepoCreate(opts) {
|
|
|
460
1124
|
);
|
|
461
1125
|
const match = out.match(/https:\/\/github\.com\/[^\s]+/g);
|
|
462
1126
|
if (match && match.length > 0) return match[match.length - 1].replace(/[.,]$/, "");
|
|
463
|
-
return
|
|
1127
|
+
return repoHttpsUrl(owner, name);
|
|
464
1128
|
}
|
|
465
|
-
async function amplifyCreateApp(opts, repoUrl) {
|
|
1129
|
+
async function amplifyCreateApp(opts, repoUrl, iamServiceRoleArn) {
|
|
1130
|
+
const cmd = [
|
|
1131
|
+
"amplify",
|
|
1132
|
+
"create-app",
|
|
1133
|
+
"--name",
|
|
1134
|
+
shortName2(opts),
|
|
1135
|
+
"--repository",
|
|
1136
|
+
repoUrl,
|
|
1137
|
+
"--access-token",
|
|
1138
|
+
opts.githubToken,
|
|
1139
|
+
"--platform",
|
|
1140
|
+
"WEB_COMPUTE",
|
|
1141
|
+
"--build-spec",
|
|
1142
|
+
AMPLIFY_BUILD_SPEC
|
|
1143
|
+
];
|
|
1144
|
+
if (iamServiceRoleArn) {
|
|
1145
|
+
cmd.push("--iam-service-role-arn", iamServiceRoleArn);
|
|
1146
|
+
}
|
|
466
1147
|
const out = await run(
|
|
467
1148
|
"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
|
-
]),
|
|
1149
|
+
awsArgs(opts, cmd),
|
|
482
1150
|
{ step: "aws amplify create-app" }
|
|
483
1151
|
);
|
|
484
1152
|
const parsed = JSON.parse(out);
|
|
@@ -552,7 +1220,7 @@ async function amplifyCreateDomain(opts, appId) {
|
|
|
552
1220
|
const route53Zone = await findRoute53Zone(opts, registrable);
|
|
553
1221
|
if (route53Zone) {
|
|
554
1222
|
log.info(
|
|
555
|
-
`Route 53 hosted zone detected for ${
|
|
1223
|
+
`Route 53 hosted zone detected for ${pc2.cyan(registrable)} \u2014 Amplify will auto-create DNS records once ACM validates.`
|
|
556
1224
|
);
|
|
557
1225
|
}
|
|
558
1226
|
const out = await run(
|
|
@@ -598,10 +1266,94 @@ async function amplifyCreateDomain(opts, appId) {
|
|
|
598
1266
|
verification: route53Zone ? void 0 : verification.length > 0 ? verification : void 0
|
|
599
1267
|
};
|
|
600
1268
|
}
|
|
1269
|
+
async function provisionIamServiceRole(opts) {
|
|
1270
|
+
const roleName = DEFAULT_AMPLIFY_ROLE_NAME;
|
|
1271
|
+
const trustPolicy = JSON.stringify({
|
|
1272
|
+
Version: "2012-10-17",
|
|
1273
|
+
Statement: [
|
|
1274
|
+
{
|
|
1275
|
+
Effect: "Allow",
|
|
1276
|
+
Principal: { Service: "amplify.amazonaws.com" },
|
|
1277
|
+
Action: "sts:AssumeRole"
|
|
1278
|
+
}
|
|
1279
|
+
]
|
|
1280
|
+
});
|
|
1281
|
+
const createArgs = [
|
|
1282
|
+
"iam",
|
|
1283
|
+
"create-role",
|
|
1284
|
+
"--role-name",
|
|
1285
|
+
roleName,
|
|
1286
|
+
"--assume-role-policy-document",
|
|
1287
|
+
trustPolicy,
|
|
1288
|
+
"--description",
|
|
1289
|
+
"Amplify Hosting service role created by create-ampless"
|
|
1290
|
+
];
|
|
1291
|
+
try {
|
|
1292
|
+
await run("aws", awsArgs(opts, createArgs), { step: "aws iam create-role" });
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1295
|
+
if (!/EntityAlreadyExists/i.test(msg) && !/already exists/i.test(msg)) {
|
|
1296
|
+
throw err;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
await run(
|
|
1300
|
+
"aws",
|
|
1301
|
+
awsArgs(opts, [
|
|
1302
|
+
"iam",
|
|
1303
|
+
"attach-role-policy",
|
|
1304
|
+
"--role-name",
|
|
1305
|
+
roleName,
|
|
1306
|
+
"--policy-arn",
|
|
1307
|
+
AMPLIFY_BACKEND_POLICY_ARN
|
|
1308
|
+
]),
|
|
1309
|
+
{ step: "aws iam attach-role-policy" }
|
|
1310
|
+
);
|
|
1311
|
+
const out = await run(
|
|
1312
|
+
"aws",
|
|
1313
|
+
awsArgs(opts, ["iam", "get-role", "--role-name", roleName]),
|
|
1314
|
+
{ step: "aws iam get-role" }
|
|
1315
|
+
);
|
|
1316
|
+
const parsed = JSON.parse(out);
|
|
1317
|
+
const arn = parsed.Role?.Arn;
|
|
1318
|
+
if (!arn) {
|
|
1319
|
+
throw new Error(`aws iam get-role for ${roleName}: missing Role.Arn in response`);
|
|
1320
|
+
}
|
|
1321
|
+
return arn;
|
|
1322
|
+
}
|
|
1323
|
+
var PreflightError = class extends Error {
|
|
1324
|
+
result;
|
|
1325
|
+
constructor(result) {
|
|
1326
|
+
super("create-ampless --deploy: pre-flight failed");
|
|
1327
|
+
this.name = "PreflightError";
|
|
1328
|
+
this.result = result;
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
601
1331
|
async function runDeploy(opts) {
|
|
602
|
-
await
|
|
603
|
-
|
|
604
|
-
|
|
1332
|
+
const pre = await runPreflight(opts, {
|
|
1333
|
+
iamServiceRoleArn: opts.iamServiceRoleArn,
|
|
1334
|
+
createIamRole: opts.createIamRole === true,
|
|
1335
|
+
mountMode: opts.mount === true
|
|
1336
|
+
});
|
|
1337
|
+
if (pre.problems.length > 0) {
|
|
1338
|
+
printPreflightReport(pre.problems);
|
|
1339
|
+
throw new PreflightError(pre);
|
|
1340
|
+
}
|
|
1341
|
+
const resolution = pre.resolution;
|
|
1342
|
+
let serviceRoleArn = resolution.iamServiceRoleArn;
|
|
1343
|
+
if (resolution.willCreateIamRole && !serviceRoleArn) {
|
|
1344
|
+
const s0 = spinner();
|
|
1345
|
+
s0.start(`Provisioning IAM service role ${DEFAULT_AMPLIFY_ROLE_NAME}`);
|
|
1346
|
+
try {
|
|
1347
|
+
serviceRoleArn = await provisionIamServiceRole(opts);
|
|
1348
|
+
s0.stop(`IAM role: ${serviceRoleArn}`);
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
s0.stop("IAM role provisioning: failed");
|
|
1351
|
+
throw new Error(
|
|
1352
|
+
`Failed to provision IAM service role: ${err instanceof Error ? err.message : String(err)}`
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
await writeFile2(resolve4(opts.projectDir, "amplify.yml"), AMPLIFY_BUILD_SPEC, "utf-8");
|
|
605
1357
|
const created = {};
|
|
606
1358
|
const fail = (step, cause) => {
|
|
607
1359
|
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
@@ -613,7 +1365,7 @@ async function runDeploy(opts) {
|
|
|
613
1365
|
}
|
|
614
1366
|
if (created.repoUrl) {
|
|
615
1367
|
cleanup.push(
|
|
616
|
-
` - GitHub repo: ${created.repoUrl} (delete: gh repo delete ${opts.githubOwner}/${opts
|
|
1368
|
+
` - GitHub repo: ${created.repoUrl} (delete: gh repo delete ${opts.githubOwner}/${shortName2(opts)} --yes)`
|
|
617
1369
|
);
|
|
618
1370
|
}
|
|
619
1371
|
const cleanupBlock = cleanup.length > 0 ? `
|
|
@@ -625,10 +1377,14 @@ Re-run after cleaning up.` : "";
|
|
|
625
1377
|
${msg}${cleanupBlock}`);
|
|
626
1378
|
};
|
|
627
1379
|
let s = spinner();
|
|
628
|
-
|
|
1380
|
+
const gitMsg = opts.mount ? "git: preparing repo for mount" : "git init + initial commit";
|
|
1381
|
+
s.start(gitMsg);
|
|
629
1382
|
try {
|
|
630
|
-
|
|
631
|
-
|
|
1383
|
+
if (opts.mount) {
|
|
1384
|
+
await ensureGitignore(opts.projectDir);
|
|
1385
|
+
}
|
|
1386
|
+
await gitInitOrReuse(opts.projectDir, opts.mount === true);
|
|
1387
|
+
s.stop(opts.mount ? "git: ready" : "git: committed initial scaffold");
|
|
632
1388
|
} catch (err) {
|
|
633
1389
|
s.stop("git: failed");
|
|
634
1390
|
fail("git init / commit", err);
|
|
@@ -646,7 +1402,7 @@ ${msg}${cleanupBlock}`);
|
|
|
646
1402
|
s = spinner();
|
|
647
1403
|
s.start("Creating Amplify Hosting app");
|
|
648
1404
|
try {
|
|
649
|
-
app = await amplifyCreateApp(opts, created.repoUrl);
|
|
1405
|
+
app = await amplifyCreateApp(opts, created.repoUrl, serviceRoleArn);
|
|
650
1406
|
created.appId = app.appId;
|
|
651
1407
|
s.stop(`Amplify app: ${app.appId}`);
|
|
652
1408
|
} catch (err) {
|
|
@@ -671,7 +1427,7 @@ ${msg}${cleanupBlock}`);
|
|
|
671
1427
|
s.stop("Amplify start-job: failed");
|
|
672
1428
|
fail("aws amplify start-job", err);
|
|
673
1429
|
}
|
|
674
|
-
const amplifyAppUrl = `https://main.${app.
|
|
1430
|
+
const amplifyAppUrl = `https://main.${app.defaultDomain}`;
|
|
675
1431
|
const result = {
|
|
676
1432
|
githubRepoUrl: created.repoUrl,
|
|
677
1433
|
amplifyAppId: app.appId,
|
|
@@ -698,7 +1454,7 @@ ${msg}`);
|
|
|
698
1454
|
// src/deploy-prompts.ts
|
|
699
1455
|
async function detectGithubLogin() {
|
|
700
1456
|
try {
|
|
701
|
-
const { stdout } = await
|
|
1457
|
+
const { stdout } = await execa3("gh", ["api", "user", "--jq", ".login"], { reject: false });
|
|
702
1458
|
const trimmed = stdout?.trim();
|
|
703
1459
|
return trimmed || void 0;
|
|
704
1460
|
} catch {
|
|
@@ -709,7 +1465,7 @@ async function detectAwsRegion() {
|
|
|
709
1465
|
if (process.env.AWS_REGION?.trim()) return process.env.AWS_REGION.trim();
|
|
710
1466
|
if (process.env.AWS_DEFAULT_REGION?.trim()) return process.env.AWS_DEFAULT_REGION.trim();
|
|
711
1467
|
try {
|
|
712
|
-
const { stdout } = await
|
|
1468
|
+
const { stdout } = await execa3("aws", ["configure", "get", "region"], { reject: false });
|
|
713
1469
|
const trimmed = stdout?.trim();
|
|
714
1470
|
return trimmed || void 0;
|
|
715
1471
|
} catch {
|
|
@@ -836,12 +1592,89 @@ async function gatherDeployOptions(args, projectDir, projectName) {
|
|
|
836
1592
|
awsRegion,
|
|
837
1593
|
domain,
|
|
838
1594
|
subdomain,
|
|
1595
|
+
iamServiceRoleArn: args.iamServiceRole,
|
|
1596
|
+
createIamRole: args.createIamRole,
|
|
839
1597
|
skipConfirm: args.skipConfirm
|
|
840
1598
|
};
|
|
841
1599
|
}
|
|
842
1600
|
|
|
843
1601
|
// src/index.ts
|
|
844
|
-
import
|
|
1602
|
+
import pc3 from "picocolors";
|
|
1603
|
+
function buildNonInteractiveOpts(args) {
|
|
1604
|
+
const projectName = args.projectName ?? (() => {
|
|
1605
|
+
const b = basename3(process.cwd());
|
|
1606
|
+
return b && b !== "/" ? b : "my-ampless-site";
|
|
1607
|
+
})();
|
|
1608
|
+
const siteName = args.siteName ?? "My Blog";
|
|
1609
|
+
const themes = args.themes ?? ["blog"];
|
|
1610
|
+
const plugins = args.plugins ?? ["seo"];
|
|
1611
|
+
return {
|
|
1612
|
+
projectName,
|
|
1613
|
+
siteName,
|
|
1614
|
+
themes,
|
|
1615
|
+
defaultTheme: themes[0],
|
|
1616
|
+
plugins
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
function warnIgnoredScaffoldFlags(args) {
|
|
1620
|
+
const ignored = [];
|
|
1621
|
+
if (args.siteName) ignored.push("--site-name");
|
|
1622
|
+
if (args.themes) ignored.push("--themes");
|
|
1623
|
+
if (args.plugins) ignored.push("--plugins");
|
|
1624
|
+
if (args.projectName) ignored.push("<project-name> positional");
|
|
1625
|
+
if (ignored.length > 0) {
|
|
1626
|
+
log3.warn(`--mount mode ignores: ${ignored.join(", ")}`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
async function runMount(args) {
|
|
1630
|
+
const destDir = process.cwd();
|
|
1631
|
+
const projectName = basename3(destDir);
|
|
1632
|
+
warnIgnoredScaffoldFlags(args);
|
|
1633
|
+
const problem = validateMountableProject(destDir);
|
|
1634
|
+
if (problem) {
|
|
1635
|
+
log3.error(problem);
|
|
1636
|
+
log3.info(
|
|
1637
|
+
"Run `npx create-ampless@alpha <name>` first to scaffold, or `cd` into a scaffolded project before passing --mount."
|
|
1638
|
+
);
|
|
1639
|
+
process.exit(1);
|
|
1640
|
+
}
|
|
1641
|
+
const deployOpts = await gatherDeployOptions(args, destDir, projectName);
|
|
1642
|
+
if (!deployOpts) return;
|
|
1643
|
+
try {
|
|
1644
|
+
const result = await runDeploy({ ...deployOpts, mount: true });
|
|
1645
|
+
printDeployResult(result);
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
if (err instanceof PreflightError) {
|
|
1648
|
+
process.exit(1);
|
|
1649
|
+
}
|
|
1650
|
+
log3.error(err instanceof Error ? err.message : String(err));
|
|
1651
|
+
process.exit(1);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
function printDeployResult(result) {
|
|
1655
|
+
const lines = [
|
|
1656
|
+
`${pc3.green("\u2714")} Project deployed`,
|
|
1657
|
+
``,
|
|
1658
|
+
` GitHub: ${pc3.cyan(result.githubRepoUrl)}`,
|
|
1659
|
+
` Amplify app: ${pc3.cyan(result.amplifyAppId)}`,
|
|
1660
|
+
` Amplify URL: ${pc3.cyan(result.amplifyAppUrl)}`
|
|
1661
|
+
];
|
|
1662
|
+
if (result.domainUrl) {
|
|
1663
|
+
lines.push(` Custom domain: ${pc3.cyan(result.domainUrl)}`);
|
|
1664
|
+
}
|
|
1665
|
+
if (result.domainVerification && result.domainVerification.length > 0) {
|
|
1666
|
+
lines.push("", ` ${pc3.bold("Add these DNS records to verify the domain:")}`);
|
|
1667
|
+
for (const v of result.domainVerification) {
|
|
1668
|
+
lines.push(` ${v.cname} CNAME ${v.target}`);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
lines.push(
|
|
1672
|
+
"",
|
|
1673
|
+
` First build is now running in Amplify Hosting.`,
|
|
1674
|
+
` Watch it at ${pc3.cyan(`https://console.aws.amazon.com/amplify/home#/${result.amplifyAppId}`)}`
|
|
1675
|
+
);
|
|
1676
|
+
outro(lines.join("\n"));
|
|
1677
|
+
}
|
|
845
1678
|
async function main() {
|
|
846
1679
|
const args = parseDeployArgs(process.argv.slice(2));
|
|
847
1680
|
if (args.help) {
|
|
@@ -851,10 +1684,19 @@ async function main() {
|
|
|
851
1684
|
for (const flag of args.unknown) {
|
|
852
1685
|
log3.warn(`Unknown argument ignored: ${flag}`);
|
|
853
1686
|
}
|
|
854
|
-
|
|
1687
|
+
if (args.mount) {
|
|
1688
|
+
await runMount(args);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
let opts;
|
|
1692
|
+
if (args.skipConfirm) {
|
|
1693
|
+
opts = buildNonInteractiveOpts(args);
|
|
1694
|
+
} else {
|
|
1695
|
+
opts = await runPrompts(args.projectName);
|
|
1696
|
+
}
|
|
855
1697
|
if (!opts) return;
|
|
856
|
-
const destDir =
|
|
857
|
-
if (
|
|
1698
|
+
const destDir = resolve5(process.cwd(), opts.projectName);
|
|
1699
|
+
if (existsSync4(destDir)) {
|
|
858
1700
|
log3.error(`Directory already exists: ${destDir}`);
|
|
859
1701
|
process.exit(1);
|
|
860
1702
|
}
|
|
@@ -874,42 +1716,24 @@ async function main() {
|
|
|
874
1716
|
if (!deployOpts) return;
|
|
875
1717
|
try {
|
|
876
1718
|
const result = await runDeploy(deployOpts);
|
|
877
|
-
|
|
878
|
-
`${pc2.green("\u2714")} Project deployed`,
|
|
879
|
-
``,
|
|
880
|
-
` GitHub: ${pc2.cyan(result.githubRepoUrl)}`,
|
|
881
|
-
` Amplify app: ${pc2.cyan(result.amplifyAppId)}`,
|
|
882
|
-
` Amplify URL: ${pc2.cyan(result.amplifyAppUrl)}`
|
|
883
|
-
];
|
|
884
|
-
if (result.domainUrl) {
|
|
885
|
-
lines.push(` Custom domain: ${pc2.cyan(result.domainUrl)}`);
|
|
886
|
-
}
|
|
887
|
-
if (result.domainVerification && result.domainVerification.length > 0) {
|
|
888
|
-
lines.push("", ` ${pc2.bold("Add these DNS records to verify the domain:")}`);
|
|
889
|
-
for (const v of result.domainVerification) {
|
|
890
|
-
lines.push(` ${v.cname} CNAME ${v.target}`);
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
lines.push(
|
|
894
|
-
"",
|
|
895
|
-
` First build is now running in Amplify Hosting.`,
|
|
896
|
-
` Watch it at ${pc2.cyan(`https://console.aws.amazon.com/amplify/home#/${result.amplifyAppId}`)}`
|
|
897
|
-
);
|
|
898
|
-
outro(lines.join("\n"));
|
|
1719
|
+
printDeployResult(result);
|
|
899
1720
|
} catch (err) {
|
|
1721
|
+
if (err instanceof PreflightError) {
|
|
1722
|
+
process.exit(1);
|
|
1723
|
+
}
|
|
900
1724
|
log3.error(err instanceof Error ? err.message : String(err));
|
|
901
1725
|
process.exit(1);
|
|
902
1726
|
}
|
|
903
1727
|
return;
|
|
904
1728
|
}
|
|
905
1729
|
outro(
|
|
906
|
-
`${
|
|
1730
|
+
`${pc3.green("\u2714")} Project created at ${pc3.bold(opts.projectName)}
|
|
907
1731
|
|
|
908
1732
|
Next steps:
|
|
909
|
-
${
|
|
910
|
-
${
|
|
911
|
-
${
|
|
912
|
-
${
|
|
1733
|
+
${pc3.cyan("cd")} ${opts.projectName}
|
|
1734
|
+
${pc3.cyan("npm install")}
|
|
1735
|
+
${pc3.cyan("npx ampx sandbox")} ${pc3.dim("# start Amplify backend")}
|
|
1736
|
+
${pc3.cyan("npm run dev")} ${pc3.dim("# start Next.js")}`
|
|
913
1737
|
);
|
|
914
1738
|
}
|
|
915
1739
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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>
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"@ampless/plugin-rss": "^0.2.0-alpha.1",
|
|
25
25
|
"@ampless/plugin-seo": "^0.2.0-alpha.1",
|
|
26
26
|
"@ampless/plugin-webhook": "^0.2.0-alpha.1",
|
|
27
|
-
"@ampless/admin": "^0.2.0-alpha.
|
|
28
|
-
"@ampless/backend": "^0.2.0-alpha.
|
|
29
|
-
"@ampless/runtime": "^0.2.0-alpha.
|
|
27
|
+
"@ampless/admin": "^0.2.0-alpha.6",
|
|
28
|
+
"@ampless/backend": "^0.2.0-alpha.2",
|
|
29
|
+
"@ampless/runtime": "^0.2.0-alpha.3",
|
|
30
30
|
"@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
|
|
31
31
|
"ampless": "^0.2.0-alpha.1",
|
|
32
32
|
"aws-amplify": "^6.10.0",
|