@stelstone/server 0.29.0 → 0.30.1
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/src/adapters/github-content.mjs +16 -8
- package/src/core/config-schema.mjs +34 -0
- package/src/default-public-config.mjs +5 -0
- package/src/routes.mjs +50 -8
- package/src/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -408,13 +408,15 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
408
408
|
? { deferredPublish: true, perEntryPublish: true }
|
|
409
409
|
: { deferredPublish: false, perEntryPublish: false },
|
|
410
410
|
|
|
411
|
-
|
|
411
|
+
/** @param {string} [target] branch to compare against; defaults to the deploy branch */
|
|
412
|
+
async pendingChanges(target) {
|
|
412
413
|
if (!draftMode) return { hasChanges: false, changedFiles: 0, files: [] };
|
|
413
414
|
await ensureDraftBranch();
|
|
415
|
+
const against = target || branch;
|
|
414
416
|
// One compare call: which files differ between published and draft?
|
|
415
417
|
// (GitHub caps the file list at 300 — orders of magnitude above any
|
|
416
418
|
// real collection here; still, say so rather than rely on it silently.)
|
|
417
|
-
const cmp = await apiGet(`/compare/${
|
|
419
|
+
const cmp = await apiGet(`/compare/${against}...${draftBranch}`);
|
|
418
420
|
const files = (cmp?.files ?? [])
|
|
419
421
|
.filter((f) => f.filename.startsWith(`${pagesDir}/`))
|
|
420
422
|
// Index manifests live on the draft branch only — the site build
|
|
@@ -437,16 +439,22 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
437
439
|
* references them by SHA — nothing is re-uploaded. A `removed` status
|
|
438
440
|
* becomes a tree entry with `sha: null`, which is how git-data deletes.
|
|
439
441
|
*
|
|
442
|
+
* `target` names the branch to land on, so one panel can push the same
|
|
443
|
+
* drafts to a preview site first and to the live site after. The caller
|
|
444
|
+
* resolves it from the configured list — an arbitrary branch name must
|
|
445
|
+
* never reach here from a request.
|
|
446
|
+
*
|
|
440
447
|
* @param {string} [message]
|
|
441
|
-
* @param {{ entries?: {collection: string, file: string}[] }} [opts]
|
|
448
|
+
* @param {{ entries?: {collection: string, file: string}[], target?: string }} [opts]
|
|
442
449
|
*/
|
|
443
|
-
async publish(message, { entries } = {}) {
|
|
450
|
+
async publish(message, { entries, target } = {}) {
|
|
451
|
+
const toBranch = target || branch;
|
|
444
452
|
if (!draftMode) {
|
|
445
453
|
// Writes are committed instantly; trigger is external (Netlify webhook on push)
|
|
446
454
|
return { ok: true, message: "All changes are already committed to GitHub" };
|
|
447
455
|
}
|
|
448
456
|
await ensureDraftBranch();
|
|
449
|
-
const pending = await this.pendingChanges();
|
|
457
|
+
const pending = await this.pendingChanges(toBranch);
|
|
450
458
|
if (!pending.hasChanges) return { ok: false, message: "No changes to publish" };
|
|
451
459
|
|
|
452
460
|
const scoped = Array.isArray(entries) && entries.length > 0;
|
|
@@ -472,7 +480,7 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
472
480
|
let lastErr;
|
|
473
481
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
474
482
|
try {
|
|
475
|
-
const refData = await apiGet(`/git/ref/heads/${
|
|
483
|
+
const refData = await apiGet(`/git/ref/heads/${toBranch}`);
|
|
476
484
|
const baseCommitSha = refData.object.sha;
|
|
477
485
|
const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
|
|
478
486
|
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
@@ -483,11 +491,11 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
483
491
|
tree: newTree.sha,
|
|
484
492
|
parents: [baseCommitSha],
|
|
485
493
|
});
|
|
486
|
-
await apiPatch(`/git/refs/heads/${
|
|
494
|
+
await apiPatch(`/git/refs/heads/${toBranch}`, { sha: newCommit.sha });
|
|
487
495
|
const shortSha = newCommit.sha.slice(0, 7);
|
|
488
496
|
return {
|
|
489
497
|
ok: true,
|
|
490
|
-
message: `Published ${targets.length} file(s) to ${
|
|
498
|
+
message: `Published ${targets.length} file(s) to ${toBranch} (${shortSha})`,
|
|
491
499
|
sha: newCommit.sha,
|
|
492
500
|
shortSha,
|
|
493
501
|
branch,
|
|
@@ -86,6 +86,39 @@ function checkContent(config, report, { runtime }) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* `content.publishTargets` gives one panel more than one place to land the
|
|
91
|
+
* drafts — a preview site first, the live site after. Each id is what a
|
|
92
|
+
* request may ask for; the branch is never taken from the request.
|
|
93
|
+
*/
|
|
94
|
+
function checkPublishTargets(config, report) {
|
|
95
|
+
const targets = config.content?.publishTargets;
|
|
96
|
+
if (targets === undefined) return;
|
|
97
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
98
|
+
report.error("content.publishTargets", "must be a non-empty array");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!config.content?.draftBranch) {
|
|
102
|
+
report.error(
|
|
103
|
+
"content.publishTargets",
|
|
104
|
+
"needs content.draftBranch — without a draft branch saving already publishes, so there is nothing to send anywhere",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
targets.forEach((t, i) => {
|
|
109
|
+
const at = `content.publishTargets[${i}]`;
|
|
110
|
+
if (!t || typeof t !== "object") return report.error(at, "must be an object");
|
|
111
|
+
if (!t.id) report.error(`${at}.id`, "is required — this is what the panel sends");
|
|
112
|
+
if (!t.branch) report.error(`${at}.branch`, "is required — the branch this target lands on");
|
|
113
|
+
if (!t.label) report.warn(`${at}.label`, "is missing — the button will show the id");
|
|
114
|
+
if (t.id && seen.has(t.id)) report.error(`${at}.id`, `duplicate target id "${t.id}"`);
|
|
115
|
+
if (t.id) seen.add(t.id);
|
|
116
|
+
if (t.branch && t.branch === config.content?.draftBranch) {
|
|
117
|
+
report.error(`${at}.branch`, "is the draft branch — publishing onto it would be a no-op");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
function checkAuth(config, report, { getSecret }) {
|
|
90
123
|
const auth = config.auth;
|
|
91
124
|
if (!isPlainObject(auth)) {
|
|
@@ -435,6 +468,7 @@ export function validateConfig(config, { runtime = "node", getSecret } = {}) {
|
|
|
435
468
|
checkCollections(config, report);
|
|
436
469
|
checkForms(config, report, { getSecret: lookup });
|
|
437
470
|
checkBuild(config, report, { getSecret: lookup });
|
|
471
|
+
checkPublishTargets(config, report);
|
|
438
472
|
checkMisc(config, report);
|
|
439
473
|
|
|
440
474
|
return { errors: report.errors, warnings: report.warnings };
|
|
@@ -22,6 +22,11 @@ export function defaultPublicConfig(config, overrides = {}) {
|
|
|
22
22
|
blocks: config.blocks,
|
|
23
23
|
content: {
|
|
24
24
|
provider: config.content?.provider || "fs",
|
|
25
|
+
// Ids and labels only — the branch each maps to stays server-side.
|
|
26
|
+
publishTargets: (config.content?.publishTargets ?? []).map((t) => ({
|
|
27
|
+
id: t.id,
|
|
28
|
+
label: t.label || t.id,
|
|
29
|
+
})),
|
|
25
30
|
// Whether saving and publishing are separate steps. False only on the
|
|
26
31
|
// github backend without a draft branch — the admin hides Publish there,
|
|
27
32
|
// because saving already published.
|
package/src/routes.mjs
CHANGED
|
@@ -87,7 +87,12 @@ export const apiRoutes = [
|
|
|
87
87
|
handler: ({ env, runtime, adminUiVersion }) =>
|
|
88
88
|
ok({
|
|
89
89
|
serverVersion: SERVER_VERSION,
|
|
90
|
-
|
|
90
|
+
// The Node server reads this off the package it resolved. A Worker
|
|
91
|
+
// cannot: the SPA is served by the assets binding and its package.json
|
|
92
|
+
// is not in the bundle. The deployment composed that directory, so it
|
|
93
|
+
// can state the version the way it states the commit below — a stated
|
|
94
|
+
// version beats a null on the About screen.
|
|
95
|
+
adminUiVersion: adminUiVersion ?? env("CMS_ADMIN_UI_VERSION") ?? null,
|
|
91
96
|
// Declared by whoever constructed the handler, not sniffed: with
|
|
92
97
|
// nodejs_compat enabled a Worker also exposes process.versions.node,
|
|
93
98
|
// so feature detection reported "node" from inside a Worker.
|
|
@@ -580,9 +585,11 @@ export const apiRoutes = [
|
|
|
580
585
|
method: "POST",
|
|
581
586
|
path: "/api/publish",
|
|
582
587
|
auth: "admin",
|
|
583
|
-
handler: async ({ adapters }) => {
|
|
588
|
+
handler: async ({ adapters, config, body }) => {
|
|
589
|
+
const target = resolveTarget(config, body?.target);
|
|
590
|
+
if (target.error) return { status: 400, json: { ok: false, message: target.error } };
|
|
584
591
|
try {
|
|
585
|
-
return ok(await adapters.content.publish());
|
|
592
|
+
return ok(await adapters.content.publish(null, { target: target.branch }));
|
|
586
593
|
} catch (err) {
|
|
587
594
|
return { status: 500, json: { ok: false, message: err.message } };
|
|
588
595
|
}
|
|
@@ -596,7 +603,9 @@ export const apiRoutes = [
|
|
|
596
603
|
method: "POST",
|
|
597
604
|
path: "/api/collections/:collection/:file/publish",
|
|
598
605
|
auth: "admin",
|
|
599
|
-
handler: async ({ adapters, params }) => {
|
|
606
|
+
handler: async ({ adapters, params, config, body }) => {
|
|
607
|
+
const target = resolveTarget(config, body?.target);
|
|
608
|
+
if (target.error) return { status: 400, json: { ok: false, message: target.error } };
|
|
600
609
|
if (!adapters.content.capabilities?.perEntryPublish) {
|
|
601
610
|
return {
|
|
602
611
|
status: 501,
|
|
@@ -607,6 +616,7 @@ export const apiRoutes = [
|
|
|
607
616
|
return ok(
|
|
608
617
|
await adapters.content.publish(null, {
|
|
609
618
|
entries: [{ collection: params.collection, file: params.file }],
|
|
619
|
+
target: target.branch,
|
|
610
620
|
}),
|
|
611
621
|
);
|
|
612
622
|
} catch (err) {
|
|
@@ -619,10 +629,12 @@ export const apiRoutes = [
|
|
|
619
629
|
method: "GET",
|
|
620
630
|
path: "/api/publish/status",
|
|
621
631
|
auth: "any",
|
|
622
|
-
handler: async ({ adapters }) => {
|
|
632
|
+
handler: async ({ adapters, config, query }) => {
|
|
633
|
+
const target = resolveTarget(config, query?.target);
|
|
634
|
+
if (target.error) return { status: 400, json: { error: target.error } };
|
|
623
635
|
try {
|
|
624
636
|
return ok({
|
|
625
|
-
...(await adapters.content.pendingChanges()),
|
|
637
|
+
...(await adapters.content.pendingChanges(target.branch)),
|
|
626
638
|
perEntryPublish: !!adapters.content.capabilities?.perEntryPublish,
|
|
627
639
|
});
|
|
628
640
|
} catch (err) {
|
|
@@ -636,11 +648,20 @@ export const apiRoutes = [
|
|
|
636
648
|
method: "GET",
|
|
637
649
|
path: "/api/deploy/status",
|
|
638
650
|
auth: "any",
|
|
639
|
-
handler: async ({ adapters, query }) => {
|
|
651
|
+
handler: async ({ adapters, config, query }) => {
|
|
640
652
|
if (!adapters.build.configured) return ok({ configured: false });
|
|
653
|
+
// Which branch's build to watch. After publishing to a target the new
|
|
654
|
+
// commit exists only on that target's branch, so without this the run
|
|
655
|
+
// is looked for on the deploy branch and never found — the pill then
|
|
656
|
+
// spins until it gives up, for a build that actually succeeded.
|
|
657
|
+
const target = resolveTarget(config, query.target);
|
|
658
|
+
if (target.error) return { status: 400, json: { error: target.error } };
|
|
641
659
|
try {
|
|
642
660
|
return ok(
|
|
643
|
-
await adapters.build.getDeployStatus({
|
|
661
|
+
await adapters.build.getDeployStatus({
|
|
662
|
+
branch: target.branch ?? query.branch,
|
|
663
|
+
sha: query.sha,
|
|
664
|
+
}),
|
|
644
665
|
);
|
|
645
666
|
} catch (err) {
|
|
646
667
|
if (err.upstreamStatus) {
|
|
@@ -652,6 +673,27 @@ export const apiRoutes = [
|
|
|
652
673
|
},
|
|
653
674
|
];
|
|
654
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Turns a target id from a request into a branch name.
|
|
678
|
+
*
|
|
679
|
+
* The id is matched against `content.publishTargets`; a branch name is never
|
|
680
|
+
* taken from the request itself, or an admin could push the drafts onto any
|
|
681
|
+
* ref in the repo. No id, or no targets configured, means the deploy branch —
|
|
682
|
+
* which is what every existing config does.
|
|
683
|
+
*/
|
|
684
|
+
function resolveTarget(config, id) {
|
|
685
|
+
const targets = config?.content?.publishTargets;
|
|
686
|
+
if (!id) return { branch: undefined };
|
|
687
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
688
|
+
return { error: `Unknown publish target "${id}" — content.publishTargets is not configured` };
|
|
689
|
+
}
|
|
690
|
+
const hit = targets.find((t) => t.id === id);
|
|
691
|
+
if (!hit) {
|
|
692
|
+
return { error: `Unknown publish target "${id}" — expected one of: ${targets.map((t) => t.id).join(", ")}` };
|
|
693
|
+
}
|
|
694
|
+
return { branch: hit.branch };
|
|
695
|
+
}
|
|
696
|
+
|
|
655
697
|
/**
|
|
656
698
|
* Media endpoints need the auth adapter to mint a CDN token. Ask the port
|
|
657
699
|
* whether it can, instead of calling and handling the exception as a 500.
|
package/src/version.mjs
CHANGED