create-shapio 0.2.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-shapio",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a new Shapio project (npx create-shapio my-cms) or a site starter (--site astro|next|sveltekit)",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://shapio.dev",
@@ -19,8 +19,8 @@
19
19
  "typecheck": "tsc -p tsconfig.json"
20
20
  },
21
21
  "dependencies": {
22
- "@shapio/client": "^0.2.1",
23
- "@shapio/visual": "^0.2.1",
22
+ "@shapio/client": "^0.3.0",
23
+ "@shapio/visual": "^0.3.0",
24
24
  "astro": "7.3.5"
25
25
  },
26
26
  "devDependencies": {
@@ -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'] = { formatVersion: 1, schemaVersion: 0, definitions: {} };
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 response = await client.admin.schema.apply({
111
- definitions: await readDefinitions(),
112
- base: FIRST_APPLY_BASE,
113
- prune: false,
114
- dryRun: false,
115
- acknowledgeBreaking: false,
116
- acknowledgeDestructive: false,
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 (url: string, deliveryToken: string, webhookSecret: string | undefined) => {
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
  });
@@ -17,8 +17,8 @@
17
17
  "test": "vitest run"
18
18
  },
19
19
  "dependencies": {
20
- "@shapio/client": "^0.2.1",
21
- "@shapio/visual": "^0.2.1",
20
+ "@shapio/client": "^0.3.0",
21
+ "@shapio/visual": "^0.3.0",
22
22
  "next": "16.3.8",
23
23
  "react": "19.3.0",
24
24
  "react-dom": "19.3.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'] = { formatVersion: 1, schemaVersion: 0, definitions: {} };
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 response = await client.admin.schema.apply({
111
- definitions: await readDefinitions(),
112
- base: FIRST_APPLY_BASE,
113
- prune: false,
114
- dryRun: false,
115
- acknowledgeBreaking: false,
116
- acknowledgeDestructive: false,
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 (url: string, deliveryToken: string, webhookSecret: string | undefined) => {
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.2.1",
25
- "@shapio/visual": "^0.2.1"
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'] = { formatVersion: 1, schemaVersion: 0, definitions: {} };
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 response = await client.admin.schema.apply({
111
- definitions: await readDefinitions(),
112
- base: FIRST_APPLY_BASE,
113
- prune: false,
114
- dryRun: false,
115
- acknowledgeBreaking: false,
116
- acknowledgeDestructive: false,
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 (url: string, deliveryToken: string, webhookSecret: string | undefined) => {
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
  });