create-shapio 0.2.0 → 0.3.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/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/astro/package.json +2 -2
- package/templates/astro/scripts/lib/admin.ts +13 -5
- package/templates/astro/scripts/seed.ts +65 -16
- package/templates/next/package.json +2 -2
- package/templates/next/scripts/lib/admin.ts +13 -5
- package/templates/next/scripts/seed.ts +65 -16
- package/templates/sveltekit/package.json +2 -2
- package/templates/sveltekit/scripts/lib/admin.ts +13 -5
- package/templates/sveltekit/scripts/seed.ts +65 -16
package/dist/index.js
CHANGED
|
@@ -17,10 +17,10 @@ const packageJson = ({ projectName, shapioSpec }) => `${JSON.stringify({
|
|
|
17
17
|
worker: "shapio worker",
|
|
18
18
|
status: "shapio status"
|
|
19
19
|
},
|
|
20
|
-
dependencies: { shapio: shapioSpec }
|
|
20
|
+
dependencies: { "@shapio/cms": shapioSpec }
|
|
21
21
|
}, null, 2)}\n`;
|
|
22
22
|
const env = ({ databaseUrl, sessionSecret }) => [
|
|
23
|
-
"# Shapio configuration. Every setting is an environment variable; `shapio` loads this file.",
|
|
23
|
+
"# Shapio configuration. Every setting is an environment variable; the `shapio` command loads this file.",
|
|
24
24
|
"# Real environment variables override values here.",
|
|
25
25
|
"NODE_ENV=production",
|
|
26
26
|
"HOST=127.0.0.1",
|
|
@@ -58,7 +58,7 @@ const shapioConfig = () => `// Project configuration for Shapio: code extensions
|
|
|
58
58
|
// with \`shapio schema pull\` / \`shapio schema apply\`. This file is code: changing it, or anything in
|
|
59
59
|
// ./extensions, needs a restart (never a rebuild). Check it with \`npx shapio extensions check\`.
|
|
60
60
|
// TypeScript works as is (erasable syntax only: no enums or namespaces). Guide: documentation/extensions.md in Shapio's repository.
|
|
61
|
-
import { defineConfig } from 'shapio/config';
|
|
61
|
+
import { defineConfig } from '@shapio/cms/config';
|
|
62
62
|
|
|
63
63
|
export const config = defineConfig({
|
|
64
64
|
// Lifecycle hooks by model API ID ('*' = every model). before* hooks run inside the write's transaction
|
|
@@ -97,7 +97,7 @@ module.exports = {
|
|
|
97
97
|
apps: [
|
|
98
98
|
{
|
|
99
99
|
name: ${JSON.stringify(projectName)},
|
|
100
|
-
script: 'node_modules
|
|
100
|
+
script: 'node_modules/@shapio/cms/dist/cli.js',
|
|
101
101
|
args: 'start',
|
|
102
102
|
exec_mode: 'fork',
|
|
103
103
|
instances: 1,
|
|
@@ -105,7 +105,7 @@ module.exports = {
|
|
|
105
105
|
env: { NODE_ENV: 'production' },
|
|
106
106
|
},
|
|
107
107
|
// Cluster variant (replace the app above): two processes sharing the port, each with its inline worker.
|
|
108
|
-
// { name: ${JSON.stringify(projectName)}, script: 'node_modules
|
|
108
|
+
// { name: ${JSON.stringify(projectName)}, script: 'node_modules/@shapio/cms/dist/cli.js', args: 'start', exec_mode: 'cluster', instances: 2, kill_timeout: 20000, env: { NODE_ENV: 'production' } },
|
|
109
109
|
],
|
|
110
110
|
};
|
|
111
111
|
`;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/templates.ts","../src/scaffold.ts","../src/starters.ts","../src/site.ts","../src/main.ts","../src/index.ts"],"sourcesContent":["export type TemplateInput = {\n projectName: string;\n shapioSpec: string;\n databaseUrl: string;\n sessionSecret: string;\n};\n\nconst packageJson = ({ projectName, shapioSpec }: TemplateInput) =>\n `${JSON.stringify(\n {\n name: projectName,\n private: true,\n type: 'module',\n engines: { node: '>=24' },\n scripts: {\n start: 'shapio start',\n migrate: 'shapio migrate',\n worker: 'shapio worker',\n status: 'shapio status',\n },\n dependencies: { shapio: shapioSpec },\n },\n null,\n 2,\n )}\\n`;\n\nconst env = ({ databaseUrl, sessionSecret }: TemplateInput) =>\n [\n '# Shapio configuration. Every setting is an environment variable; `shapio` loads this file.',\n '# Real environment variables override values here.',\n 'NODE_ENV=production',\n 'HOST=127.0.0.1',\n 'PORT=4300',\n 'LOG_LEVEL=info',\n '',\n '# The origin people reach Shapio at; every absolute URL is built from it. Change it when you deploy.',\n 'PUBLIC_URL=http://localhost:4300',\n '# BASE_PATH=/cms',\n '',\n '# HTTPS without a reverse proxy: set PUBLIC_URL=https://your.domain and PORT=443, and point these at your',\n '# certificate and key (PEM). Renewed files are picked up without a restart. HTTP_PORT=80 redirects to HTTPS.',\n '# TLS_CERT_FILE=/etc/shapio/tls/fullchain.pem',\n '# TLS_KEY_FILE=/etc/shapio/tls/privkey.pem',\n '# HTTP_PORT=80',\n '',\n '# PostgreSQL >= 16, or sqlite:<path> for a single-process install',\n `DATABASE_URL=${databaseUrl}`,\n 'MIGRATE_ON_START=true',\n '',\n '# Signs preview links, private-media URLs and app-user tokens, and encrypts stored secrets.',\n '# Generated for this project; keep it secret, and back it up with the database.',\n `SESSION_SECRET=${sessionSecret}`,\n '',\n '# SETUP_REQUIRE_TOKEN=true # require the logged one-time token for first-run setup (for installs exposed before setup)',\n '',\n '# inline: jobs run inside the server process. dedicated: run `npm run worker` as a second process.',\n 'WORKER_MODE=inline',\n '',\n 'MEDIA_PATH=./media',\n '',\n ].join('\\n');\n\nconst shapioConfig =\n () => `// Project configuration for Shapio: code extensions (hooks, custom routes, services, jobs) and custom\n// field editors. Content models are NOT configured here; they are created live in the admin UI and synced\n// with \\`shapio schema pull\\` / \\`shapio schema apply\\`. This file is code: changing it, or anything in\n// ./extensions, needs a restart (never a rebuild). Check it with \\`npx shapio extensions check\\`.\n// TypeScript works as is (erasable syntax only: no enums or namespaces). Guide: documentation/extensions.md in Shapio's repository.\nimport { defineConfig } from 'shapio/config';\n\nexport const config = defineConfig({\n // Lifecycle hooks by model API ID ('*' = every model). before* hooks run inside the write's transaction\n // and may reject it (422 HOOK_REJECTED); after* hooks run after commit, as retried jobs.\n hooks: {\n // article: {\n // beforePublish: ({ data, reject }) => {\n // if (!data?.cover) reject('An article needs a cover image');\n // },\n // },\n },\n\n // Custom React field editors: file names of ES modules built into ./extensions/editors/, e.g.\n // ['star-rating.js']. The admin loads them at runtime; installing one is a file copy plus a restart.\n editors: [],\n\n // Custom Fastify routes, mounted at /api/ext/<prefix>. Mutating routes must declare config.audit.\n // routes: [{ prefix: 'acme', plugin: async (app) => { app.get('/ping', async () => ({ ok: true })); } }],\n\n // Services constructed once at startup and shared by hooks, routes and jobs.\n // services: { stats: ({ services }) => ({ articles: () => services.content.count('article') }) },\n\n // Background jobs, registered as ext.<name>; enqueue with services.jobs.enqueue('<name>', payload).\n // jobs: { nightlyReport: async ({ services, logger }) => { logger.info('report'); } },\n});\n`;\n\nconst gitignore = () => `node_modules/\n.env\nmedia/*\n!media/.gitkeep\n`;\n\nconst pm2Ecosystem = ({ projectName }: TemplateInput) => `// PM2 process file: pm2 start ecosystem.config.cjs\n// One instance in fork mode, the simplest setup. Several instances on one database are supported too: use the\n// cluster variant below.\nmodule.exports = {\n apps: [\n {\n name: ${JSON.stringify(projectName)},\n script: 'node_modules/shapio/dist/cli.js',\n args: 'start',\n exec_mode: 'fork',\n instances: 1,\n kill_timeout: 20000,\n env: { NODE_ENV: 'production' },\n },\n // Cluster variant (replace the app above): two processes sharing the port, each with its inline worker.\n // { name: ${JSON.stringify(projectName)}, script: 'node_modules/shapio/dist/cli.js', args: 'start', exec_mode: 'cluster', instances: 2, kill_timeout: 20000, env: { NODE_ENV: 'production' } },\n ],\n};\n`;\n\nconst readme = ({ projectName }: TemplateInput) =>\n `# ${projectName}\n\nA [Shapio](https://github.com/mybrokengnome/shapio) project.\n\n1. Set \\`DATABASE_URL\\` in \\`.env\\` to a PostgreSQL (>= 16) database, or to \\`sqlite:<path>\\` for a\n single-process install.\n2. \\`npm run start\\`. Migrations run on start. The first boot logs where to create the owner account: open\n \\`/admin/\\`; the first person to complete Setup becomes the owner.\n3. Under PM2: \\`pm2 start ecosystem.config.cjs\\`.\n\nContent models are edited live in the admin; no restart is needed for modelling. Changing \\`shapio.config.ts\\`\nor \\`extensions/\\` (code) needs a restart; \\`npx shapio extensions check\\` validates them.\n`;\n\n/** Files to write, relative to the project directory. */\nexport const renderTemplates = (input: TemplateInput): ReadonlyArray<[path: string, content: string]> => [\n ['package.json', packageJson(input)],\n ['.env', env(input)],\n [\n '.env.example',\n env({ ...input, databaseUrl: 'postgres://user:password@localhost:5432/shapio', sessionSecret: '' }),\n ],\n ['shapio.config.ts', shapioConfig()],\n ['.gitignore', gitignore()],\n ['ecosystem.config.cjs', pm2Ecosystem(input)],\n ['README.md', readme(input)],\n ['extensions/.gitkeep', ''],\n ['extensions/editors/.gitkeep', ''],\n ['media/.gitkeep', ''],\n];\n","import { randomBytes } from 'node:crypto';\nimport { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';\nimport { basename, dirname, join, resolve } from 'node:path';\nimport { renderTemplates } from './templates.js';\n\nexport type ScaffoldOptions = {\n directory: string;\n shapioSpec: string;\n databaseUrl?: string;\n};\n\nexport class ScaffoldError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ScaffoldError';\n }\n}\n\n/** npm package names: lowercase, URL-safe. Derived from the directory name. */\nexport const toPackageName = (directory: string): string =>\n basename(resolve(directory))\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, '-')\n .replace(/^[._-]+|[-.]+$/g, '') || 'shapio-project';\n\n/** Writes the project files. Refuses to touch a non-empty directory. Returns the absolute project path. */\nexport const scaffoldProject = ({ directory, shapioSpec, databaseUrl }: ScaffoldOptions): string => {\n const root = resolve(directory);\n if (existsSync(root) && readdirSync(root).length > 0) {\n throw new ScaffoldError(`${root} exists and is not empty`);\n }\n const projectName = toPackageName(root);\n const files = renderTemplates({\n projectName,\n shapioSpec,\n databaseUrl:\n databaseUrl ?? `postgres://postgres:postgres@localhost:5432/${projectName.replace(/[^a-z0-9_]/g, '_')}`,\n sessionSecret: randomBytes(32).toString('base64url'),\n });\n for (const [path, content] of files) {\n const target = join(root, path);\n mkdirSync(dirname(target), { recursive: true });\n writeFileSync(target, content, { flag: 'wx' });\n }\n return root;\n};\n","/** The site starters `create-shapio --site` scaffolds (examples/<name> in the repository). */\nexport const SITE_STARTERS = ['astro', 'next', 'sveltekit'] as const;\n\nexport type SiteStarter = (typeof SITE_STARTERS)[number];\n\nexport const isSiteStarter = (value: string): value is SiteStarter =>\n (SITE_STARTERS as readonly string[]).includes(value);\n\n/** npm leaves `.gitignore` out of published packages, so templates carry it under this name. */\nexport const PACKED_GITIGNORE = 'gitignore';\n","import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport { ScaffoldError, toPackageName } from './scaffold.js';\nimport { PACKED_GITIGNORE, type SiteStarter } from './starters.js';\n\nexport type ScaffoldSiteOptions = {\n directory: string;\n site: SiteStarter;\n /** The packed templates (templates/ next to dist/ in the published package). */\n templatesDir: string;\n};\n\n/**\n * Writes a standalone site project from a starter template: the framework app, the blog's model files in\n * shapio/, and the seed and smoke scripts. Refuses to touch a non-empty directory. Returns the project path.\n */\nexport const scaffoldSite = ({ directory, site, templatesDir }: ScaffoldSiteOptions): string => {\n const root = resolve(directory);\n if (existsSync(root) && readdirSync(root).length > 0) {\n throw new ScaffoldError(`${root} exists and is not empty`);\n }\n const template = join(templatesDir, site);\n if (!existsSync(join(template, 'package.json'))) {\n throw new ScaffoldError(\n `The ${site} template is missing from ${templatesDir}; build create-shapio first`,\n );\n }\n cpSync(template, root, { recursive: true });\n if (existsSync(join(root, PACKED_GITIGNORE))) {\n renameSync(join(root, PACKED_GITIGNORE), join(root, '.gitignore'));\n }\n const packagePath = join(root, 'package.json');\n const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as Record<string, unknown>;\n writeFileSync(\n packagePath,\n `${JSON.stringify({ ...packageJson, name: toPackageName(root), version: '0.1.0' }, null, 2)}\\n`,\n );\n return root;\n};\n","import { spawnSync } from 'node:child_process';\nimport { readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { parseArgs } from 'node:util';\nimport { scaffoldProject, ScaffoldError } from './scaffold.js';\nimport { scaffoldSite } from './site.js';\nimport { isSiteStarter, SITE_STARTERS, type SiteStarter } from './starters.js';\n\nconst USAGE = `Usage: npx create-shapio <directory> [options]\n\nWithout --site: a Shapio project (the CMS server). With --site: a website that reads content from Shapio.\n\nOptions:\n --site <${SITE_STARTERS.join('|')}> Create a site starter instead (a blog: pages, articles, authors)\n --database-url <url> Database URL to put in .env (PostgreSQL, or sqlite:<path> for a single-process\n install; CMS project only)\n --shapio-spec <spec> Dependency spec for shapio (default: ^<this version>; a tarball path works too)\n --no-install Skip npm install\n -h, --help Show this help\n`;\n\n/** Packed from the repository's examples/ when create-shapio is built (templates/ beside dist/). */\nconst TEMPLATES_DIR = fileURLToPath(new URL('../templates/', import.meta.url));\n\nconst ownVersion = (): string => {\n const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {\n version: string;\n };\n return packageJson.version;\n};\n\ntype Output = { out: (text: string) => void; err: (text: string) => void };\n\n/** Returns the exit code. */\nexport const main = (argv: readonly string[], { out, err }: Output): number => {\n const { values, positionals } = parseArgs({\n args: [...argv],\n allowPositionals: true,\n allowNegative: true,\n options: {\n 'database-url': { type: 'string' },\n 'shapio-spec': { type: 'string' },\n site: { type: 'string' },\n install: { type: 'boolean', default: true },\n help: { type: 'boolean', short: 'h' },\n },\n });\n const [directory] = positionals;\n if (values.help || !directory) {\n (directory ? out : err)(USAGE);\n return values.help ? 0 : 1;\n }\n const site = values.site;\n if (site !== undefined && !isSiteStarter(site)) {\n err(`create-shapio: --site must be one of ${SITE_STARTERS.join(', ')}\\n`);\n return 1;\n }\n if (site !== undefined && (values['database-url'] || values['shapio-spec'])) {\n err('create-shapio: --database-url and --shapio-spec are for a CMS project, not --site\\n');\n return 1;\n }\n let root: string;\n try {\n root = site\n ? scaffoldSite({ directory, site, templatesDir: TEMPLATES_DIR })\n : scaffoldProject({\n directory,\n shapioSpec: values['shapio-spec'] ?? `^${ownVersion()}`,\n ...(values['database-url'] ? { databaseUrl: values['database-url'] } : {}),\n });\n } catch (error) {\n if (error instanceof ScaffoldError) {\n err(`create-shapio: ${error.message}\\n`);\n return 1;\n }\n throw error;\n }\n out(`Created a ${site ? `${site} site` : 'Shapio project'} in ${root}\\n`);\n if (values.install) {\n out('Installing dependencies with npm...\\n');\n const result = spawnSync('npm', ['install'], { cwd: root, stdio: 'inherit' });\n if (result.status !== 0) {\n err('create-shapio: npm install failed; run it yourself in the project directory\\n');\n return 1;\n }\n }\n out(\n site\n ? siteNextSteps(directory, site)\n : `\\nNext:\\n cd ${directory}\\n edit .env (DATABASE_URL)\\n npm run start\\n`,\n );\n return 0;\n};\n\nconst SERVE_COMMAND: Record<SiteStarter, string> = {\n astro: 'npm run preview',\n next: 'npm run start',\n sveltekit: 'npm run preview',\n};\n\nconst siteNextSteps = (directory: string, site: SiteStarter) =>\n [\n '',\n 'Next (with a running Shapio; README.md has the details):',\n ` cd ${directory}`,\n ' SHAPIO_URL=http://localhost:4300 SHAPIO_ADMIN_EMAIL=you@example.com SHAPIO_ADMIN_PASSWORD=... npm run seed',\n ' npm run build',\n ` ${SERVE_COMMAND[site]}`,\n '',\n ].join('\\n');\n","#!/usr/bin/env node\nimport { main } from './main.js';\n\nprocess.exitCode = main(process.argv.slice(2), {\n out: (text) => process.stdout.write(text),\n err: (text) => process.stderr.write(text),\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/templates.ts","../src/scaffold.ts","../src/starters.ts","../src/site.ts","../src/main.ts","../src/index.ts"],"sourcesContent":["export type TemplateInput = {\n projectName: string;\n shapioSpec: string;\n databaseUrl: string;\n sessionSecret: string;\n};\n\nconst packageJson = ({ projectName, shapioSpec }: TemplateInput) =>\n `${JSON.stringify(\n {\n name: projectName,\n private: true,\n type: 'module',\n engines: { node: '>=24' },\n scripts: {\n start: 'shapio start',\n migrate: 'shapio migrate',\n worker: 'shapio worker',\n status: 'shapio status',\n },\n dependencies: { '@shapio/cms': shapioSpec },\n },\n null,\n 2,\n )}\\n`;\n\nconst env = ({ databaseUrl, sessionSecret }: TemplateInput) =>\n [\n '# Shapio configuration. Every setting is an environment variable; the `shapio` command loads this file.',\n '# Real environment variables override values here.',\n 'NODE_ENV=production',\n 'HOST=127.0.0.1',\n 'PORT=4300',\n 'LOG_LEVEL=info',\n '',\n '# The origin people reach Shapio at; every absolute URL is built from it. Change it when you deploy.',\n 'PUBLIC_URL=http://localhost:4300',\n '# BASE_PATH=/cms',\n '',\n '# HTTPS without a reverse proxy: set PUBLIC_URL=https://your.domain and PORT=443, and point these at your',\n '# certificate and key (PEM). Renewed files are picked up without a restart. HTTP_PORT=80 redirects to HTTPS.',\n '# TLS_CERT_FILE=/etc/shapio/tls/fullchain.pem',\n '# TLS_KEY_FILE=/etc/shapio/tls/privkey.pem',\n '# HTTP_PORT=80',\n '',\n '# PostgreSQL >= 16, or sqlite:<path> for a single-process install',\n `DATABASE_URL=${databaseUrl}`,\n 'MIGRATE_ON_START=true',\n '',\n '# Signs preview links, private-media URLs and app-user tokens, and encrypts stored secrets.',\n '# Generated for this project; keep it secret, and back it up with the database.',\n `SESSION_SECRET=${sessionSecret}`,\n '',\n '# SETUP_REQUIRE_TOKEN=true # require the logged one-time token for first-run setup (for installs exposed before setup)',\n '',\n '# inline: jobs run inside the server process. dedicated: run `npm run worker` as a second process.',\n 'WORKER_MODE=inline',\n '',\n 'MEDIA_PATH=./media',\n '',\n ].join('\\n');\n\nconst shapioConfig =\n () => `// Project configuration for Shapio: code extensions (hooks, custom routes, services, jobs) and custom\n// field editors. Content models are NOT configured here; they are created live in the admin UI and synced\n// with \\`shapio schema pull\\` / \\`shapio schema apply\\`. This file is code: changing it, or anything in\n// ./extensions, needs a restart (never a rebuild). Check it with \\`npx shapio extensions check\\`.\n// TypeScript works as is (erasable syntax only: no enums or namespaces). Guide: documentation/extensions.md in Shapio's repository.\nimport { defineConfig } from '@shapio/cms/config';\n\nexport const config = defineConfig({\n // Lifecycle hooks by model API ID ('*' = every model). before* hooks run inside the write's transaction\n // and may reject it (422 HOOK_REJECTED); after* hooks run after commit, as retried jobs.\n hooks: {\n // article: {\n // beforePublish: ({ data, reject }) => {\n // if (!data?.cover) reject('An article needs a cover image');\n // },\n // },\n },\n\n // Custom React field editors: file names of ES modules built into ./extensions/editors/, e.g.\n // ['star-rating.js']. The admin loads them at runtime; installing one is a file copy plus a restart.\n editors: [],\n\n // Custom Fastify routes, mounted at /api/ext/<prefix>. Mutating routes must declare config.audit.\n // routes: [{ prefix: 'acme', plugin: async (app) => { app.get('/ping', async () => ({ ok: true })); } }],\n\n // Services constructed once at startup and shared by hooks, routes and jobs.\n // services: { stats: ({ services }) => ({ articles: () => services.content.count('article') }) },\n\n // Background jobs, registered as ext.<name>; enqueue with services.jobs.enqueue('<name>', payload).\n // jobs: { nightlyReport: async ({ services, logger }) => { logger.info('report'); } },\n});\n`;\n\nconst gitignore = () => `node_modules/\n.env\nmedia/*\n!media/.gitkeep\n`;\n\nconst pm2Ecosystem = ({ projectName }: TemplateInput) => `// PM2 process file: pm2 start ecosystem.config.cjs\n// One instance in fork mode, the simplest setup. Several instances on one database are supported too: use the\n// cluster variant below.\nmodule.exports = {\n apps: [\n {\n name: ${JSON.stringify(projectName)},\n script: 'node_modules/@shapio/cms/dist/cli.js',\n args: 'start',\n exec_mode: 'fork',\n instances: 1,\n kill_timeout: 20000,\n env: { NODE_ENV: 'production' },\n },\n // Cluster variant (replace the app above): two processes sharing the port, each with its inline worker.\n // { name: ${JSON.stringify(projectName)}, script: 'node_modules/@shapio/cms/dist/cli.js', args: 'start', exec_mode: 'cluster', instances: 2, kill_timeout: 20000, env: { NODE_ENV: 'production' } },\n ],\n};\n`;\n\nconst readme = ({ projectName }: TemplateInput) =>\n `# ${projectName}\n\nA [Shapio](https://github.com/mybrokengnome/shapio) project.\n\n1. Set \\`DATABASE_URL\\` in \\`.env\\` to a PostgreSQL (>= 16) database, or to \\`sqlite:<path>\\` for a\n single-process install.\n2. \\`npm run start\\`. Migrations run on start. The first boot logs where to create the owner account: open\n \\`/admin/\\`; the first person to complete Setup becomes the owner.\n3. Under PM2: \\`pm2 start ecosystem.config.cjs\\`.\n\nContent models are edited live in the admin; no restart is needed for modelling. Changing \\`shapio.config.ts\\`\nor \\`extensions/\\` (code) needs a restart; \\`npx shapio extensions check\\` validates them.\n`;\n\n/** Files to write, relative to the project directory. */\nexport const renderTemplates = (input: TemplateInput): ReadonlyArray<[path: string, content: string]> => [\n ['package.json', packageJson(input)],\n ['.env', env(input)],\n [\n '.env.example',\n env({ ...input, databaseUrl: 'postgres://user:password@localhost:5432/shapio', sessionSecret: '' }),\n ],\n ['shapio.config.ts', shapioConfig()],\n ['.gitignore', gitignore()],\n ['ecosystem.config.cjs', pm2Ecosystem(input)],\n ['README.md', readme(input)],\n ['extensions/.gitkeep', ''],\n ['extensions/editors/.gitkeep', ''],\n ['media/.gitkeep', ''],\n];\n","import { randomBytes } from 'node:crypto';\nimport { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';\nimport { basename, dirname, join, resolve } from 'node:path';\nimport { renderTemplates } from './templates.js';\n\nexport type ScaffoldOptions = {\n directory: string;\n shapioSpec: string;\n databaseUrl?: string;\n};\n\nexport class ScaffoldError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ScaffoldError';\n }\n}\n\n/** npm package names: lowercase, URL-safe. Derived from the directory name. */\nexport const toPackageName = (directory: string): string =>\n basename(resolve(directory))\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, '-')\n .replace(/^[._-]+|[-.]+$/g, '') || 'shapio-project';\n\n/** Writes the project files. Refuses to touch a non-empty directory. Returns the absolute project path. */\nexport const scaffoldProject = ({ directory, shapioSpec, databaseUrl }: ScaffoldOptions): string => {\n const root = resolve(directory);\n if (existsSync(root) && readdirSync(root).length > 0) {\n throw new ScaffoldError(`${root} exists and is not empty`);\n }\n const projectName = toPackageName(root);\n const files = renderTemplates({\n projectName,\n shapioSpec,\n databaseUrl:\n databaseUrl ?? `postgres://postgres:postgres@localhost:5432/${projectName.replace(/[^a-z0-9_]/g, '_')}`,\n sessionSecret: randomBytes(32).toString('base64url'),\n });\n for (const [path, content] of files) {\n const target = join(root, path);\n mkdirSync(dirname(target), { recursive: true });\n writeFileSync(target, content, { flag: 'wx' });\n }\n return root;\n};\n","/** The site starters `create-shapio --site` scaffolds (examples/<name> in the repository). */\nexport const SITE_STARTERS = ['astro', 'next', 'sveltekit'] as const;\n\nexport type SiteStarter = (typeof SITE_STARTERS)[number];\n\nexport const isSiteStarter = (value: string): value is SiteStarter =>\n (SITE_STARTERS as readonly string[]).includes(value);\n\n/** npm leaves `.gitignore` out of published packages, so templates carry it under this name. */\nexport const PACKED_GITIGNORE = 'gitignore';\n","import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport { ScaffoldError, toPackageName } from './scaffold.js';\nimport { PACKED_GITIGNORE, type SiteStarter } from './starters.js';\n\nexport type ScaffoldSiteOptions = {\n directory: string;\n site: SiteStarter;\n /** The packed templates (templates/ next to dist/ in the published package). */\n templatesDir: string;\n};\n\n/**\n * Writes a standalone site project from a starter template: the framework app, the blog's model files in\n * shapio/, and the seed and smoke scripts. Refuses to touch a non-empty directory. Returns the project path.\n */\nexport const scaffoldSite = ({ directory, site, templatesDir }: ScaffoldSiteOptions): string => {\n const root = resolve(directory);\n if (existsSync(root) && readdirSync(root).length > 0) {\n throw new ScaffoldError(`${root} exists and is not empty`);\n }\n const template = join(templatesDir, site);\n if (!existsSync(join(template, 'package.json'))) {\n throw new ScaffoldError(\n `The ${site} template is missing from ${templatesDir}; build create-shapio first`,\n );\n }\n cpSync(template, root, { recursive: true });\n if (existsSync(join(root, PACKED_GITIGNORE))) {\n renameSync(join(root, PACKED_GITIGNORE), join(root, '.gitignore'));\n }\n const packagePath = join(root, 'package.json');\n const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as Record<string, unknown>;\n writeFileSync(\n packagePath,\n `${JSON.stringify({ ...packageJson, name: toPackageName(root), version: '0.1.0' }, null, 2)}\\n`,\n );\n return root;\n};\n","import { spawnSync } from 'node:child_process';\nimport { readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { parseArgs } from 'node:util';\nimport { scaffoldProject, ScaffoldError } from './scaffold.js';\nimport { scaffoldSite } from './site.js';\nimport { isSiteStarter, SITE_STARTERS, type SiteStarter } from './starters.js';\n\nconst USAGE = `Usage: npx create-shapio <directory> [options]\n\nWithout --site: a Shapio project (the CMS server). With --site: a website that reads content from Shapio.\n\nOptions:\n --site <${SITE_STARTERS.join('|')}> Create a site starter instead (a blog: pages, articles, authors)\n --database-url <url> Database URL to put in .env (PostgreSQL, or sqlite:<path> for a single-process\n install; CMS project only)\n --shapio-spec <spec> Dependency spec for shapio (default: ^<this version>; a tarball path works too)\n --no-install Skip npm install\n -h, --help Show this help\n`;\n\n/** Packed from the repository's examples/ when create-shapio is built (templates/ beside dist/). */\nconst TEMPLATES_DIR = fileURLToPath(new URL('../templates/', import.meta.url));\n\nconst ownVersion = (): string => {\n const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {\n version: string;\n };\n return packageJson.version;\n};\n\ntype Output = { out: (text: string) => void; err: (text: string) => void };\n\n/** Returns the exit code. */\nexport const main = (argv: readonly string[], { out, err }: Output): number => {\n const { values, positionals } = parseArgs({\n args: [...argv],\n allowPositionals: true,\n allowNegative: true,\n options: {\n 'database-url': { type: 'string' },\n 'shapio-spec': { type: 'string' },\n site: { type: 'string' },\n install: { type: 'boolean', default: true },\n help: { type: 'boolean', short: 'h' },\n },\n });\n const [directory] = positionals;\n if (values.help || !directory) {\n (directory ? out : err)(USAGE);\n return values.help ? 0 : 1;\n }\n const site = values.site;\n if (site !== undefined && !isSiteStarter(site)) {\n err(`create-shapio: --site must be one of ${SITE_STARTERS.join(', ')}\\n`);\n return 1;\n }\n if (site !== undefined && (values['database-url'] || values['shapio-spec'])) {\n err('create-shapio: --database-url and --shapio-spec are for a CMS project, not --site\\n');\n return 1;\n }\n let root: string;\n try {\n root = site\n ? scaffoldSite({ directory, site, templatesDir: TEMPLATES_DIR })\n : scaffoldProject({\n directory,\n shapioSpec: values['shapio-spec'] ?? `^${ownVersion()}`,\n ...(values['database-url'] ? { databaseUrl: values['database-url'] } : {}),\n });\n } catch (error) {\n if (error instanceof ScaffoldError) {\n err(`create-shapio: ${error.message}\\n`);\n return 1;\n }\n throw error;\n }\n out(`Created a ${site ? `${site} site` : 'Shapio project'} in ${root}\\n`);\n if (values.install) {\n out('Installing dependencies with npm...\\n');\n const result = spawnSync('npm', ['install'], { cwd: root, stdio: 'inherit' });\n if (result.status !== 0) {\n err('create-shapio: npm install failed; run it yourself in the project directory\\n');\n return 1;\n }\n }\n out(\n site\n ? siteNextSteps(directory, site)\n : `\\nNext:\\n cd ${directory}\\n edit .env (DATABASE_URL)\\n npm run start\\n`,\n );\n return 0;\n};\n\nconst SERVE_COMMAND: Record<SiteStarter, string> = {\n astro: 'npm run preview',\n next: 'npm run start',\n sveltekit: 'npm run preview',\n};\n\nconst siteNextSteps = (directory: string, site: SiteStarter) =>\n [\n '',\n 'Next (with a running Shapio; README.md has the details):',\n ` cd ${directory}`,\n ' SHAPIO_URL=http://localhost:4300 SHAPIO_ADMIN_EMAIL=you@example.com SHAPIO_ADMIN_PASSWORD=... npm run seed',\n ' npm run build',\n ` ${SERVE_COMMAND[site]}`,\n '',\n ].join('\\n');\n","#!/usr/bin/env node\nimport { main } from './main.js';\n\nprocess.exitCode = main(process.argv.slice(2), {\n out: (text) => process.stdout.write(text),\n err: (text) => process.stderr.write(text),\n});\n"],"mappings":";;;;;;;;AAOA,MAAM,eAAe,EAAE,aAAa,iBAClC,GAAG,KAAK,UACN;CACE,MAAM;CACN,SAAS;CACT,MAAM;CACN,SAAS,EAAE,MAAM,OAAO;CACxB,SAAS;EACP,OAAO;EACP,SAAS;EACT,QAAQ;EACR,QAAQ;CACV;CACA,cAAc,EAAE,eAAe,WAAW;AAC5C,GACA,MACA,CACF,EAAE;AAEJ,MAAM,OAAO,EAAE,aAAa,oBAC1B;CACE;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA,gBAAgB;CAChB;CACA;CACA;CACA;CACA,kBAAkB;CAClB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,IAAI;AAEb,MAAM,qBACE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCR,MAAM,kBAAkB;;;;;AAMxB,MAAM,gBAAgB,EAAE,kBAAiC;;;;;;cAM3C,KAAK,UAAU,WAAW,EAAE;;;;;;;;;iBASzB,KAAK,UAAU,WAAW,EAAE;;;;AAK7C,MAAM,UAAU,EAAE,kBAChB,KAAK,YAAY;;;;;;;;;;;;;;AAenB,MAAa,mBAAmB,UAAyE;CACvG,CAAC,gBAAgB,YAAY,KAAK,CAAC;CACnC,CAAC,QAAQ,IAAI,KAAK,CAAC;CACnB,CACE,gBACA,IAAI;EAAE,GAAG;EAAO,aAAa;EAAkD,eAAe;CAAG,CAAC,CACpG;CACA,CAAC,oBAAoB,aAAa,CAAC;CACnC,CAAC,cAAc,UAAU,CAAC;CAC1B,CAAC,wBAAwB,aAAa,KAAK,CAAC;CAC5C,CAAC,aAAa,OAAO,KAAK,CAAC;CAC3B,CAAC,uBAAuB,EAAE;CAC1B,CAAC,+BAA+B,EAAE;CAClC,CAAC,kBAAkB,EAAE;AACvB;;;AC7IA,IAAa,gBAAb,cAAmC,MAAM;CACvC,YAAY,SAAiB;EAC3B,MAAM,OAAO;EACb,KAAK,OAAO;CACd;AACF;;AAGA,MAAa,iBAAiB,cAC5B,SAAS,QAAQ,SAAS,CAAC,CAAC,CACzB,YAAY,CAAC,CACb,QAAQ,kBAAkB,GAAG,CAAC,CAC9B,QAAQ,mBAAmB,EAAE,KAAK;;AAGvC,MAAa,mBAAmB,EAAE,WAAW,YAAY,kBAA2C;CAClG,MAAM,OAAO,QAAQ,SAAS;CAC9B,IAAI,WAAW,IAAI,KAAK,YAAY,IAAI,CAAC,CAAC,SAAS,GACjD,MAAM,IAAI,cAAc,GAAG,KAAK,yBAAyB;CAE3D,MAAM,cAAc,cAAc,IAAI;CACtC,MAAM,QAAQ,gBAAgB;EAC5B;EACA;EACA,aACE,eAAe,+CAA+C,YAAY,QAAQ,eAAe,GAAG;EACtG,eAAe,YAAY,EAAE,CAAC,CAAC,SAAS,WAAW;CACrD,CAAC;CACD,KAAK,MAAM,CAAC,MAAM,YAAY,OAAO;EACnC,MAAM,SAAS,KAAK,MAAM,IAAI;EAC9B,UAAU,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;EAC9C,cAAc,QAAQ,SAAS,EAAE,MAAM,KAAK,CAAC;CAC/C;CACA,OAAO;AACT;;;;AC5CA,MAAa,gBAAgB;CAAC;CAAS;CAAQ;AAAW;AAI1D,MAAa,iBAAiB,UAC3B,cAAoC,SAAS,KAAK;;AAGrD,MAAa,mBAAmB;;;;;;;ACOhC,MAAa,gBAAgB,EAAE,WAAW,MAAM,mBAAgD;CAC9F,MAAM,OAAO,QAAQ,SAAS;CAC9B,IAAI,WAAW,IAAI,KAAK,YAAY,IAAI,CAAC,CAAC,SAAS,GACjD,MAAM,IAAI,cAAc,GAAG,KAAK,yBAAyB;CAE3D,MAAM,WAAW,KAAK,cAAc,IAAI;CACxC,IAAI,CAAC,WAAW,KAAK,UAAU,cAAc,CAAC,GAC5C,MAAM,IAAI,cACR,OAAO,KAAK,4BAA4B,aAAa,4BACvD;CAEF,OAAO,UAAU,MAAM,EAAE,WAAW,KAAK,CAAC;CAC1C,IAAI,WAAW,KAAK,MAAA,WAAsB,CAAC,GACzC,WAAW,KAAK,MAAM,gBAAgB,GAAG,KAAK,MAAM,YAAY,CAAC;CAEnE,MAAM,cAAc,KAAK,MAAM,cAAc;CAC7C,MAAM,cAAc,KAAK,MAAM,aAAa,aAAa,MAAM,CAAC;CAChE,cACE,aACA,GAAG,KAAK,UAAU;EAAE,GAAG;EAAa,MAAM,cAAc,IAAI;EAAG,SAAS;CAAQ,GAAG,MAAM,CAAC,EAAE,GAC9F;CACA,OAAO;AACT;;;AC9BA,MAAM,QAAQ;;;;;YAKF,cAAc,KAAK,GAAG,EAAE;;;;;;;;AASpC,MAAM,gBAAgB,cAAc,IAAI,IAAI,iBAAiB,YAAY,GAAG,CAAC;AAE7E,MAAM,mBAA2B;CAI/B,OAHoB,KAAK,MAAM,aAAa,IAAI,IAAI,mBAAmB,YAAY,GAAG,GAAG,MAAM,CAG9E,CAAC,CAAC;AACrB;;AAKA,MAAa,QAAQ,MAAyB,EAAE,KAAK,UAA0B;CAC7E,MAAM,EAAE,QAAQ,gBAAgB,UAAU;EACxC,MAAM,CAAC,GAAG,IAAI;EACd,kBAAkB;EAClB,eAAe;EACf,SAAS;GACP,gBAAgB,EAAE,MAAM,SAAS;GACjC,eAAe,EAAE,MAAM,SAAS;GAChC,MAAM,EAAE,MAAM,SAAS;GACvB,SAAS;IAAE,MAAM;IAAW,SAAS;GAAK;GAC1C,MAAM;IAAE,MAAM;IAAW,OAAO;GAAI;EACtC;CACF,CAAC;CACD,MAAM,CAAC,aAAa;CACpB,IAAI,OAAO,QAAQ,CAAC,WAAW;EAC7B,CAAC,YAAY,MAAM,IAAA,CAAK,KAAK;EAC7B,OAAO,OAAO,OAAO,IAAI;CAC3B;CACA,MAAM,OAAO,OAAO;CACpB,IAAI,SAAS,KAAA,KAAa,CAAC,cAAc,IAAI,GAAG;EAC9C,IAAI,wCAAwC,cAAc,KAAK,IAAI,EAAE,GAAG;EACxE,OAAO;CACT;CACA,IAAI,SAAS,KAAA,MAAc,OAAO,mBAAmB,OAAO,iBAAiB;EAC3E,IAAI,qFAAqF;EACzF,OAAO;CACT;CACA,IAAI;CACJ,IAAI;EACF,OAAO,OACH,aAAa;GAAE;GAAW;GAAM,cAAc;EAAc,CAAC,IAC7D,gBAAgB;GACd;GACA,YAAY,OAAO,kBAAkB,IAAI,WAAW;GACpD,GAAI,OAAO,kBAAkB,EAAE,aAAa,OAAO,gBAAgB,IAAI,CAAC;EAC1E,CAAC;CACP,SAAS,OAAO;EACd,IAAI,iBAAiB,eAAe;GAClC,IAAI,kBAAkB,MAAM,QAAQ,GAAG;GACvC,OAAO;EACT;EACA,MAAM;CACR;CACA,IAAI,aAAa,OAAO,GAAG,KAAK,SAAS,iBAAiB,MAAM,KAAK,GAAG;CACxE,IAAI,OAAO,SAAS;EAClB,IAAI,uCAAuC;EAE3C,IADe,UAAU,OAAO,CAAC,SAAS,GAAG;GAAE,KAAK;GAAM,OAAO;EAAU,CAClE,CAAC,CAAC,WAAW,GAAG;GACvB,IAAI,+EAA+E;GACnF,OAAO;EACT;CACF;CACA,IACE,OACI,cAAc,WAAW,IAAI,IAC7B,iBAAiB,UAAU,gDACjC;CACA,OAAO;AACT;AAEA,MAAM,gBAA6C;CACjD,OAAO;CACP,MAAM;CACN,WAAW;AACb;AAEA,MAAM,iBAAiB,WAAmB,SACxC;CACE;CACA;CACA,QAAQ;CACR;CACA;CACA,KAAK,cAAc;CACnB;AACF,CAAC,CAAC,KAAK,IAAI;;;AC1Gb,QAAQ,WAAW,KAAK,QAAQ,KAAK,MAAM,CAAC,GAAG;CAC7C,MAAM,SAAS,QAAQ,OAAO,MAAM,IAAI;CACxC,MAAM,SAAS,QAAQ,OAAO,MAAM,IAAI;AAC1C,CAAC"}
|
package/package.json
CHANGED
|
@@ -3,10 +3,13 @@ import { createClient, type ShapioClient } from '@shapio/client';
|
|
|
3
3
|
/**
|
|
4
4
|
* How the seed talks to Shapio's admin API: with SHAPIO_TOKEN (an admin API token), or with
|
|
5
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.
|
|
6
|
+
* for itself (the schema CLI needs a bearer token) and revokes it when done. Every request is about the site
|
|
7
|
+
* in SHAPIO_SITE (the one the starter reads), else the token's site, else the primary site.
|
|
7
8
|
*/
|
|
8
9
|
export type AdminConnection = {
|
|
9
10
|
url: string;
|
|
11
|
+
/** SHAPIO_SITE, if set: the site every request is about. */
|
|
12
|
+
site: string | undefined;
|
|
10
13
|
token: string;
|
|
11
14
|
client: ShapioClient;
|
|
12
15
|
/** A signed-in admin session (email and password only): preview tokens are issued to people, not API tokens. */
|
|
@@ -17,7 +20,7 @@ export type AdminConnection = {
|
|
|
17
20
|
const SESSION_COOKIE = 'shapio_session';
|
|
18
21
|
const SEED_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
19
22
|
|
|
20
|
-
const signIn = async (url: string, email: string, password: string) => {
|
|
23
|
+
const signIn = async (url: string, email: string, password: string, site: string | undefined) => {
|
|
21
24
|
const response = await fetch(`${url.replace(/\/+$/, '')}/api/admin/auth/login`, {
|
|
22
25
|
method: 'POST',
|
|
23
26
|
headers: { 'content-type': 'application/json' },
|
|
@@ -34,16 +37,20 @@ const signIn = async (url: string, email: string, password: string) => {
|
|
|
34
37
|
return createClient({
|
|
35
38
|
baseUrl: url,
|
|
36
39
|
headers: () => ({ cookie: cookie.split(';')[0] ?? '', 'x-csrf-token': csrfToken }),
|
|
40
|
+
...(site ? { site } : {}),
|
|
37
41
|
});
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnection> => {
|
|
41
45
|
const url = env.SHAPIO_URL ?? 'http://localhost:4300';
|
|
46
|
+
const site = env.SHAPIO_SITE?.trim() || undefined;
|
|
47
|
+
const siteOption = site ? { site } : {};
|
|
42
48
|
if (env.SHAPIO_TOKEN) {
|
|
43
49
|
return {
|
|
44
50
|
url,
|
|
51
|
+
site,
|
|
45
52
|
token: env.SHAPIO_TOKEN,
|
|
46
|
-
client: createClient({ baseUrl: url, token: env.SHAPIO_TOKEN }),
|
|
53
|
+
client: createClient({ baseUrl: url, token: env.SHAPIO_TOKEN, ...siteOption }),
|
|
47
54
|
session: undefined,
|
|
48
55
|
close: async () => undefined,
|
|
49
56
|
};
|
|
@@ -53,7 +60,7 @@ export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnect
|
|
|
53
60
|
if (!email || !password) {
|
|
54
61
|
throw new Error('Set SHAPIO_TOKEN (an admin API token), or SHAPIO_ADMIN_EMAIL and SHAPIO_ADMIN_PASSWORD');
|
|
55
62
|
}
|
|
56
|
-
const session = await signIn(url, email, password);
|
|
63
|
+
const session = await signIn(url, email, password, site);
|
|
57
64
|
const roles = await session.admin.roles.list();
|
|
58
65
|
const admin = roles.find((role) => role.key === 'admin');
|
|
59
66
|
if (!admin) {
|
|
@@ -68,8 +75,9 @@ export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnect
|
|
|
68
75
|
});
|
|
69
76
|
return {
|
|
70
77
|
url,
|
|
78
|
+
site,
|
|
71
79
|
token: created.token,
|
|
72
|
-
client: createClient({ baseUrl: url, token: created.token }),
|
|
80
|
+
client: createClient({ baseUrl: url, token: created.token, ...siteOption }),
|
|
73
81
|
session,
|
|
74
82
|
close: () => session.admin.tokens.revoke(created.apiToken.id),
|
|
75
83
|
};
|
|
@@ -27,7 +27,8 @@ import { createPng } from './lib/png.js';
|
|
|
27
27
|
* content to the seed):
|
|
28
28
|
* 1. the `fr` locale;
|
|
29
29
|
* 2. the models and components in shapio/ through the schema apply API (live, no restart; the same planner
|
|
30
|
-
* as `shapio schema apply` and the admin)
|
|
30
|
+
* as `shapio schema apply` and the admin), as the site's own (SHAPIO_SITE, else the token's site, else the
|
|
31
|
+
* primary): a starter's schema belongs to the first site it is seeded on;
|
|
31
32
|
* 3. placeholder images, an author, the site settings, pages and articles in English and French, published
|
|
32
33
|
* per locale;
|
|
33
34
|
* 4. a deployment connection named "Preview" whose preview URL opens this starter's /preview/ page (the
|
|
@@ -64,7 +65,12 @@ const MEDIA_READY_TIMEOUT_MS = 120_000;
|
|
|
64
65
|
const SCHEMA_CHANGE_TIMEOUT_MS = 120_000;
|
|
65
66
|
const POLL_INTERVAL_MS = 500;
|
|
66
67
|
/** No lock file: a first apply. Definitions that already match are skipped by the server. */
|
|
67
|
-
const FIRST_APPLY_BASE: SchemaApplyInput['base'] = {
|
|
68
|
+
const FIRST_APPLY_BASE: SchemaApplyInput['base'] = {
|
|
69
|
+
formatVersion: 2,
|
|
70
|
+
schemaVersion: 0,
|
|
71
|
+
sites: [],
|
|
72
|
+
definitions: {},
|
|
73
|
+
};
|
|
68
74
|
|
|
69
75
|
const log = (line: string) => process.stdout.write(`${line}\n`);
|
|
70
76
|
|
|
@@ -106,15 +112,43 @@ const waitForChange = async (client: ShapioClient, item: SchemaSyncResult) => {
|
|
|
106
112
|
}
|
|
107
113
|
};
|
|
108
114
|
|
|
115
|
+
/** A refusal the seed explains itself: printed without a stack. */
|
|
116
|
+
class SeedRefusedError extends Error {}
|
|
117
|
+
|
|
118
|
+
/** The schema files' IDs already belong to another site's definitions (the starter was seeded there first). */
|
|
119
|
+
const isSeededElsewhere = (error: unknown) =>
|
|
120
|
+
error instanceof ShapioApiError &&
|
|
121
|
+
error.code === 'SCHEMA_INVALID' &&
|
|
122
|
+
((error.details as { issues?: Array<{ code?: string }> } | undefined)?.issues ?? []).some(
|
|
123
|
+
(issue) => issue.code === 'DUPLICATE_ID',
|
|
124
|
+
);
|
|
125
|
+
|
|
109
126
|
const applySchema = async (client: ShapioClient) => {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
const definitions = await readDefinitions();
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = await client.admin.schema.apply({
|
|
131
|
+
definitions,
|
|
132
|
+
// Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default.
|
|
133
|
+
scopes: definitions.map(() => 'site' as const),
|
|
134
|
+
base: FIRST_APPLY_BASE,
|
|
135
|
+
prune: false,
|
|
136
|
+
dryRun: false,
|
|
137
|
+
acknowledgeBreaking: false,
|
|
138
|
+
acknowledgeDestructive: false,
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isSeededElsewhere(error)) {
|
|
142
|
+
throw new SeedRefusedError(
|
|
143
|
+
"This starter's schema already belongs to another site of this instance: a starter's schema belongs " +
|
|
144
|
+
'to the first site it is seeded on. To reuse it on this site, share it with all sites ' +
|
|
145
|
+
'with a network admin token (`shapio schema scope <apiKey> --shared --site <that site>` for each ' +
|
|
146
|
+
'component, then author, page, article and siteSettings) and seed again, or seed a separate instance.',
|
|
147
|
+
{ cause: error },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
118
152
|
for (const item of response.results) {
|
|
119
153
|
if (item.outcome === 'pending') {
|
|
120
154
|
await waitForChange(client, item);
|
|
@@ -347,17 +381,24 @@ const tryRevalidateWebhook = async (client: ShapioClient, siteUrl: string, path:
|
|
|
347
381
|
}
|
|
348
382
|
};
|
|
349
383
|
|
|
350
|
-
const writeEnv = async (
|
|
384
|
+
const writeEnv = async (
|
|
385
|
+
url: string,
|
|
386
|
+
site: string | undefined,
|
|
387
|
+
deliveryToken: string,
|
|
388
|
+
webhookSecret: string | undefined,
|
|
389
|
+
) => {
|
|
351
390
|
const path = resolve('.env');
|
|
352
391
|
const lines = [
|
|
353
392
|
'# Written by the seed. The delivery token is read-only; keep this file out of git.',
|
|
354
393
|
`SHAPIO_URL=${url}`,
|
|
355
394
|
`SHAPIO_DELIVERY_TOKEN=${deliveryToken}`,
|
|
395
|
+
// The site the content was seeded on: the starter reads the same one.
|
|
396
|
+
...(site ? [`SHAPIO_SITE=${site}`] : []),
|
|
356
397
|
...(webhookSecret ? [`SHAPIO_WEBHOOK_SECRET=${webhookSecret}`] : []),
|
|
357
398
|
];
|
|
358
399
|
await writeFile(path, `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
359
400
|
log(
|
|
360
|
-
`Wrote SHAPIO_URL, SHAPIO_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
|
|
401
|
+
`Wrote SHAPIO_URL, ${site ? 'SHAPIO_SITE, ' : ''}SHAPIO_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
|
|
361
402
|
);
|
|
362
403
|
};
|
|
363
404
|
|
|
@@ -381,16 +422,24 @@ const main = async () => {
|
|
|
381
422
|
const webhookSecret = revalidatePath
|
|
382
423
|
? await tryRevalidateWebhook(admin.client, siteUrl, revalidatePath)
|
|
383
424
|
: undefined;
|
|
384
|
-
await writeEnv(admin.url, await createDeliveryToken(admin.client), webhookSecret);
|
|
425
|
+
await writeEnv(admin.url, admin.site, await createDeliveryToken(admin.client), webhookSecret);
|
|
385
426
|
log('Seeded. Build the site with: npm run build');
|
|
386
427
|
} finally {
|
|
387
428
|
await admin.close();
|
|
388
429
|
}
|
|
389
430
|
};
|
|
390
431
|
|
|
432
|
+
const describeFailure = (error: unknown) => {
|
|
433
|
+
if (error instanceof SeedRefusedError) {
|
|
434
|
+
return error.message;
|
|
435
|
+
}
|
|
436
|
+
if (error instanceof ShapioApiError) {
|
|
437
|
+
return `${error.code}: ${error.message} ${JSON.stringify(error.details ?? '')}`;
|
|
438
|
+
}
|
|
439
|
+
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
440
|
+
};
|
|
441
|
+
|
|
391
442
|
main().catch((error: unknown) => {
|
|
392
|
-
process.stderr.write(
|
|
393
|
-
`seed failed: ${error instanceof ShapioApiError ? `${error.code}: ${error.message} ${JSON.stringify(error.details ?? '')}` : error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
|
394
|
-
);
|
|
443
|
+
process.stderr.write(`seed failed: ${describeFailure(error)}\n`);
|
|
395
444
|
process.exitCode = 1;
|
|
396
445
|
});
|
|
@@ -3,10 +3,13 @@ import { createClient, type ShapioClient } from '@shapio/client';
|
|
|
3
3
|
/**
|
|
4
4
|
* How the seed talks to Shapio's admin API: with SHAPIO_TOKEN (an admin API token), or with
|
|
5
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.
|
|
6
|
+
* for itself (the schema CLI needs a bearer token) and revokes it when done. Every request is about the site
|
|
7
|
+
* in SHAPIO_SITE (the one the starter reads), else the token's site, else the primary site.
|
|
7
8
|
*/
|
|
8
9
|
export type AdminConnection = {
|
|
9
10
|
url: string;
|
|
11
|
+
/** SHAPIO_SITE, if set: the site every request is about. */
|
|
12
|
+
site: string | undefined;
|
|
10
13
|
token: string;
|
|
11
14
|
client: ShapioClient;
|
|
12
15
|
/** A signed-in admin session (email and password only): preview tokens are issued to people, not API tokens. */
|
|
@@ -17,7 +20,7 @@ export type AdminConnection = {
|
|
|
17
20
|
const SESSION_COOKIE = 'shapio_session';
|
|
18
21
|
const SEED_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
19
22
|
|
|
20
|
-
const signIn = async (url: string, email: string, password: string) => {
|
|
23
|
+
const signIn = async (url: string, email: string, password: string, site: string | undefined) => {
|
|
21
24
|
const response = await fetch(`${url.replace(/\/+$/, '')}/api/admin/auth/login`, {
|
|
22
25
|
method: 'POST',
|
|
23
26
|
headers: { 'content-type': 'application/json' },
|
|
@@ -34,16 +37,20 @@ const signIn = async (url: string, email: string, password: string) => {
|
|
|
34
37
|
return createClient({
|
|
35
38
|
baseUrl: url,
|
|
36
39
|
headers: () => ({ cookie: cookie.split(';')[0] ?? '', 'x-csrf-token': csrfToken }),
|
|
40
|
+
...(site ? { site } : {}),
|
|
37
41
|
});
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnection> => {
|
|
41
45
|
const url = env.SHAPIO_URL ?? 'http://localhost:4300';
|
|
46
|
+
const site = env.SHAPIO_SITE?.trim() || undefined;
|
|
47
|
+
const siteOption = site ? { site } : {};
|
|
42
48
|
if (env.SHAPIO_TOKEN) {
|
|
43
49
|
return {
|
|
44
50
|
url,
|
|
51
|
+
site,
|
|
45
52
|
token: env.SHAPIO_TOKEN,
|
|
46
|
-
client: createClient({ baseUrl: url, token: env.SHAPIO_TOKEN }),
|
|
53
|
+
client: createClient({ baseUrl: url, token: env.SHAPIO_TOKEN, ...siteOption }),
|
|
47
54
|
session: undefined,
|
|
48
55
|
close: async () => undefined,
|
|
49
56
|
};
|
|
@@ -53,7 +60,7 @@ export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnect
|
|
|
53
60
|
if (!email || !password) {
|
|
54
61
|
throw new Error('Set SHAPIO_TOKEN (an admin API token), or SHAPIO_ADMIN_EMAIL and SHAPIO_ADMIN_PASSWORD');
|
|
55
62
|
}
|
|
56
|
-
const session = await signIn(url, email, password);
|
|
63
|
+
const session = await signIn(url, email, password, site);
|
|
57
64
|
const roles = await session.admin.roles.list();
|
|
58
65
|
const admin = roles.find((role) => role.key === 'admin');
|
|
59
66
|
if (!admin) {
|
|
@@ -68,8 +75,9 @@ export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnect
|
|
|
68
75
|
});
|
|
69
76
|
return {
|
|
70
77
|
url,
|
|
78
|
+
site,
|
|
71
79
|
token: created.token,
|
|
72
|
-
client: createClient({ baseUrl: url, token: created.token }),
|
|
80
|
+
client: createClient({ baseUrl: url, token: created.token, ...siteOption }),
|
|
73
81
|
session,
|
|
74
82
|
close: () => session.admin.tokens.revoke(created.apiToken.id),
|
|
75
83
|
};
|
|
@@ -27,7 +27,8 @@ import { createPng } from './lib/png.js';
|
|
|
27
27
|
* content to the seed):
|
|
28
28
|
* 1. the `fr` locale;
|
|
29
29
|
* 2. the models and components in shapio/ through the schema apply API (live, no restart; the same planner
|
|
30
|
-
* as `shapio schema apply` and the admin)
|
|
30
|
+
* as `shapio schema apply` and the admin), as the site's own (SHAPIO_SITE, else the token's site, else the
|
|
31
|
+
* primary): a starter's schema belongs to the first site it is seeded on;
|
|
31
32
|
* 3. placeholder images, an author, the site settings, pages and articles in English and French, published
|
|
32
33
|
* per locale;
|
|
33
34
|
* 4. a deployment connection named "Preview" whose preview URL opens this starter's /preview/ page (the
|
|
@@ -64,7 +65,12 @@ const MEDIA_READY_TIMEOUT_MS = 120_000;
|
|
|
64
65
|
const SCHEMA_CHANGE_TIMEOUT_MS = 120_000;
|
|
65
66
|
const POLL_INTERVAL_MS = 500;
|
|
66
67
|
/** No lock file: a first apply. Definitions that already match are skipped by the server. */
|
|
67
|
-
const FIRST_APPLY_BASE: SchemaApplyInput['base'] = {
|
|
68
|
+
const FIRST_APPLY_BASE: SchemaApplyInput['base'] = {
|
|
69
|
+
formatVersion: 2,
|
|
70
|
+
schemaVersion: 0,
|
|
71
|
+
sites: [],
|
|
72
|
+
definitions: {},
|
|
73
|
+
};
|
|
68
74
|
|
|
69
75
|
const log = (line: string) => process.stdout.write(`${line}\n`);
|
|
70
76
|
|
|
@@ -106,15 +112,43 @@ const waitForChange = async (client: ShapioClient, item: SchemaSyncResult) => {
|
|
|
106
112
|
}
|
|
107
113
|
};
|
|
108
114
|
|
|
115
|
+
/** A refusal the seed explains itself: printed without a stack. */
|
|
116
|
+
class SeedRefusedError extends Error {}
|
|
117
|
+
|
|
118
|
+
/** The schema files' IDs already belong to another site's definitions (the starter was seeded there first). */
|
|
119
|
+
const isSeededElsewhere = (error: unknown) =>
|
|
120
|
+
error instanceof ShapioApiError &&
|
|
121
|
+
error.code === 'SCHEMA_INVALID' &&
|
|
122
|
+
((error.details as { issues?: Array<{ code?: string }> } | undefined)?.issues ?? []).some(
|
|
123
|
+
(issue) => issue.code === 'DUPLICATE_ID',
|
|
124
|
+
);
|
|
125
|
+
|
|
109
126
|
const applySchema = async (client: ShapioClient) => {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
const definitions = await readDefinitions();
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = await client.admin.schema.apply({
|
|
131
|
+
definitions,
|
|
132
|
+
// Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default.
|
|
133
|
+
scopes: definitions.map(() => 'site' as const),
|
|
134
|
+
base: FIRST_APPLY_BASE,
|
|
135
|
+
prune: false,
|
|
136
|
+
dryRun: false,
|
|
137
|
+
acknowledgeBreaking: false,
|
|
138
|
+
acknowledgeDestructive: false,
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isSeededElsewhere(error)) {
|
|
142
|
+
throw new SeedRefusedError(
|
|
143
|
+
"This starter's schema already belongs to another site of this instance: a starter's schema belongs " +
|
|
144
|
+
'to the first site it is seeded on. To reuse it on this site, share it with all sites ' +
|
|
145
|
+
'with a network admin token (`shapio schema scope <apiKey> --shared --site <that site>` for each ' +
|
|
146
|
+
'component, then author, page, article and siteSettings) and seed again, or seed a separate instance.',
|
|
147
|
+
{ cause: error },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
118
152
|
for (const item of response.results) {
|
|
119
153
|
if (item.outcome === 'pending') {
|
|
120
154
|
await waitForChange(client, item);
|
|
@@ -347,17 +381,24 @@ const tryRevalidateWebhook = async (client: ShapioClient, siteUrl: string, path:
|
|
|
347
381
|
}
|
|
348
382
|
};
|
|
349
383
|
|
|
350
|
-
const writeEnv = async (
|
|
384
|
+
const writeEnv = async (
|
|
385
|
+
url: string,
|
|
386
|
+
site: string | undefined,
|
|
387
|
+
deliveryToken: string,
|
|
388
|
+
webhookSecret: string | undefined,
|
|
389
|
+
) => {
|
|
351
390
|
const path = resolve('.env');
|
|
352
391
|
const lines = [
|
|
353
392
|
'# Written by the seed. The delivery token is read-only; keep this file out of git.',
|
|
354
393
|
`SHAPIO_URL=${url}`,
|
|
355
394
|
`SHAPIO_DELIVERY_TOKEN=${deliveryToken}`,
|
|
395
|
+
// The site the content was seeded on: the starter reads the same one.
|
|
396
|
+
...(site ? [`SHAPIO_SITE=${site}`] : []),
|
|
356
397
|
...(webhookSecret ? [`SHAPIO_WEBHOOK_SECRET=${webhookSecret}`] : []),
|
|
357
398
|
];
|
|
358
399
|
await writeFile(path, `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
359
400
|
log(
|
|
360
|
-
`Wrote SHAPIO_URL, SHAPIO_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
|
|
401
|
+
`Wrote SHAPIO_URL, ${site ? 'SHAPIO_SITE, ' : ''}SHAPIO_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
|
|
361
402
|
);
|
|
362
403
|
};
|
|
363
404
|
|
|
@@ -381,16 +422,24 @@ const main = async () => {
|
|
|
381
422
|
const webhookSecret = revalidatePath
|
|
382
423
|
? await tryRevalidateWebhook(admin.client, siteUrl, revalidatePath)
|
|
383
424
|
: undefined;
|
|
384
|
-
await writeEnv(admin.url, await createDeliveryToken(admin.client), webhookSecret);
|
|
425
|
+
await writeEnv(admin.url, admin.site, await createDeliveryToken(admin.client), webhookSecret);
|
|
385
426
|
log('Seeded. Build the site with: npm run build');
|
|
386
427
|
} finally {
|
|
387
428
|
await admin.close();
|
|
388
429
|
}
|
|
389
430
|
};
|
|
390
431
|
|
|
432
|
+
const describeFailure = (error: unknown) => {
|
|
433
|
+
if (error instanceof SeedRefusedError) {
|
|
434
|
+
return error.message;
|
|
435
|
+
}
|
|
436
|
+
if (error instanceof ShapioApiError) {
|
|
437
|
+
return `${error.code}: ${error.message} ${JSON.stringify(error.details ?? '')}`;
|
|
438
|
+
}
|
|
439
|
+
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
440
|
+
};
|
|
441
|
+
|
|
391
442
|
main().catch((error: unknown) => {
|
|
392
|
-
process.stderr.write(
|
|
393
|
-
`seed failed: ${error instanceof ShapioApiError ? `${error.code}: ${error.message} ${JSON.stringify(error.details ?? '')}` : error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
|
394
|
-
);
|
|
443
|
+
process.stderr.write(`seed failed: ${describeFailure(error)}\n`);
|
|
395
444
|
process.exitCode = 1;
|
|
396
445
|
});
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@shapio/client": "^0.
|
|
25
|
-
"@shapio/visual": "^0.
|
|
24
|
+
"@shapio/client": "^0.3.0",
|
|
25
|
+
"@shapio/visual": "^0.3.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@sveltejs/adapter-static": "4.0.0",
|
|
@@ -3,10 +3,13 @@ import { createClient, type ShapioClient } from '@shapio/client';
|
|
|
3
3
|
/**
|
|
4
4
|
* How the seed talks to Shapio's admin API: with SHAPIO_TOKEN (an admin API token), or with
|
|
5
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.
|
|
6
|
+
* for itself (the schema CLI needs a bearer token) and revokes it when done. Every request is about the site
|
|
7
|
+
* in SHAPIO_SITE (the one the starter reads), else the token's site, else the primary site.
|
|
7
8
|
*/
|
|
8
9
|
export type AdminConnection = {
|
|
9
10
|
url: string;
|
|
11
|
+
/** SHAPIO_SITE, if set: the site every request is about. */
|
|
12
|
+
site: string | undefined;
|
|
10
13
|
token: string;
|
|
11
14
|
client: ShapioClient;
|
|
12
15
|
/** A signed-in admin session (email and password only): preview tokens are issued to people, not API tokens. */
|
|
@@ -17,7 +20,7 @@ export type AdminConnection = {
|
|
|
17
20
|
const SESSION_COOKIE = 'shapio_session';
|
|
18
21
|
const SEED_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
19
22
|
|
|
20
|
-
const signIn = async (url: string, email: string, password: string) => {
|
|
23
|
+
const signIn = async (url: string, email: string, password: string, site: string | undefined) => {
|
|
21
24
|
const response = await fetch(`${url.replace(/\/+$/, '')}/api/admin/auth/login`, {
|
|
22
25
|
method: 'POST',
|
|
23
26
|
headers: { 'content-type': 'application/json' },
|
|
@@ -34,16 +37,20 @@ const signIn = async (url: string, email: string, password: string) => {
|
|
|
34
37
|
return createClient({
|
|
35
38
|
baseUrl: url,
|
|
36
39
|
headers: () => ({ cookie: cookie.split(';')[0] ?? '', 'x-csrf-token': csrfToken }),
|
|
40
|
+
...(site ? { site } : {}),
|
|
37
41
|
});
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnection> => {
|
|
41
45
|
const url = env.SHAPIO_URL ?? 'http://localhost:4300';
|
|
46
|
+
const site = env.SHAPIO_SITE?.trim() || undefined;
|
|
47
|
+
const siteOption = site ? { site } : {};
|
|
42
48
|
if (env.SHAPIO_TOKEN) {
|
|
43
49
|
return {
|
|
44
50
|
url,
|
|
51
|
+
site,
|
|
45
52
|
token: env.SHAPIO_TOKEN,
|
|
46
|
-
client: createClient({ baseUrl: url, token: env.SHAPIO_TOKEN }),
|
|
53
|
+
client: createClient({ baseUrl: url, token: env.SHAPIO_TOKEN, ...siteOption }),
|
|
47
54
|
session: undefined,
|
|
48
55
|
close: async () => undefined,
|
|
49
56
|
};
|
|
@@ -53,7 +60,7 @@ export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnect
|
|
|
53
60
|
if (!email || !password) {
|
|
54
61
|
throw new Error('Set SHAPIO_TOKEN (an admin API token), or SHAPIO_ADMIN_EMAIL and SHAPIO_ADMIN_PASSWORD');
|
|
55
62
|
}
|
|
56
|
-
const session = await signIn(url, email, password);
|
|
63
|
+
const session = await signIn(url, email, password, site);
|
|
57
64
|
const roles = await session.admin.roles.list();
|
|
58
65
|
const admin = roles.find((role) => role.key === 'admin');
|
|
59
66
|
if (!admin) {
|
|
@@ -68,8 +75,9 @@ export const connectAdmin = async (env: NodeJS.ProcessEnv): Promise<AdminConnect
|
|
|
68
75
|
});
|
|
69
76
|
return {
|
|
70
77
|
url,
|
|
78
|
+
site,
|
|
71
79
|
token: created.token,
|
|
72
|
-
client: createClient({ baseUrl: url, token: created.token }),
|
|
80
|
+
client: createClient({ baseUrl: url, token: created.token, ...siteOption }),
|
|
73
81
|
session,
|
|
74
82
|
close: () => session.admin.tokens.revoke(created.apiToken.id),
|
|
75
83
|
};
|
|
@@ -27,7 +27,8 @@ import { createPng } from './lib/png.js';
|
|
|
27
27
|
* content to the seed):
|
|
28
28
|
* 1. the `fr` locale;
|
|
29
29
|
* 2. the models and components in shapio/ through the schema apply API (live, no restart; the same planner
|
|
30
|
-
* as `shapio schema apply` and the admin)
|
|
30
|
+
* as `shapio schema apply` and the admin), as the site's own (SHAPIO_SITE, else the token's site, else the
|
|
31
|
+
* primary): a starter's schema belongs to the first site it is seeded on;
|
|
31
32
|
* 3. placeholder images, an author, the site settings, pages and articles in English and French, published
|
|
32
33
|
* per locale;
|
|
33
34
|
* 4. a deployment connection named "Preview" whose preview URL opens this starter's /preview/ page (the
|
|
@@ -64,7 +65,12 @@ const MEDIA_READY_TIMEOUT_MS = 120_000;
|
|
|
64
65
|
const SCHEMA_CHANGE_TIMEOUT_MS = 120_000;
|
|
65
66
|
const POLL_INTERVAL_MS = 500;
|
|
66
67
|
/** No lock file: a first apply. Definitions that already match are skipped by the server. */
|
|
67
|
-
const FIRST_APPLY_BASE: SchemaApplyInput['base'] = {
|
|
68
|
+
const FIRST_APPLY_BASE: SchemaApplyInput['base'] = {
|
|
69
|
+
formatVersion: 2,
|
|
70
|
+
schemaVersion: 0,
|
|
71
|
+
sites: [],
|
|
72
|
+
definitions: {},
|
|
73
|
+
};
|
|
68
74
|
|
|
69
75
|
const log = (line: string) => process.stdout.write(`${line}\n`);
|
|
70
76
|
|
|
@@ -106,15 +112,43 @@ const waitForChange = async (client: ShapioClient, item: SchemaSyncResult) => {
|
|
|
106
112
|
}
|
|
107
113
|
};
|
|
108
114
|
|
|
115
|
+
/** A refusal the seed explains itself: printed without a stack. */
|
|
116
|
+
class SeedRefusedError extends Error {}
|
|
117
|
+
|
|
118
|
+
/** The schema files' IDs already belong to another site's definitions (the starter was seeded there first). */
|
|
119
|
+
const isSeededElsewhere = (error: unknown) =>
|
|
120
|
+
error instanceof ShapioApiError &&
|
|
121
|
+
error.code === 'SCHEMA_INVALID' &&
|
|
122
|
+
((error.details as { issues?: Array<{ code?: string }> } | undefined)?.issues ?? []).some(
|
|
123
|
+
(issue) => issue.code === 'DUPLICATE_ID',
|
|
124
|
+
);
|
|
125
|
+
|
|
109
126
|
const applySchema = async (client: ShapioClient) => {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
const definitions = await readDefinitions();
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = await client.admin.schema.apply({
|
|
131
|
+
definitions,
|
|
132
|
+
// Every definition is the site's own (sites/<key>/ in a pulled tree), never shared by default.
|
|
133
|
+
scopes: definitions.map(() => 'site' as const),
|
|
134
|
+
base: FIRST_APPLY_BASE,
|
|
135
|
+
prune: false,
|
|
136
|
+
dryRun: false,
|
|
137
|
+
acknowledgeBreaking: false,
|
|
138
|
+
acknowledgeDestructive: false,
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isSeededElsewhere(error)) {
|
|
142
|
+
throw new SeedRefusedError(
|
|
143
|
+
"This starter's schema already belongs to another site of this instance: a starter's schema belongs " +
|
|
144
|
+
'to the first site it is seeded on. To reuse it on this site, share it with all sites ' +
|
|
145
|
+
'with a network admin token (`shapio schema scope <apiKey> --shared --site <that site>` for each ' +
|
|
146
|
+
'component, then author, page, article and siteSettings) and seed again, or seed a separate instance.',
|
|
147
|
+
{ cause: error },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
118
152
|
for (const item of response.results) {
|
|
119
153
|
if (item.outcome === 'pending') {
|
|
120
154
|
await waitForChange(client, item);
|
|
@@ -347,17 +381,24 @@ const tryRevalidateWebhook = async (client: ShapioClient, siteUrl: string, path:
|
|
|
347
381
|
}
|
|
348
382
|
};
|
|
349
383
|
|
|
350
|
-
const writeEnv = async (
|
|
384
|
+
const writeEnv = async (
|
|
385
|
+
url: string,
|
|
386
|
+
site: string | undefined,
|
|
387
|
+
deliveryToken: string,
|
|
388
|
+
webhookSecret: string | undefined,
|
|
389
|
+
) => {
|
|
351
390
|
const path = resolve('.env');
|
|
352
391
|
const lines = [
|
|
353
392
|
'# Written by the seed. The delivery token is read-only; keep this file out of git.',
|
|
354
393
|
`SHAPIO_URL=${url}`,
|
|
355
394
|
`SHAPIO_DELIVERY_TOKEN=${deliveryToken}`,
|
|
395
|
+
// The site the content was seeded on: the starter reads the same one.
|
|
396
|
+
...(site ? [`SHAPIO_SITE=${site}`] : []),
|
|
356
397
|
...(webhookSecret ? [`SHAPIO_WEBHOOK_SECRET=${webhookSecret}`] : []),
|
|
357
398
|
];
|
|
358
399
|
await writeFile(path, `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
359
400
|
log(
|
|
360
|
-
`Wrote SHAPIO_URL, SHAPIO_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
|
|
401
|
+
`Wrote SHAPIO_URL, ${site ? 'SHAPIO_SITE, ' : ''}SHAPIO_DELIVERY_TOKEN${webhookSecret ? ' and SHAPIO_WEBHOOK_SECRET' : ''} to ${path}`,
|
|
361
402
|
);
|
|
362
403
|
};
|
|
363
404
|
|
|
@@ -381,16 +422,24 @@ const main = async () => {
|
|
|
381
422
|
const webhookSecret = revalidatePath
|
|
382
423
|
? await tryRevalidateWebhook(admin.client, siteUrl, revalidatePath)
|
|
383
424
|
: undefined;
|
|
384
|
-
await writeEnv(admin.url, await createDeliveryToken(admin.client), webhookSecret);
|
|
425
|
+
await writeEnv(admin.url, admin.site, await createDeliveryToken(admin.client), webhookSecret);
|
|
385
426
|
log('Seeded. Build the site with: npm run build');
|
|
386
427
|
} finally {
|
|
387
428
|
await admin.close();
|
|
388
429
|
}
|
|
389
430
|
};
|
|
390
431
|
|
|
432
|
+
const describeFailure = (error: unknown) => {
|
|
433
|
+
if (error instanceof SeedRefusedError) {
|
|
434
|
+
return error.message;
|
|
435
|
+
}
|
|
436
|
+
if (error instanceof ShapioApiError) {
|
|
437
|
+
return `${error.code}: ${error.message} ${JSON.stringify(error.details ?? '')}`;
|
|
438
|
+
}
|
|
439
|
+
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
440
|
+
};
|
|
441
|
+
|
|
391
442
|
main().catch((error: unknown) => {
|
|
392
|
-
process.stderr.write(
|
|
393
|
-
`seed failed: ${error instanceof ShapioApiError ? `${error.code}: ${error.message} ${JSON.stringify(error.details ?? '')}` : error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
|
394
|
-
);
|
|
443
|
+
process.stderr.write(`seed failed: ${describeFailure(error)}\n`);
|
|
395
444
|
process.exitCode = 1;
|
|
396
445
|
});
|