create-ampless 0.2.0-alpha.0
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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +229 -0
- package/dist/templates/_shared/RUNBOOK.md +178 -0
- package/dist/templates/_shared/amplify/auth/post-confirmation/handler.ts +4 -0
- package/dist/templates/_shared/amplify/auth/post-confirmation/resource.ts +6 -0
- package/dist/templates/_shared/amplify/auth/resource.ts +8 -0
- package/dist/templates/_shared/amplify/backend.ts +29 -0
- package/dist/templates/_shared/amplify/data/get-published-post.js +33 -0
- package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +52 -0
- package/dist/templates/_shared/amplify/data/list-published-posts.js +57 -0
- package/dist/templates/_shared/amplify/data/resource.ts +30 -0
- package/dist/templates/_shared/amplify/events/dispatcher/handler.ts +4 -0
- package/dist/templates/_shared/amplify/events/dispatcher/resource.ts +12 -0
- package/dist/templates/_shared/amplify/events/processor-trusted/handler.ts +12 -0
- package/dist/templates/_shared/amplify/events/processor-trusted/resource.ts +14 -0
- package/dist/templates/_shared/amplify/events/processor-untrusted/handler.ts +10 -0
- package/dist/templates/_shared/amplify/events/processor-untrusted/resource.ts +9 -0
- package/dist/templates/_shared/amplify/functions/api-key-renewer/handler.ts +4 -0
- package/dist/templates/_shared/amplify/functions/api-key-renewer/resource.ts +12 -0
- package/dist/templates/_shared/amplify/storage/resource.ts +7 -0
- package/dist/templates/_shared/app/(admin)/admin/layout.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/media/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/posts/new/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/posts/page.tsx +4 -0
- package/dist/templates/_shared/app/(admin)/admin/sites/[siteId]/page.tsx +5 -0
- package/dist/templates/_shared/app/(admin)/admin/sites/[siteId]/theme/page.tsx +6 -0
- package/dist/templates/_shared/app/(admin)/admin/sites/page.tsx +5 -0
- package/dist/templates/_shared/app/api/media/[...path]/route.ts +5 -0
- package/dist/templates/_shared/app/globals.css +114 -0
- package/dist/templates/_shared/app/layout.tsx +48 -0
- package/dist/templates/_shared/app/login/page.tsx +4 -0
- package/dist/templates/_shared/app/providers.tsx +13 -0
- package/dist/templates/_shared/app/site/[siteId]/[slug]/page.tsx +10 -0
- package/dist/templates/_shared/app/site/[siteId]/feed.xml/route.ts +5 -0
- package/dist/templates/_shared/app/site/[siteId]/og/[slug]/route.ts +6 -0
- package/dist/templates/_shared/app/site/[siteId]/page.tsx +10 -0
- package/dist/templates/_shared/app/site/[siteId]/raw/[slug]/route.ts +5 -0
- package/dist/templates/_shared/app/site/[siteId]/sitemap.xml/route.ts +5 -0
- package/dist/templates/_shared/app/site/[siteId]/tag/[tag]/page.tsx +10 -0
- package/dist/templates/_shared/cms.config.ts +110 -0
- package/dist/templates/_shared/components/i18n-provider.tsx +7 -0
- package/dist/templates/_shared/components/lightbox-content.tsx +69 -0
- package/dist/templates/_shared/components/site-chrome/collapsible-sidebar.tsx +54 -0
- package/dist/templates/_shared/components/site-chrome/mobile-menu.tsx +68 -0
- package/dist/templates/_shared/components/site-chrome/site-footer.tsx +43 -0
- package/dist/templates/_shared/components/site-chrome/site-header.tsx +94 -0
- package/dist/templates/_shared/components/site-chrome/site-sidebar.tsx +81 -0
- package/dist/templates/_shared/components/tag-list.tsx +25 -0
- package/dist/templates/_shared/components.json +21 -0
- package/dist/templates/_shared/lib/admin-site-client.ts +10 -0
- package/dist/templates/_shared/lib/admin-site.ts +8 -0
- package/dist/templates/_shared/lib/admin.ts +24 -0
- package/dist/templates/_shared/lib/ampless.ts +23 -0
- package/dist/templates/_shared/lib/amplify-server.ts +7 -0
- package/dist/templates/_shared/lib/amplify.ts +9 -0
- package/dist/templates/_shared/lib/auth-server.ts +11 -0
- package/dist/templates/_shared/lib/cn.ts +5 -0
- package/dist/templates/_shared/lib/i18n.ts +31 -0
- package/dist/templates/_shared/lib/kv-provider.ts +7 -0
- package/dist/templates/_shared/lib/media.ts +6 -0
- package/dist/templates/_shared/lib/posts-provider.ts +7 -0
- package/dist/templates/_shared/lib/posts-public.ts +19 -0
- package/dist/templates/_shared/lib/posts.ts +12 -0
- package/dist/templates/_shared/lib/seo.ts +8 -0
- package/dist/templates/_shared/lib/site-settings.ts +8 -0
- package/dist/templates/_shared/lib/storage.ts +7 -0
- package/dist/templates/_shared/lib/theme-actions.ts +5 -0
- package/dist/templates/_shared/lib/theme-active.ts +8 -0
- package/dist/templates/_shared/lib/theme-config.ts +10 -0
- package/dist/templates/_shared/lib/upload.ts +6 -0
- package/dist/templates/_shared/middleware.ts +13 -0
- package/dist/templates/_shared/next.config.mjs +11 -0
- package/dist/templates/_shared/package.json +63 -0
- package/dist/templates/_shared/postcss.config.mjs +5 -0
- package/dist/templates/_shared/themes-registry.ts +38 -0
- package/dist/templates/_shared/tsconfig.json +23 -0
- package/dist/templates/blog/README.md +52 -0
- package/dist/templates/blog/index.ts +29 -0
- package/dist/templates/blog/manifest.ts +144 -0
- package/dist/templates/blog/pages/feed.ts +31 -0
- package/dist/templates/blog/pages/home.tsx +108 -0
- package/dist/templates/blog/pages/post.tsx +94 -0
- package/dist/templates/blog/pages/sitemap.ts +30 -0
- package/dist/templates/blog/pages/tag.tsx +76 -0
- package/dist/templates/blog/tokens.css +54 -0
- package/dist/templates/corporate/README.md +20 -0
- package/dist/templates/corporate/index.ts +25 -0
- package/dist/templates/corporate/manifest.ts +94 -0
- package/dist/templates/corporate/pages/feed.ts +29 -0
- package/dist/templates/corporate/pages/home.tsx +130 -0
- package/dist/templates/corporate/pages/post.tsx +96 -0
- package/dist/templates/corporate/pages/sitemap.ts +28 -0
- package/dist/templates/corporate/pages/tag.tsx +81 -0
- package/dist/templates/corporate/tokens.css +47 -0
- package/dist/templates/dads/README.md +35 -0
- package/dist/templates/dads/index.ts +25 -0
- package/dist/templates/dads/manifest.ts +84 -0
- package/dist/templates/dads/pages/feed.ts +29 -0
- package/dist/templates/dads/pages/home.tsx +126 -0
- package/dist/templates/dads/pages/post.tsx +102 -0
- package/dist/templates/dads/pages/sitemap.ts +28 -0
- package/dist/templates/dads/pages/tag.tsx +86 -0
- package/dist/templates/dads/tokens.css +67 -0
- package/dist/templates/docs/README.md +27 -0
- package/dist/templates/docs/index.ts +25 -0
- package/dist/templates/docs/manifest.ts +89 -0
- package/dist/templates/docs/pages/feed.ts +29 -0
- package/dist/templates/docs/pages/home.tsx +88 -0
- package/dist/templates/docs/pages/post.tsx +96 -0
- package/dist/templates/docs/pages/sitemap.ts +28 -0
- package/dist/templates/docs/pages/tag.tsx +79 -0
- package/dist/templates/docs/tokens.css +55 -0
- package/dist/templates/landing/README.md +25 -0
- package/dist/templates/landing/index.ts +25 -0
- package/dist/templates/landing/manifest.ts +118 -0
- package/dist/templates/landing/pages/feed.ts +31 -0
- package/dist/templates/landing/pages/home.tsx +123 -0
- package/dist/templates/landing/pages/post.tsx +95 -0
- package/dist/templates/landing/pages/sitemap.ts +28 -0
- package/dist/templates/landing/pages/tag.tsx +85 -0
- package/dist/templates/landing/tokens.css +47 -0
- package/dist/templates/minimal/README.md +52 -0
- package/dist/templates/minimal/index.ts +25 -0
- package/dist/templates/minimal/manifest.ts +35 -0
- package/dist/templates/minimal/pages/feed.ts +31 -0
- package/dist/templates/minimal/pages/home.tsx +44 -0
- package/dist/templates/minimal/pages/post.tsx +65 -0
- package/dist/templates/minimal/pages/sitemap.ts +30 -0
- package/dist/templates/minimal/pages/tag.tsx +46 -0
- package/dist/templates/minimal/tokens.css +46 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ampless contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# create-ampless
|
|
2
|
+
|
|
3
|
+
CLI scaffolding tool for [ampless](https://github.com/heavymoons/ampless) projects.
|
|
4
|
+
|
|
5
|
+
> **Pre-release / alpha.** Breaking changes possible in any minor version until v1.0. Use the `@alpha` tag (the `@latest` tag won't exist until v1.0).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx create-ampless@alpha
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The wizard walks you through:
|
|
12
|
+
|
|
13
|
+
1. Project name
|
|
14
|
+
2. Site name (used as the default `<title>` and OGP `siteName`)
|
|
15
|
+
3. Theme — `blog` for v0.1
|
|
16
|
+
4. Plugins — `seo`, `rss`, `webhook`
|
|
17
|
+
|
|
18
|
+
Output is a Next.js 15 (App Router) project with the AWS Amplify Gen 2 backend definitions, an admin panel at `/admin`, public blog at `/`, the chosen plugins pre-wired in `cms.config.ts`, and a `RUNBOOK.md` for operations notes.
|
|
19
|
+
|
|
20
|
+
## Next steps inside the generated project
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd my-project
|
|
24
|
+
npm install
|
|
25
|
+
npx ampx sandbox # provision AWS dev resources, generates amplify_outputs.json
|
|
26
|
+
npm run dev # http://localhost:3000
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Sign up at `/login` — the first registered user is automatically promoted to the `ampless-admin` Cognito group.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- Node.js >= 20
|
|
34
|
+
- AWS account + `aws configure` already set up (sandbox / pipeline-deploy talk to AWS directly)
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
[MIT](../../LICENSE)
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { spinner, outro, log } from "@clack/prompts";
|
|
5
|
+
import { existsSync as existsSync2 } from "fs";
|
|
6
|
+
import { resolve as resolve3 } from "path";
|
|
7
|
+
|
|
8
|
+
// src/prompts.ts
|
|
9
|
+
import * as p from "@clack/prompts";
|
|
10
|
+
async function runPrompts(argProjectName) {
|
|
11
|
+
p.intro("create-ampless");
|
|
12
|
+
const result = await p.group(
|
|
13
|
+
{
|
|
14
|
+
projectName: () => p.text({
|
|
15
|
+
message: "Project name",
|
|
16
|
+
placeholder: "my-ampless-site",
|
|
17
|
+
defaultValue: argProjectName ?? "my-ampless-site",
|
|
18
|
+
validate: (v) => {
|
|
19
|
+
if (!v.trim()) return "Project name is required";
|
|
20
|
+
if (!/^[a-z0-9-_]+$/.test(v)) return "Use lowercase letters, numbers, hyphens, underscores";
|
|
21
|
+
}
|
|
22
|
+
}),
|
|
23
|
+
siteName: () => p.text({
|
|
24
|
+
message: "Site display name",
|
|
25
|
+
placeholder: "My Blog",
|
|
26
|
+
defaultValue: "My Blog"
|
|
27
|
+
}),
|
|
28
|
+
// Multiple themes can ship side-by-side. The first selected is the
|
|
29
|
+
// default active theme; admins can switch per-site at runtime. Add
|
|
30
|
+
// / remove themes later by editing themes-registry.ts and
|
|
31
|
+
// themes/<name>/.
|
|
32
|
+
themes: () => p.multiselect({
|
|
33
|
+
message: "Themes to install (space to toggle)",
|
|
34
|
+
options: [
|
|
35
|
+
{ value: "blog", label: "Blog \u2014 neutral monochrome (shadcn default)" },
|
|
36
|
+
{ value: "minimal", label: "Minimal \u2014 soft blue accent on warm neutral" },
|
|
37
|
+
{ value: "landing", label: "Landing \u2014 hero-led marketing page" },
|
|
38
|
+
{ value: "corporate", label: "Corporate \u2014 slate / navy company site" },
|
|
39
|
+
{ value: "docs", label: "Docs \u2014 sidebar-led docs (tag-driven sections)" },
|
|
40
|
+
{ value: "dads", label: "DADS \u2014 Digital Agency Design System (Japanese government style)" }
|
|
41
|
+
],
|
|
42
|
+
initialValues: ["blog"],
|
|
43
|
+
required: true
|
|
44
|
+
}),
|
|
45
|
+
plugins: () => p.multiselect({
|
|
46
|
+
message: "Plugins (space to toggle)",
|
|
47
|
+
options: [
|
|
48
|
+
{ value: "seo", label: "SEO \u2014 meta tags, OGP, sitemap", hint: "recommended" },
|
|
49
|
+
{ value: "rss", label: "RSS \u2014 /feed.xml" },
|
|
50
|
+
{ value: "webhook", label: "Webhook \u2014 POST events to external URLs" }
|
|
51
|
+
],
|
|
52
|
+
initialValues: ["seo"],
|
|
53
|
+
required: false
|
|
54
|
+
})
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
onCancel: () => {
|
|
58
|
+
p.cancel("Cancelled.");
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
const themes = result.themes;
|
|
64
|
+
if (themes.length === 0) {
|
|
65
|
+
p.cancel("At least one theme must be selected.");
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const confirmed = await p.confirm({
|
|
69
|
+
message: `Create project "${result.projectName}"?`,
|
|
70
|
+
initialValue: true
|
|
71
|
+
});
|
|
72
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
73
|
+
p.cancel("Cancelled.");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
projectName: result.projectName,
|
|
78
|
+
siteName: result.siteName,
|
|
79
|
+
themes,
|
|
80
|
+
defaultTheme: themes[0],
|
|
81
|
+
plugins: result.plugins
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/scaffold.ts
|
|
86
|
+
import { cp, readFile, writeFile, readdir, mkdir, rm } from "fs/promises";
|
|
87
|
+
import { join, extname, resolve as resolve2 } from "path";
|
|
88
|
+
|
|
89
|
+
// src/templates.ts
|
|
90
|
+
import { existsSync } from "fs";
|
|
91
|
+
import { fileURLToPath } from "url";
|
|
92
|
+
import { resolve } from "path";
|
|
93
|
+
function resolveTemplatesDir() {
|
|
94
|
+
const distDir = resolve(fileURLToPath(import.meta.url), "..");
|
|
95
|
+
const bundled = resolve(distDir, "templates");
|
|
96
|
+
if (existsSync(bundled)) return bundled;
|
|
97
|
+
return resolve(distDir, "..", "..", "..", "templates");
|
|
98
|
+
}
|
|
99
|
+
var templatesDir = resolveTemplatesDir();
|
|
100
|
+
function sharedTemplateDir() {
|
|
101
|
+
return resolve(templatesDir, "_shared");
|
|
102
|
+
}
|
|
103
|
+
function templatePath(theme) {
|
|
104
|
+
return resolve(templatesDir, theme);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/scaffold.ts
|
|
108
|
+
var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
109
|
+
".json",
|
|
110
|
+
".md",
|
|
111
|
+
".ts",
|
|
112
|
+
".tsx",
|
|
113
|
+
".js",
|
|
114
|
+
".jsx",
|
|
115
|
+
".mjs",
|
|
116
|
+
".cjs",
|
|
117
|
+
".html",
|
|
118
|
+
".css",
|
|
119
|
+
".env",
|
|
120
|
+
".txt",
|
|
121
|
+
".yaml",
|
|
122
|
+
".yml",
|
|
123
|
+
".toml",
|
|
124
|
+
".gitignore"
|
|
125
|
+
]);
|
|
126
|
+
async function substituteFile(filePath, vars) {
|
|
127
|
+
const ext = extname(filePath) || filePath.endsWith(".gitignore") ? ".gitignore" : "";
|
|
128
|
+
if (!TEXT_EXTENSIONS.has(ext)) return;
|
|
129
|
+
const content = await readFile(filePath, "utf-8");
|
|
130
|
+
const replaced = content.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
|
|
131
|
+
if (replaced !== content) await writeFile(filePath, replaced, "utf-8");
|
|
132
|
+
}
|
|
133
|
+
async function substituteDir(dirPath, vars) {
|
|
134
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
135
|
+
await Promise.all(
|
|
136
|
+
entries.map(async (entry) => {
|
|
137
|
+
const fullPath = join(dirPath, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
await substituteDir(fullPath, vars);
|
|
140
|
+
} else {
|
|
141
|
+
await substituteFile(fullPath, vars);
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
function buildRegistry(themes) {
|
|
147
|
+
const imports = themes.map((t) => `import ${t} from '@/themes/${t}'`).join("\n");
|
|
148
|
+
const map = themes.map((t) => ` ${t},`).join("\n");
|
|
149
|
+
return `// Generated by create-ampless. Lists every theme installed under
|
|
150
|
+
// \`themes/<name>/\`. Adding a theme = drop a directory under themes/,
|
|
151
|
+
// add an import + map entry below, and redeploy.
|
|
152
|
+
//
|
|
153
|
+
// \`theme.active\` (per-site, in KvStore) picks which theme renders for
|
|
154
|
+
// each request; everything listed here gets bundled so switching is
|
|
155
|
+
// instant.
|
|
156
|
+
|
|
157
|
+
${imports}
|
|
158
|
+
|
|
159
|
+
export const themes = {
|
|
160
|
+
${map}
|
|
161
|
+
} as const
|
|
162
|
+
|
|
163
|
+
export type ThemeName = keyof typeof themes
|
|
164
|
+
|
|
165
|
+
export const themeList = Object.values(themes)
|
|
166
|
+
|
|
167
|
+
export const DEFAULT_THEME: ThemeName = (themeList[0]?.name as ThemeName) ?? '${themes[0]}'
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
async function scaffold(sharedDir, themesRoot, destDir, opts) {
|
|
171
|
+
await cp(sharedDir, destDir, { recursive: true });
|
|
172
|
+
const destThemesDir = resolve2(destDir, "themes");
|
|
173
|
+
await rm(destThemesDir, { recursive: true, force: true });
|
|
174
|
+
await mkdir(destThemesDir, { recursive: true });
|
|
175
|
+
for (const theme of opts.themes) {
|
|
176
|
+
const src = templatePath(theme);
|
|
177
|
+
const dst = resolve2(destThemesDir, theme);
|
|
178
|
+
await cp(src, dst, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
void themesRoot;
|
|
181
|
+
const registryPath = resolve2(destDir, "themes-registry.ts");
|
|
182
|
+
await writeFile(registryPath, buildRegistry(opts.themes), "utf-8");
|
|
183
|
+
const vars = {
|
|
184
|
+
projectName: opts.projectName,
|
|
185
|
+
siteName: opts.siteName,
|
|
186
|
+
year: String((/* @__PURE__ */ new Date()).getFullYear()),
|
|
187
|
+
plugins: JSON.stringify(opts.plugins),
|
|
188
|
+
themes: JSON.stringify(opts.themes),
|
|
189
|
+
defaultTheme: opts.defaultTheme
|
|
190
|
+
};
|
|
191
|
+
await substituteDir(destDir, vars);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/index.ts
|
|
195
|
+
import pc from "picocolors";
|
|
196
|
+
async function main() {
|
|
197
|
+
const argProjectName = process.argv[2];
|
|
198
|
+
const opts = await runPrompts(argProjectName);
|
|
199
|
+
if (!opts) return;
|
|
200
|
+
const destDir = resolve3(process.cwd(), opts.projectName);
|
|
201
|
+
if (existsSync2(destDir)) {
|
|
202
|
+
log.error(`Directory already exists: ${destDir}`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
const sharedDir = sharedTemplateDir();
|
|
206
|
+
const s = spinner();
|
|
207
|
+
s.start("Scaffolding project...");
|
|
208
|
+
try {
|
|
209
|
+
await scaffold(sharedDir, templatesDir, destDir, opts);
|
|
210
|
+
s.stop("Done!");
|
|
211
|
+
} catch (err) {
|
|
212
|
+
s.stop("Failed.");
|
|
213
|
+
log.error(String(err));
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
outro(
|
|
217
|
+
`${pc.green("\u2714")} Project created at ${pc.bold(opts.projectName)}
|
|
218
|
+
|
|
219
|
+
Next steps:
|
|
220
|
+
${pc.cyan("cd")} ${opts.projectName}
|
|
221
|
+
${pc.cyan("npm install")}
|
|
222
|
+
${pc.cyan("npx ampx sandbox")} ${pc.dim("# start Amplify backend")}
|
|
223
|
+
${pc.cyan("npm run dev")} ${pc.dim("# start Next.js")}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
main().catch((err) => {
|
|
227
|
+
console.error(err);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Runbook
|
|
2
|
+
|
|
3
|
+
Operational tasks for an ampless-powered site.
|
|
4
|
+
|
|
5
|
+
## AppSync API key (auto-renewed)
|
|
6
|
+
|
|
7
|
+
Public blog reads (`listPublishedPosts`, `getPublishedPost`,
|
|
8
|
+
`listPostsByTag`) are gated by an AppSync API key. The key lives in
|
|
9
|
+
`amplify_outputs.json` and is therefore **visible to anyone visiting
|
|
10
|
+
the public site** — treat it as a low-trust credential. Its only
|
|
11
|
+
privilege is calling the three custom queries, which themselves only
|
|
12
|
+
return rows where `status === 'published'`.
|
|
13
|
+
|
|
14
|
+
### Why an API key (and not the Identity Pool guest role)?
|
|
15
|
+
|
|
16
|
+
Amplify Gen 2 `a.handler.custom` resolvers don't support `allow.guest()`
|
|
17
|
+
or `allow.authenticated('identityPool')` — only apiKey / userPool /
|
|
18
|
+
lambda / group / owner. v0.1 chose API key for simplicity; switching
|
|
19
|
+
the public reads to a Lambda function data source (`a.handler.function`)
|
|
20
|
+
is a v0.2 candidate.
|
|
21
|
+
|
|
22
|
+
### Auto-renewal — no rotation runbook required
|
|
23
|
+
|
|
24
|
+
The `api-key-renewer` Lambda (see `amplify/functions/api-key-renewer/`)
|
|
25
|
+
is invoked by an EventBridge schedule on the 1st of every month at
|
|
26
|
+
03:00 UTC. It calls `AppSync.UpdateApiKey` to push `expires` to
|
|
27
|
+
"now + 364 days" on the existing key, so:
|
|
28
|
+
|
|
29
|
+
- the key id never changes,
|
|
30
|
+
- `amplify_outputs.json` stays valid,
|
|
31
|
+
- the Next.js app does not need to be rebuilt,
|
|
32
|
+
- at any moment, the key has at least ~334 days of remaining validity.
|
|
33
|
+
|
|
34
|
+
If you want to inspect or trigger it manually:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# verify current expiry
|
|
38
|
+
aws appsync list-api-keys \
|
|
39
|
+
--region <region-from-amplify_outputs.json:data.aws_region> \
|
|
40
|
+
--api-id <api-id-derived-from-amplify_outputs.json:data.url>
|
|
41
|
+
|
|
42
|
+
# manual run (e.g. after a long sandbox pause)
|
|
43
|
+
aws lambda invoke \
|
|
44
|
+
--function-name $(aws lambda list-functions \
|
|
45
|
+
--query "Functions[?contains(FunctionName,'api-key-renewer')].FunctionName | [0]" \
|
|
46
|
+
--output text) \
|
|
47
|
+
/tmp/out.json && cat /tmp/out.json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### If a key is suspected leaked
|
|
51
|
+
|
|
52
|
+
Immediate response is to rotate the key value (not just push expiry):
|
|
53
|
+
|
|
54
|
+
1. In `amplify/data/resource.ts`, edit a comment to force a CFN update
|
|
55
|
+
2. Run `npx ampx sandbox` (sandbox) or `npx ampx pipeline-deploy ...`
|
|
56
|
+
(production) — Amplify regenerates the key value
|
|
57
|
+
3. Re-deploy the Next.js app so SSR picks up the new `data.api_key`
|
|
58
|
+
|
|
59
|
+
## Common operations
|
|
60
|
+
|
|
61
|
+
### Promote / demote a user
|
|
62
|
+
|
|
63
|
+
Use the AWS Cognito console:
|
|
64
|
+
|
|
65
|
+
1. User Pool → Users → pick the user
|
|
66
|
+
2. Group memberships → Add to / remove from group
|
|
67
|
+
3. Have the user sign out and back in for the new claims to apply
|
|
68
|
+
|
|
69
|
+
Groups: `ampless-admin` (full CRUD + ops), `ampless-editor` (content
|
|
70
|
+
CRUD), `ampless-reader` (reserved for future REST/MCP API consumers).
|
|
71
|
+
|
|
72
|
+
### Reset a user's password (admin override)
|
|
73
|
+
|
|
74
|
+
If someone is locked out and email-based recovery isn't an option:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
aws cognito-idp admin-set-user-password \
|
|
78
|
+
--user-pool-id <pool-id-from-amplify_outputs.json:auth.user_pool_id> \
|
|
79
|
+
--region <region> \
|
|
80
|
+
--username <email> \
|
|
81
|
+
--password '<new-password>' --permanent
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The `/login` page also has a self-service "Forgot password?" flow.
|
|
85
|
+
|
|
86
|
+
### Restore from a Post-table backup
|
|
87
|
+
|
|
88
|
+
DynamoDB Point-in-Time Recovery is **not** enabled by `defineData` in
|
|
89
|
+
v0.1; turn it on manually via AWS Console → DynamoDB → Tables →
|
|
90
|
+
`<your post table>` → Backups → Edit PITR. Once enabled, restoration
|
|
91
|
+
takes the form `aws dynamodb restore-table-to-point-in-time` to a new
|
|
92
|
+
table; you'll need to migrate items back to the live table afterwards.
|
|
93
|
+
|
|
94
|
+
### Inspect failed plugin events
|
|
95
|
+
|
|
96
|
+
Failed processor invocations land in the shared events DLQ created in
|
|
97
|
+
`amplify/backend.ts` (`EventsDlq`). View messages via the SQS console
|
|
98
|
+
or `aws sqs receive-message --queue-url <dlq-url> --max-number-of-messages 10`.
|
|
99
|
+
There's no automated alarm in v0.1 — periodic manual checks recommended,
|
|
100
|
+
or wire up a CloudWatch alarm on `ApproximateNumberOfMessagesVisible`.
|
|
101
|
+
|
|
102
|
+
## Multi-site / custom domains
|
|
103
|
+
|
|
104
|
+
ampless can serve multiple sites from one Amplify Hosting deployment.
|
|
105
|
+
Each site is identified by a `siteId` and bound to one or more
|
|
106
|
+
hostnames via `cms.config.ts`:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
sites: {
|
|
110
|
+
blog: {
|
|
111
|
+
domains: ['blog.example.com', 'www.example.com'],
|
|
112
|
+
name: 'My Blog',
|
|
113
|
+
url: 'https://blog.example.com',
|
|
114
|
+
},
|
|
115
|
+
docs: {
|
|
116
|
+
domains: ['docs.example.com'],
|
|
117
|
+
name: 'Docs',
|
|
118
|
+
url: 'https://docs.example.com',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The middleware (`middleware.ts`) maps incoming `Host` to a `siteId` and
|
|
124
|
+
internally rewrites the path to `/_sites/{siteId}/...`. Subdomains and
|
|
125
|
+
fully separate domains are equivalent at the application layer — only
|
|
126
|
+
the AWS-side wiring differs.
|
|
127
|
+
|
|
128
|
+
### Single domain operation
|
|
129
|
+
|
|
130
|
+
If `sites` is undefined or has only one entry, ampless runs in
|
|
131
|
+
single-site mode (`siteId='default'`). SSR responses follow each page's
|
|
132
|
+
own caching directives (so you can opt into CloudFront caching with
|
|
133
|
+
`Cache-Control: public, s-maxage=...` per route).
|
|
134
|
+
|
|
135
|
+
### Multi-site mode caveat: SSR caching is force-disabled
|
|
136
|
+
|
|
137
|
+
When two or more sites are declared, the middleware adds
|
|
138
|
+
`Cache-Control: private, no-store` to every public response. This is
|
|
139
|
+
because Amplify Hosting's CloudFront does not include `Host` in its
|
|
140
|
+
cache key — leaving caching on would let `https://site1/foo` and
|
|
141
|
+
`https://site2/foo` cross-contaminate at the edge. The trade-off is
|
|
142
|
+
that every public read hits Lambda. Lifting it requires moving off
|
|
143
|
+
Amplify Hosting onto a self-managed CloudFront + Open Next stack
|
|
144
|
+
(roadmap: post-v1.0).
|
|
145
|
+
|
|
146
|
+
### Adding a custom domain to Amplify Hosting
|
|
147
|
+
|
|
148
|
+
For each domain you want to bind:
|
|
149
|
+
|
|
150
|
+
1. **Amplify Hosting console** → your app → **Domain management** →
|
|
151
|
+
**Add domain**.
|
|
152
|
+
2. Enter the apex domain (`example.com`) and the subdomains you want
|
|
153
|
+
to attach. Amplify provisions an ACM certificate and a CloudFront
|
|
154
|
+
SAN entry automatically.
|
|
155
|
+
3. Update DNS:
|
|
156
|
+
- **Same DNS provider as Route 53 / Amplify managed**: Amplify
|
|
157
|
+
creates the CNAMEs for you, just confirm.
|
|
158
|
+
- **External DNS** (Cloudflare, Squarespace, etc.): Amplify shows
|
|
159
|
+
CNAME / DNS verification records to copy. ACM email-validation
|
|
160
|
+
also works as a fallback.
|
|
161
|
+
4. Wait for the **Domain activation** to finish (typically 15–60
|
|
162
|
+
minutes; certificate validation is the slow step).
|
|
163
|
+
5. Add the new domain to the matching `sites.{id}.domains[]` in
|
|
164
|
+
`cms.config.ts` and redeploy:
|
|
165
|
+
```bash
|
|
166
|
+
git add cms.config.ts && git commit -m "feat: add docs.example.com"
|
|
167
|
+
git push # Amplify Hosting picks it up
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Verify end-to-end:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
curl -I https://docs.example.com/ # 200 with the docs site's HTML
|
|
174
|
+
curl -sI https://docs.example.com/ | grep -i cache # Cache-Control: private, no-store
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
If the request returns `404 Site not found` instead, the host is not
|
|
178
|
+
listed in any `sites.*.domains[]` — fix the config and redeploy.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineAmplessAuth } from '@ampless/backend'
|
|
2
|
+
import { postConfirmation } from './post-confirmation/resource.js'
|
|
3
|
+
|
|
4
|
+
// Provisions a Cognito User Pool + Identity Pool with the three role
|
|
5
|
+
// groups (ampless-admin, ampless-editor, ampless-reader) and wires in
|
|
6
|
+
// the post-confirmation Lambda that promotes the first confirmed user
|
|
7
|
+
// to ampless-admin.
|
|
8
|
+
export const auth = defineAmplessAuth({ postConfirmation })
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineAmplessBackend } from '@ampless/backend'
|
|
2
|
+
|
|
3
|
+
import { auth } from './auth/resource.js'
|
|
4
|
+
import { data } from './data/resource.js'
|
|
5
|
+
import { storage } from './storage/resource.js'
|
|
6
|
+
import { postConfirmation } from './auth/post-confirmation/resource.js'
|
|
7
|
+
import { eventDispatcher } from './events/dispatcher/resource.js'
|
|
8
|
+
import { processorTrusted } from './events/processor-trusted/resource.js'
|
|
9
|
+
import { processorUntrusted } from './events/processor-untrusted/resource.js'
|
|
10
|
+
import { apiKeyRenewer } from './functions/api-key-renewer/resource.js'
|
|
11
|
+
|
|
12
|
+
// `defineAmplessBackend` provisions auth, data, storage, the event
|
|
13
|
+
// system (DynamoDB Streams → SQS-trusted / SQS-untrusted → trust_level
|
|
14
|
+
// Lambdas), the AppSync API key renewer, and every IAM / CORS /
|
|
15
|
+
// password policy override. Add custom CDK constructs / IAM policies
|
|
16
|
+
// below by mutating the returned `backend` — `defineAmplessBackend`
|
|
17
|
+
// returns the same object Amplify Gen 2's `defineBackend` does.
|
|
18
|
+
const backend = defineAmplessBackend({
|
|
19
|
+
auth,
|
|
20
|
+
data,
|
|
21
|
+
storage,
|
|
22
|
+
postConfirmation,
|
|
23
|
+
eventDispatcher,
|
|
24
|
+
processorTrusted,
|
|
25
|
+
processorUntrusted,
|
|
26
|
+
apiKeyRenewer,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export default backend
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { util } from '@aws-appsync/utils'
|
|
2
|
+
|
|
3
|
+
// AppSync JS resolver: returns a single published post by slug.
|
|
4
|
+
//
|
|
5
|
+
// Reads the `bySiteIdSlug` GSI: PK = `${siteId}#${slug}`. A given
|
|
6
|
+
// (site, slug) tuple identifies at most one row, so this is an O(1)
|
|
7
|
+
// PK Query — no scan, no filter, no per-partition limit issues.
|
|
8
|
+
//
|
|
9
|
+
// Drafts are dropped in the response handler. The admin form
|
|
10
|
+
// enforces a unique slug per site at the application layer, but if
|
|
11
|
+
// somehow draft + published share a slug we prefer the published row.
|
|
12
|
+
export function request(ctx) {
|
|
13
|
+
const siteId = ctx.args.siteId ?? 'default'
|
|
14
|
+
const slug = ctx.args.slug
|
|
15
|
+
const partition = `${siteId}#${slug}`
|
|
16
|
+
return {
|
|
17
|
+
operation: 'Query',
|
|
18
|
+
index: 'bySiteIdSlug',
|
|
19
|
+
query: {
|
|
20
|
+
expression: '#siteIdSlug = :siteIdSlug',
|
|
21
|
+
expressionNames: { '#siteIdSlug': 'siteIdSlug' },
|
|
22
|
+
expressionValues: util.dynamodb.toMapValues({ ':siteIdSlug': partition }),
|
|
23
|
+
},
|
|
24
|
+
limit: 5,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function response(ctx) {
|
|
29
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type)
|
|
30
|
+
const items = ctx.result.items ?? []
|
|
31
|
+
const published = items.find((i) => i.status === 'published')
|
|
32
|
+
return published ?? null
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { util } from '@aws-appsync/utils'
|
|
2
|
+
|
|
3
|
+
// AppSync JS resolver: list published posts for a given tag, newest first.
|
|
4
|
+
// Reads the denormalized PostTag table where:
|
|
5
|
+
// PK = `${siteId}#${tag}`
|
|
6
|
+
// SK = `${publishedAt}#${postId}` (so descending SK = newest first)
|
|
7
|
+
//
|
|
8
|
+
// Authorization is enforced by AppSync; the resolver itself only encodes
|
|
9
|
+
// the tag/site partition condition. Drafts never appear here because the
|
|
10
|
+
// admin client only writes PostTag rows for posts whose status is
|
|
11
|
+
// 'published'.
|
|
12
|
+
export function request(ctx) {
|
|
13
|
+
const { siteId = 'default', tag, limit, nextToken } = ctx.args
|
|
14
|
+
return {
|
|
15
|
+
operation: 'Query',
|
|
16
|
+
query: {
|
|
17
|
+
expression: '#siteIdTag = :siteIdTag',
|
|
18
|
+
expressionNames: { '#siteIdTag': 'siteIdTag' },
|
|
19
|
+
expressionValues: util.dynamodb.toMapValues({
|
|
20
|
+
':siteIdTag': `${siteId}#${tag}`,
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
scanIndexForward: false, // newest first (SK descends)
|
|
24
|
+
limit: limit ?? 20,
|
|
25
|
+
nextToken: nextToken ?? undefined,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function response(ctx) {
|
|
30
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type)
|
|
31
|
+
|
|
32
|
+
// PostTag rows are summary records (no `body`). Map them to the same
|
|
33
|
+
// PublicPost shape `listPublishedPosts` returns; the detail view should
|
|
34
|
+
// call `getPublishedPost(slug)` for the full body.
|
|
35
|
+
const items = (ctx.result.items ?? []).map((row) => ({
|
|
36
|
+
siteId: row.siteId,
|
|
37
|
+
postId: row.postId,
|
|
38
|
+
slug: row.slug,
|
|
39
|
+
title: row.title,
|
|
40
|
+
excerpt: row.excerpt ?? null,
|
|
41
|
+
format: 'markdown',
|
|
42
|
+
body: null,
|
|
43
|
+
status: 'published',
|
|
44
|
+
publishedAt: row.publishedAt,
|
|
45
|
+
tags: row.tags ?? [],
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
items,
|
|
50
|
+
nextToken: ctx.result.nextToken ?? null,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { util } from '@aws-appsync/utils'
|
|
2
|
+
|
|
3
|
+
// AppSync JS resolver: list a site's published posts, newest first.
|
|
4
|
+
//
|
|
5
|
+
// Reads the `bySiteIdStatus` GSI:
|
|
6
|
+
// PK = `${siteId}#${status}` (denormalized field set by writers)
|
|
7
|
+
// SK = publishedAt
|
|
8
|
+
// so a single Query reads only one site's published partition. Drafts
|
|
9
|
+
// never appear because the PK condition pins status='published'.
|
|
10
|
+
//
|
|
11
|
+
// Date-range filtering (`from`, `to`) is pushed into the SK condition,
|
|
12
|
+
// so DynamoDB only reads the matching range. `nextToken` paginates
|
|
13
|
+
// without re-issuing a fresh query.
|
|
14
|
+
export function request(ctx) {
|
|
15
|
+
const { siteId = 'default', from, to, limit, nextToken } = ctx.args
|
|
16
|
+
|
|
17
|
+
const partition = `${siteId}#published`
|
|
18
|
+
let keyExpression = '#siteIdStatus = :siteIdStatus'
|
|
19
|
+
const expressionNames = { '#siteIdStatus': 'siteIdStatus' }
|
|
20
|
+
const expressionValueMap = { ':siteIdStatus': partition }
|
|
21
|
+
|
|
22
|
+
if (from && to) {
|
|
23
|
+
keyExpression += ' AND #publishedAt BETWEEN :from AND :to'
|
|
24
|
+
expressionNames['#publishedAt'] = 'publishedAt'
|
|
25
|
+
expressionValueMap[':from'] = from
|
|
26
|
+
expressionValueMap[':to'] = to
|
|
27
|
+
} else if (from) {
|
|
28
|
+
keyExpression += ' AND #publishedAt >= :from'
|
|
29
|
+
expressionNames['#publishedAt'] = 'publishedAt'
|
|
30
|
+
expressionValueMap[':from'] = from
|
|
31
|
+
} else if (to) {
|
|
32
|
+
keyExpression += ' AND #publishedAt <= :to'
|
|
33
|
+
expressionNames['#publishedAt'] = 'publishedAt'
|
|
34
|
+
expressionValueMap[':to'] = to
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
operation: 'Query',
|
|
39
|
+
index: 'bySiteIdStatus',
|
|
40
|
+
query: {
|
|
41
|
+
expression: keyExpression,
|
|
42
|
+
expressionNames,
|
|
43
|
+
expressionValues: util.dynamodb.toMapValues(expressionValueMap),
|
|
44
|
+
},
|
|
45
|
+
scanIndexForward: false, // newest first
|
|
46
|
+
limit: limit ?? 20,
|
|
47
|
+
nextToken: nextToken ?? undefined,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function response(ctx) {
|
|
52
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type)
|
|
53
|
+
return {
|
|
54
|
+
items: ctx.result.items ?? [],
|
|
55
|
+
nextToken: ctx.result.nextToken ?? null,
|
|
56
|
+
}
|
|
57
|
+
}
|