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 +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/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
|
});
|