@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stelstone/server",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
4
4
  "description": "Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- async pendingChanges() {
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/${branch}...${draftBranch}`);
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/${branch}`);
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/${branch}`, { sha: newCommit.sha });
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 ${branch} (${shortSha})`,
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
- adminUiVersion: adminUiVersion ?? null,
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({ branch: query.branch, sha: query.sha }),
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
@@ -5,4 +5,4 @@
5
5
  * require() and no import.meta.url, so reading the manifest at runtime yields
6
6
  * "unknown" there.
7
7
  */
8
- export const SERVER_VERSION = "0.29.0";
8
+ export const SERVER_VERSION = "0.30.1";