create-shapio 0.2.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.
Files changed (161) hide show
  1. package/LICENSE +201 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +311 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +37 -0
  6. package/templates/astro/.env.example +10 -0
  7. package/templates/astro/README.md +31 -0
  8. package/templates/astro/astro.config.mjs +50 -0
  9. package/templates/astro/gitignore +4 -0
  10. package/templates/astro/package.json +31 -0
  11. package/templates/astro/public/favicon.svg +9 -0
  12. package/templates/astro/public/robots.txt +3 -0
  13. package/templates/astro/scripts/incrementalBuild.ts +142 -0
  14. package/templates/astro/scripts/lib/admin.ts +76 -0
  15. package/templates/astro/scripts/lib/callback.ts +46 -0
  16. package/templates/astro/scripts/lib/content.ts +462 -0
  17. package/templates/astro/scripts/lib/png.ts +47 -0
  18. package/templates/astro/scripts/reportStatus.ts +40 -0
  19. package/templates/astro/scripts/seed.ts +396 -0
  20. package/templates/astro/scripts/smoke.ts +127 -0
  21. package/templates/astro/scripts/smokeHttp.ts +162 -0
  22. package/templates/astro/shapio/components/callToAction.json +85 -0
  23. package/templates/astro/shapio/components/feature.json +46 -0
  24. package/templates/astro/shapio/components/featureGrid.json +51 -0
  25. package/templates/astro/shapio/components/gallery.json +52 -0
  26. package/templates/astro/shapio/components/hero.json +108 -0
  27. package/templates/astro/shapio/models/article.json +150 -0
  28. package/templates/astro/shapio/models/author.json +73 -0
  29. package/templates/astro/shapio/models/page.json +93 -0
  30. package/templates/astro/shapio/models/siteSettings.json +87 -0
  31. package/templates/astro/src/layouts/Base.astro +52 -0
  32. package/templates/astro/src/lib/config.ts +42 -0
  33. package/templates/astro/src/lib/preview.ts +57 -0
  34. package/templates/astro/src/lib/render.ts +146 -0
  35. package/templates/astro/src/lib/shapio.ts +104 -0
  36. package/templates/astro/src/lib/site.ts +55 -0
  37. package/templates/astro/src/lib/types.ts +96 -0
  38. package/templates/astro/src/pages/404.astro +8 -0
  39. package/templates/astro/src/pages/[locale]/[...slug].astro +28 -0
  40. package/templates/astro/src/pages/[locale]/articles/[slug].astro +25 -0
  41. package/templates/astro/src/pages/[locale]/articles/index.astro +22 -0
  42. package/templates/astro/src/pages/[locale]/colophon.astro +19 -0
  43. package/templates/astro/src/pages/build.json.ts +9 -0
  44. package/templates/astro/src/pages/index.astro +18 -0
  45. package/templates/astro/src/pages/preview.astro +21 -0
  46. package/templates/astro/src/scripts/preview.ts +47 -0
  47. package/templates/astro/src/styles/site.css +199 -0
  48. package/templates/astro/tsconfig.json +17 -0
  49. package/templates/next/.env.example +15 -0
  50. package/templates/next/README.md +69 -0
  51. package/templates/next/gitignore +5 -0
  52. package/templates/next/next.config.ts +69 -0
  53. package/templates/next/package.json +34 -0
  54. package/templates/next/public/favicon.svg +9 -0
  55. package/templates/next/scripts/lib/admin.ts +76 -0
  56. package/templates/next/scripts/lib/content.ts +462 -0
  57. package/templates/next/scripts/lib/png.ts +47 -0
  58. package/templates/next/scripts/seed.ts +396 -0
  59. package/templates/next/scripts/smokeHttp.ts +162 -0
  60. package/templates/next/shapio/components/callToAction.json +85 -0
  61. package/templates/next/shapio/components/feature.json +46 -0
  62. package/templates/next/shapio/components/featureGrid.json +51 -0
  63. package/templates/next/shapio/components/gallery.json +52 -0
  64. package/templates/next/shapio/components/hero.json +108 -0
  65. package/templates/next/shapio/models/article.json +150 -0
  66. package/templates/next/shapio/models/author.json +73 -0
  67. package/templates/next/shapio/models/page.json +93 -0
  68. package/templates/next/shapio/models/siteSettings.json +87 -0
  69. package/templates/next/src/app/[locale]/[[...slug]]/page.tsx +40 -0
  70. package/templates/next/src/app/[locale]/articles/[slug]/page.tsx +37 -0
  71. package/templates/next/src/app/[locale]/articles/page.tsx +28 -0
  72. package/templates/next/src/app/[locale]/colophon/page.tsx +25 -0
  73. package/templates/next/src/app/[locale]/layout.tsx +77 -0
  74. package/templates/next/src/app/api/revalidate/route.ts +31 -0
  75. package/templates/next/src/app/preview/layout.tsx +20 -0
  76. package/templates/next/src/app/preview/page.tsx +6 -0
  77. package/templates/next/src/app/site.css +199 -0
  78. package/templates/next/src/components/ArticleCard/index.tsx +19 -0
  79. package/templates/next/src/components/ArticleView/index.tsx +40 -0
  80. package/templates/next/src/components/Byline/index.tsx +15 -0
  81. package/templates/next/src/components/LocaleSwitch/index.tsx +16 -0
  82. package/templates/next/src/components/Preview/hooks/usePreviewDraft.ts +53 -0
  83. package/templates/next/src/components/Preview/index.tsx +33 -0
  84. package/templates/next/src/components/ResponsiveImage/index.tsx +36 -0
  85. package/templates/next/src/components/RichText/index.tsx +13 -0
  86. package/templates/next/src/components/Sections/CallToAction/index.tsx +14 -0
  87. package/templates/next/src/components/Sections/FeatureGrid/index.tsx +17 -0
  88. package/templates/next/src/components/Sections/Gallery/index.tsx +20 -0
  89. package/templates/next/src/components/Sections/Hero/index.tsx +31 -0
  90. package/templates/next/src/components/Sections/SectionView/index.tsx +30 -0
  91. package/templates/next/src/components/Sections/index.tsx +19 -0
  92. package/templates/next/src/lib/articles.ts +5 -0
  93. package/templates/next/src/lib/config.ts +57 -0
  94. package/templates/next/src/lib/liveSnapshot.ts +74 -0
  95. package/templates/next/src/lib/log.ts +4 -0
  96. package/templates/next/src/lib/preview.ts +56 -0
  97. package/templates/next/src/lib/revalidation.ts +91 -0
  98. package/templates/next/src/lib/revalidationTargets.test.ts +67 -0
  99. package/templates/next/src/lib/revalidationTargets.ts +66 -0
  100. package/templates/next/src/lib/shapio.ts +75 -0
  101. package/templates/next/src/lib/site.ts +66 -0
  102. package/templates/next/src/lib/types.ts +96 -0
  103. package/templates/next/src/lib/webhookEvent.test.ts +61 -0
  104. package/templates/next/src/lib/webhookEvent.ts +47 -0
  105. package/templates/next/src/styles.d.ts +3 -0
  106. package/templates/next/tsconfig.json +23 -0
  107. package/templates/sveltekit/.env.example +10 -0
  108. package/templates/sveltekit/README.md +38 -0
  109. package/templates/sveltekit/gitignore +4 -0
  110. package/templates/sveltekit/package.json +38 -0
  111. package/templates/sveltekit/scripts/lib/admin.ts +76 -0
  112. package/templates/sveltekit/scripts/lib/content.ts +462 -0
  113. package/templates/sveltekit/scripts/lib/png.ts +47 -0
  114. package/templates/sveltekit/scripts/seed.ts +396 -0
  115. package/templates/sveltekit/scripts/smokeHttp.ts +162 -0
  116. package/templates/sveltekit/shapio/components/callToAction.json +85 -0
  117. package/templates/sveltekit/shapio/components/feature.json +46 -0
  118. package/templates/sveltekit/shapio/components/featureGrid.json +51 -0
  119. package/templates/sveltekit/shapio/components/gallery.json +52 -0
  120. package/templates/sveltekit/shapio/components/hero.json +108 -0
  121. package/templates/sveltekit/shapio/models/article.json +150 -0
  122. package/templates/sveltekit/shapio/models/author.json +73 -0
  123. package/templates/sveltekit/shapio/models/page.json +93 -0
  124. package/templates/sveltekit/shapio/models/siteSettings.json +87 -0
  125. package/templates/sveltekit/src/app.html +12 -0
  126. package/templates/sveltekit/src/env.ts +16 -0
  127. package/templates/sveltekit/src/hooks.server.ts +9 -0
  128. package/templates/sveltekit/src/lib/articles.ts +5 -0
  129. package/templates/sveltekit/src/lib/components/ArticleCard.svelte +21 -0
  130. package/templates/sveltekit/src/lib/components/ArticleView.svelte +43 -0
  131. package/templates/sveltekit/src/lib/components/Byline.svelte +14 -0
  132. package/templates/sveltekit/src/lib/components/CallToAction.svelte +15 -0
  133. package/templates/sveltekit/src/lib/components/FeatureGrid.svelte +22 -0
  134. package/templates/sveltekit/src/lib/components/Gallery.svelte +25 -0
  135. package/templates/sveltekit/src/lib/components/Hero.svelte +24 -0
  136. package/templates/sveltekit/src/lib/components/ResponsiveImage.svelte +30 -0
  137. package/templates/sveltekit/src/lib/components/RichText.svelte +13 -0
  138. package/templates/sveltekit/src/lib/components/Sections.svelte +30 -0
  139. package/templates/sveltekit/src/lib/preview.ts +56 -0
  140. package/templates/sveltekit/src/lib/server/config.ts +46 -0
  141. package/templates/sveltekit/src/lib/server/shapio.ts +93 -0
  142. package/templates/sveltekit/src/lib/site.css +199 -0
  143. package/templates/sveltekit/src/lib/site.ts +66 -0
  144. package/templates/sveltekit/src/lib/types.ts +96 -0
  145. package/templates/sveltekit/src/routes/+layout.ts +3 -0
  146. package/templates/sveltekit/src/routes/+page.ts +7 -0
  147. package/templates/sveltekit/src/routes/[locale]/+layout.server.ts +16 -0
  148. package/templates/sveltekit/src/routes/[locale]/+layout.svelte +32 -0
  149. package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.server.ts +28 -0
  150. package/templates/sveltekit/src/routes/[locale]/[...slug]/+page.svelte +15 -0
  151. package/templates/sveltekit/src/routes/[locale]/articles/+page.server.ts +9 -0
  152. package/templates/sveltekit/src/routes/[locale]/articles/+page.svelte +17 -0
  153. package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.server.ts +25 -0
  154. package/templates/sveltekit/src/routes/[locale]/articles/[slug]/+page.svelte +15 -0
  155. package/templates/sveltekit/src/routes/[locale]/colophon/+page.svelte +17 -0
  156. package/templates/sveltekit/src/routes/[locale]/colophon/+page.ts +5 -0
  157. package/templates/sveltekit/src/routes/preview/+page.server.ts +5 -0
  158. package/templates/sveltekit/src/routes/preview/+page.svelte +67 -0
  159. package/templates/sveltekit/static/favicon.svg +9 -0
  160. package/templates/sveltekit/tsconfig.json +14 -0
  161. package/templates/sveltekit/vite.config.ts +63 -0
@@ -0,0 +1,50 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { defineConfig } from 'astro/config';
3
+
4
+ // `.env` (written by `npm run seed`) for the settings this file reads; the environment wins.
5
+ try {
6
+ process.loadEnvFile();
7
+ } catch {
8
+ // No .env: the environment alone.
9
+ }
10
+
11
+ const originOf = (url) => {
12
+ try {
13
+ return new URL(url).origin;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ };
18
+
19
+ /**
20
+ * Only this site and Shapio may frame its pages (Shapio's preview pane shows them beside the document). Sent
21
+ * by `astro dev` and `astro preview`, and written to dist/_headers for hosts that read it (Cloudflare Pages,
22
+ * Netlify); set the same header on any other host.
23
+ */
24
+ const shapioOrigin = originOf(
25
+ process.env.PUBLIC_SHAPIO_URL || process.env.SHAPIO_URL || 'http://localhost:4300',
26
+ );
27
+ const FRAME_ANCESTORS = `frame-ancestors 'self'${shapioOrigin ? ` ${shapioOrigin}` : ''}`;
28
+
29
+ const frameAncestorsHeaders = {
30
+ name: 'shapio-frame-ancestors',
31
+ hooks: {
32
+ 'astro:build:done': async ({ dir }) => {
33
+ await writeFile(new URL('_headers', dir), `/*\n Content-Security-Policy: ${FRAME_ANCESTORS}\n`);
34
+ },
35
+ },
36
+ };
37
+
38
+ /**
39
+ * Static output: `npm run build` writes plain HTML to dist/ (any static host: Cloudflare Pages, Netlify, a
40
+ * bucket). SITE_URL sets absolute URLs (canonical links) when known. Astro's telemetry is turned off in this
41
+ * package's scripts (ASTRO_TELEMETRY_DISABLED=1).
42
+ */
43
+ export default defineConfig({
44
+ output: 'static',
45
+ site: process.env.SITE_URL || undefined,
46
+ trailingSlash: 'always',
47
+ build: { format: 'directory' },
48
+ server: { headers: { 'Content-Security-Policy': FRAME_ANCESTORS } },
49
+ integrations: [frameAncestorsHeaders],
50
+ });
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ dist/
3
+ .astro/
4
+ .env
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "example-astro",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Astro starter: a static site built from Shapio content (pinned snapshot, EN + FR, preview, signed build callbacks).",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "scripts": {
11
+ "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev",
12
+ "build": "ASTRO_TELEMETRY_DISABLED=1 astro build",
13
+ "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
14
+ "seed": "node --env-file-if-exists=.env --import tsx scripts/seed.ts --site-url http://localhost:4321",
15
+ "smoke": "node --env-file-if-exists=.env --import tsx scripts/smoke.ts",
16
+ "smoke:http": "node --import tsx scripts/smokeHttp.ts --url http://localhost:4321",
17
+ "report-status": "node --env-file-if-exists=.env --import tsx scripts/reportStatus.ts",
18
+ "build:incremental": "node --env-file-if-exists=.env --import tsx scripts/incrementalBuild.ts",
19
+ "typecheck": "tsc -p tsconfig.json"
20
+ },
21
+ "dependencies": {
22
+ "@shapio/client": "^0.2.0",
23
+ "@shapio/visual": "^0.2.0",
24
+ "astro": "7.3.5"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "24.19.1",
28
+ "tsx": "4.23.15",
29
+ "typescript": "6.0.3"
30
+ }
31
+ }
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Shapio">
2
+ <title>Shapio</title>
3
+ <rect width="64" height="64" rx="14" fill="#2563EB"/>
4
+ <g transform="translate(32 32) scale(0.72) translate(-32 -32)">
5
+ <path fill="#FAF9F6" d="M43.39 8.16L51.31 12.73A8.25 8.25 0 0 1 55.43 19.88L55.43 21.08A4.72 4.72 0 0 1 48.36 25.17L33.51 16.59ZM20.61 55.84L12.69 51.27A8.25 8.25 0 0 1 8.57 44.12L8.57 42.92A4.72 4.72 0 0 1 15.64 38.83L30.49 47.41Z"/>
6
+ <path fill="#FAF9F6" d="M30.23 5.67A8.84 8.84 0 0 1 39.07 5.67L47.52 10.54L26.62 22.61A3.24 3.24 0 0 0 25 25.42L25 26.92A2.65 2.65 0 0 0 26.33 29.21L29.37 30.97A1.47 1.47 0 0 1 29.37 33.52L24.24 36.48A3.24 3.24 0 0 1 21 36.48L15.27 33.17A10.61 10.61 0 0 1 9.97 23.98L9.97 23.49A10.61 10.61 0 0 1 15.27 14.3ZM33.77 58.33A8.84 8.84 0 0 1 24.93 58.33L16.48 53.46L37.38 41.39A3.24 3.24 0 0 0 39 38.58L39 37.08A2.65 2.65 0 0 0 37.67 34.79L34.63 33.03A1.47 1.47 0 0 1 34.63 30.48L39.76 27.52A3.24 3.24 0 0 1 43 27.52L48.73 30.83A10.61 10.61 0 0 1 54.03 40.02L54.03 40.51A10.61 10.61 0 0 1 48.73 49.7Z"/>
7
+ <path fill="none" stroke="#2563EB" stroke-width="1.1" d="M35.28 17.61L47.52 10.54M28.72 46.39L16.48 53.46"/>
8
+ </g>
9
+ </svg>
@@ -0,0 +1,3 @@
1
+ User-agent: *
2
+ Allow: /
3
+ Disallow: /preview/
@@ -0,0 +1,142 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { createClient, ShapioApiError, type SnapshotChange } from '@shapio/client';
5
+
6
+ /**
7
+ * `npm run build:incremental [--dry-run]`: builds only when content changed.
8
+ *
9
+ * It asks Shapio what changed between the snapshot the last build showed (`dist/build.json`, or
10
+ * SHAPIO_FROM_SNAPSHOT) and the current one (`/api/snapshots/changes`), prints the routes those changes
11
+ * touch, and runs `astro build` pinned to the new snapshot, or skips the build when nothing changed. A
12
+ * schema change between the two snapshots, or no previous build, means a full rebuild. The printed routes
13
+ * are what a server-rendered site would revalidate (documentation/snapshots.md shows `revalidatePath`).
14
+ *
15
+ * Settings: SHAPIO_URL and SHAPIO_DELIVERY_TOKEN (as for `npm run build`), SHAPIO_FROM_SNAPSHOT (optional).
16
+ */
17
+ const DIST_BUILD_INFO = resolve(import.meta.dirname, '../dist/build.json');
18
+
19
+ const requireEnv = (name: string): string => {
20
+ const value = process.env[name];
21
+ if (!value) {
22
+ throw new Error(`Set ${name} (npm run seed writes it to .env)`);
23
+ }
24
+ return value;
25
+ };
26
+
27
+ const client = createClient({
28
+ baseUrl: process.env.SHAPIO_URL ?? 'http://localhost:4300',
29
+ token: requireEnv('SHAPIO_DELIVERY_TOKEN'),
30
+ });
31
+
32
+ /** The snapshot the previous build showed, if there was one. */
33
+ const previousSnapshot = async (): Promise<number | undefined> => {
34
+ const configured = process.env.SHAPIO_FROM_SNAPSHOT;
35
+ if (configured) {
36
+ const value = Number(configured);
37
+ if (!Number.isInteger(value) || value < 0) {
38
+ throw new Error(`SHAPIO_FROM_SNAPSHOT must be a snapshot number, got "${configured}"`);
39
+ }
40
+ return value;
41
+ }
42
+ try {
43
+ const info = JSON.parse(await readFile(DIST_BUILD_INFO, 'utf8')) as { snapshot?: unknown };
44
+ return typeof info.snapshot === 'number' ? info.snapshot : undefined;
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
47
+ return undefined;
48
+ }
49
+ throw error;
50
+ }
51
+ };
52
+
53
+ /** The entry's slug in `locale` at `snapshot` (null when it is not there). */
54
+ const slugAt = async (change: SnapshotChange, locale: string, snapshot: number): Promise<string | null> => {
55
+ const query = new URLSearchParams({ locale, snapshot: String(snapshot), fields: 'slug' });
56
+ try {
57
+ const result = await client.request<{ data: { slug?: unknown } }>(
58
+ `/api/content/${change.routeKey}/${change.id}?${query.toString()}`,
59
+ );
60
+ return typeof result.data.slug === 'string' ? result.data.slug : null;
61
+ } catch (error) {
62
+ if (error instanceof ShapioApiError && error.status === 404) {
63
+ return null;
64
+ }
65
+ throw error;
66
+ }
67
+ };
68
+
69
+ /** Site routes (src/pages) an entry change touches. */
70
+ const routesOf = async (change: SnapshotChange, from: number, to: number): Promise<string[]> => {
71
+ const routes: string[] = [];
72
+ for (const { locale, change: kind } of change.locales) {
73
+ if (change.modelKey === 'author') {
74
+ routes.push(`/${locale}/articles/`);
75
+ continue;
76
+ }
77
+ // An unpublished entry's route is where it was; the others are where they are now.
78
+ const slug = await slugAt(change, locale, kind === 'unpublished' ? from : to);
79
+ if (slug === null) {
80
+ continue;
81
+ }
82
+ if (change.modelKey === 'page') {
83
+ routes.push(slug === 'home' ? `/${locale}/` : `/${locale}/${slug}/`);
84
+ } else if (change.modelKey === 'article') {
85
+ routes.push(`/${locale}/articles/${slug}/`, `/${locale}/articles/`);
86
+ }
87
+ }
88
+ return routes;
89
+ };
90
+
91
+ const runBuild = (snapshot: number) =>
92
+ new Promise<void>((resolveBuild, reject) => {
93
+ const child = spawn('npm', ['run', 'build'], {
94
+ cwd: resolve(import.meta.dirname, '..'),
95
+ stdio: 'inherit',
96
+ env: { ...process.env, SHAPIO_SNAPSHOT: String(snapshot) },
97
+ });
98
+ child.on('error', reject);
99
+ child.on('exit', (code) =>
100
+ code === 0 ? resolveBuild() : reject(new Error(`astro build exited with ${code}`)),
101
+ );
102
+ });
103
+
104
+ const main = async () => {
105
+ const dryRun = process.argv.includes('--dry-run');
106
+ const { snapshot: to } = await client.snapshots.current();
107
+ const from = await previousSnapshot();
108
+ const say = (line: string) => process.stdout.write(`${line}\n`);
109
+
110
+ if (from === undefined) {
111
+ say(`No previous build: building everything at snapshot ${to}.`);
112
+ } else if (from === to) {
113
+ say(`Nothing published since snapshot ${from}: skipping the build.`);
114
+ return;
115
+ } else {
116
+ const diff = await client.snapshots.allChanges({ from, to });
117
+ if (diff.items.length === 0) {
118
+ say(`Snapshots ${from} → ${to} change nothing this site reads: skipping the build.`);
119
+ return;
120
+ }
121
+ if (diff.schemaVersions.from !== diff.schemaVersions.to) {
122
+ say(`The schema changed between snapshots ${from} and ${to}: rebuilding every route.`);
123
+ } else {
124
+ const routes = new Set<string>();
125
+ for (const change of diff.items) {
126
+ (await routesOf(change, from, to)).forEach((route) => routes.add(route));
127
+ }
128
+ say(`Snapshots ${from} → ${to}: ${diff.items.length} changed entries; routes to refresh:`);
129
+ [...routes].sort().forEach((route) => say(` ${route}`));
130
+ }
131
+ }
132
+ if (dryRun) {
133
+ say('--dry-run: not building.');
134
+ return;
135
+ }
136
+ await runBuild(to);
137
+ };
138
+
139
+ main().catch((error: unknown) => {
140
+ process.stderr.write(`build:incremental: ${error instanceof Error ? error.message : String(error)}\n`);
141
+ process.exitCode = 1;
142
+ });
@@ -0,0 +1,76 @@
1
+ import { createClient, type ShapioClient } from '@shapio/client';
2
+
3
+ /**
4
+ * How the seed talks to Shapio's admin API: with SHAPIO_TOKEN (an admin API token), or with
5
+ * SHAPIO_ADMIN_EMAIL + SHAPIO_ADMIN_PASSWORD, in which case it signs in, creates a one-hour admin API token
6
+ * for itself (the schema CLI needs a bearer token) and revokes it when done.
7
+ */
8
+ export type AdminConnection = {
9
+ url: string;
10
+ token: string;
11
+ client: ShapioClient;
12
+ /** A signed-in admin session (email and password only): preview tokens are issued to people, not API tokens. */
13
+ session: ShapioClient | undefined;
14
+ close: () => Promise<void>;
15
+ };
16
+
17
+ const SESSION_COOKIE = 'shapio_session';
18
+ const SEED_TOKEN_TTL_MS = 60 * 60 * 1000;
19
+
20
+ const signIn = async (url: string, email: string, password: string) => {
21
+ const response = await fetch(`${url.replace(/\/+$/, '')}/api/admin/auth/login`, {
22
+ method: 'POST',
23
+ headers: { 'content-type': 'application/json' },
24
+ body: JSON.stringify({ email, password }),
25
+ });
26
+ if (!response.ok) {
27
+ throw new Error(`Signing in as ${email} failed: HTTP ${response.status} ${await response.text()}`);
28
+ }
29
+ const cookie = response.headers.getSetCookie().find((value) => value.startsWith(`${SESSION_COOKIE}=`));
30
+ const { csrfToken } = (await response.json()) as { csrfToken: string };
31
+ if (!cookie) {
32
+ throw new Error('Signing in returned no session cookie');
33
+ }
34
+ return createClient({
35
+ baseUrl: url,
36
+ headers: () => ({ cookie: cookie.split(';')[0] ?? '', 'x-csrf-token': csrfToken }),
37
+ });
38
+ };
39
+
40
+ export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnection> => {
41
+ const url = env.SHAPIO_URL ?? 'http://localhost:4300';
42
+ if (env.SHAPIO_TOKEN) {
43
+ return {
44
+ url,
45
+ token: env.SHAPIO_TOKEN,
46
+ client: createClient({ baseUrl: url, token: env.SHAPIO_TOKEN }),
47
+ session: undefined,
48
+ close: async () => undefined,
49
+ };
50
+ }
51
+ const email = env.SHAPIO_ADMIN_EMAIL;
52
+ const password = env.SHAPIO_ADMIN_PASSWORD;
53
+ if (!email || !password) {
54
+ throw new Error('Set SHAPIO_TOKEN (an admin API token), or SHAPIO_ADMIN_EMAIL and SHAPIO_ADMIN_PASSWORD');
55
+ }
56
+ const session = await signIn(url, email, password);
57
+ const roles = await session.admin.roles.list();
58
+ const admin = roles.find((role) => role.key === 'admin');
59
+ if (!admin) {
60
+ throw new Error('The built-in admin role is missing');
61
+ }
62
+ const created = await session.admin.tokens.create({
63
+ name: 'starter seed (temporary)',
64
+ roleId: admin.id,
65
+ // A network token: the seed applies models and adds locales, which are network actions.
66
+ network: true,
67
+ expiresAt: new Date(Date.now() + SEED_TOKEN_TTL_MS).toISOString(),
68
+ });
69
+ return {
70
+ url,
71
+ token: created.token,
72
+ client: createClient({ baseUrl: url, token: created.token }),
73
+ session,
74
+ close: () => session.admin.tokens.revoke(created.apiToken.id),
75
+ };
76
+ };
@@ -0,0 +1,46 @@
1
+ import { createHmac } from 'node:crypto';
2
+
3
+ /**
4
+ * Signed build callbacks for Shapio's generic deployment connection: after Shapio's signed trigger started
5
+ * a build (its body carries `runId`, `snapshot` and `callbackUrl`), the build reports `building`, then
6
+ * `deployed` or `failed`, to `callbackUrl` (`{SHAPIO_URL}/api/hooks/deployments/<connection id>`). The body
7
+ * is signed like Shapio's own webhooks: `X-Shapio-Signature: v1=<hex HMAC-SHA256(secret, "<ts>.<body>")>`
8
+ * with `X-Shapio-Timestamp: <unix seconds>`, using the connection's signing secret. Shapio only moves a run
9
+ * forward, so a late `building` never undoes `deployed`.
10
+ */
11
+ export type CallbackStatus = 'building' | 'deployed' | 'failed';
12
+
13
+ export const CALLBACK_STATUSES: readonly CallbackStatus[] = ['building', 'deployed', 'failed'];
14
+
15
+ export type CallbackReport = {
16
+ runId: string;
17
+ status: CallbackStatus;
18
+ siteUrl?: string;
19
+ logUrl?: string;
20
+ message?: string;
21
+ };
22
+
23
+ export const signCallback = (secret: string, body: string, now = new Date()) => {
24
+ const timestamp = Math.floor(now.getTime() / 1000);
25
+ const signature = createHmac('sha256', secret).update(`${timestamp}.${body}`, 'utf8').digest('hex');
26
+ return { 'x-shapio-timestamp': String(timestamp), 'x-shapio-signature': `v1=${signature}` };
27
+ };
28
+
29
+ /** Posts one signed report; resolves with Shapio's answer (`applied: false` for a late, older state). */
30
+ export const sendCallback = async (
31
+ callbackUrl: string,
32
+ secret: string,
33
+ report: CallbackReport,
34
+ ): Promise<{ applied: boolean; status: string }> => {
35
+ const body = JSON.stringify(report);
36
+ const response = await fetch(callbackUrl, {
37
+ method: 'POST',
38
+ headers: { 'content-type': 'application/json', ...signCallback(secret, body) },
39
+ body,
40
+ });
41
+ const text = await response.text();
42
+ if (!response.ok) {
43
+ throw new Error(`Shapio refused the callback: HTTP ${response.status} ${text}`);
44
+ }
45
+ return JSON.parse(text) as { applied: boolean; status: string };
46
+ };