ferst-core 0.9.5 → 0.9.7

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.
@@ -60,53 +60,10 @@ export function repoSlug() {
60
60
  return '';
61
61
  }
62
62
 
63
- export const PROD_GIT_BASE = 'https://signin.ferst.co.uk';
64
- export const DEV_GIT_BASE = 'https://signin-dev.ferst.co.uk';
65
-
66
- /**
67
- * The platform git-proxy origin (CMS `base_url` / `api_root`) for THIS build:
68
- * 1. `FERST_GIT_BASE` env — explicit override, always wins.
69
- * 2. committed `ferst-site.json` `gitBase` — an explicit per-site pin (e.g. a
70
- * custom proxy); a BYO/seeded site can hard-bind its origin this way.
71
- * 3. otherwise DERIVE from the deploy environment: the production branch talks to
72
- * the prod git-proxy, every other (preview/dev) branch talks to the dev
73
- * git-proxy. So a templated repo self-configures per environment on deploy — a
74
- * dev-branch build points its CMS at the dev worker and main at prod — with NO
75
- * per-client config. (The production branch defaults to `main`; override via
76
- * `FERST_PROD_BRANCH` env or `ferst-site.json` `productionBranch`.)
77
- * This restores the "site respects the environment it's deployed in" binding the old
78
- * per-branch site config gave, without a per-client file to maintain.
79
- */
80
- export function resolveGitBase(cwd = process.cwd(), branch = deploymentBranch()) {
81
- const fromEnv = env('FERST_GIT_BASE');
82
- if (fromEnv) return fromEnv;
83
- let prodBranch = env('FERST_PROD_BRANCH') || 'main';
84
- try {
85
- const site = JSON.parse(readFileSync(resolve(cwd, 'ferst-site.json'), 'utf8'));
86
- if (site && typeof site.gitBase === 'string' && site.gitBase.trim()) return site.gitBase.trim();
87
- if (!env('FERST_PROD_BRANCH') && site && typeof site.productionBranch === 'string' && site.productionBranch.trim()) {
88
- prodBranch = site.productionBranch.trim();
89
- }
90
- } catch {
91
- /* no ferst-site.json (e.g. the template repo itself) — derive from the branch */
92
- }
93
- return branch === prodBranch ? PROD_GIT_BASE : DEV_GIT_BASE;
94
- }
95
-
96
- /** The deployment branch: CF Pages env → current git branch → main. */
97
- export function deploymentBranch() {
98
- const cf = env('CF_PAGES_BRANCH');
99
- if (cf) return cf;
100
- try {
101
- const git = execSync('git rev-parse --abbrev-ref HEAD', {
102
- stdio: ['ignore', 'pipe', 'ignore'],
103
- }).toString().trim();
104
- if (git && git !== 'HEAD') return git;
105
- } catch {
106
- /* not a git checkout — fall through */
107
- }
108
- return 'main';
109
- }
63
+ // The git-proxy origin lives in lib/git-base.mjs, which components can import: this file is a
64
+ // build-time script whose module scope needs a real filesystem (TEMPLATE_PATH).
65
+ export { PROD_GIT_BASE, DEV_GIT_BASE, resolveGitBase, deploymentBranch } from '../lib/git-base.mjs';
66
+ import { resolveGitBase, deploymentBranch } from '../lib/git-base.mjs';
110
67
 
111
68
  /** Ferst's managed GitHub org: its sites are on the Basic plan. */
112
69
  const MANAGED_OWNER = 'myai4webdev';
@@ -12,7 +12,7 @@
12
12
  import { readFileSync, realpathSync } from 'node:fs';
13
13
  import { resolve } from 'node:path';
14
14
  import { pathToFileURL } from 'node:url';
15
- import { resolveGitBase } from './cms-config.mjs';
15
+ import { resolveGitBase } from '../lib/git-base.mjs';
16
16
 
17
17
  /**
18
18
  * True when THIS file is run as a CLI (not imported). Each bin needs its own copy:
@@ -294,6 +294,30 @@ export function mergeSiteMeta(existing, provenance) {
294
294
  return { ...base, lastUpdate: provenance };
295
295
  }
296
296
 
297
+ /**
298
+ * What the update would leave in one template-owned file: the template's copy, then the
299
+ * same adjustments the real run makes (package.json keeps the site's own name/description and
300
+ * takes the canonical engine range; the manifest takes the canonical coreRange). The dry run
301
+ * compares this, not the raw template copy, with the site's file, so it reports only real
302
+ * changes (CTK #20, OLR #23). Pure.
303
+ */
304
+ export function scaffoldFileAfterUpdate(path, upstreamRaw, currentRaw, coreRange) {
305
+ let out = upstreamRaw;
306
+ if (path === 'package.json') {
307
+ if (currentRaw != null) out = keepClientPackageFields(out, currentRaw) ?? out;
308
+ if (coreRange) out = replacePackageCoreDep(out, coreRange) ?? out;
309
+ } else if (path === MANIFEST && coreRange) {
310
+ out = replaceCoreRange(out, coreRange) ?? out;
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /** Whether a new drift stamp records a different state than the last (the time alone isn't). */
316
+ export function stampChanged(previous, next) {
317
+ if (!previous || typeof previous !== 'object') return true;
318
+ return ['templateUpstream', 'templateCommit', 'templateVersion', 'coreVersion'].some((k) => (previous[k] ?? null) !== (next[k] ?? null));
319
+ }
320
+
297
321
  // ── thin side-effecting shell (kept out of the pure helpers above) ────────────────
298
322
 
299
323
  const exec = (cmd, cwd) => execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
@@ -413,11 +437,25 @@ function main() {
413
437
 
414
438
  let templateVersion = null; // the template version this sync brought (for the manifest + stamp)
415
439
  let scaffoldCount = 0; // files the scaffold sync touched (counted BEFORE the npm churn)
440
+ let dryScaffold = []; // dry-run only: the files the update would really change
416
441
  if (dryRun) {
442
+ // Compare what the update would leave (the site's package identity, the canonical engine
443
+ // range), not the raw template copy, so only real changes count (CTK #20, OLR #23).
417
444
  const spec = syncPaths.map(q).join(' ');
418
- const diff = (tryGit(`diff --stat ${TEMPLATE_REMOTE}/${branch} -- ${spec}`, cwd) || '').trim();
445
+ const target = noCore
446
+ ? readInstalledCoreVersion(cwd)
447
+ : (tryExec('npm view ferst-core version', cwd) || '').trim() || readInstalledCoreVersion(cwd);
448
+ const range = target ? coreRangeFor(target) : null;
449
+ const candidates = (tryGit(`diff --name-only ${TEMPLATE_REMOTE}/${branch} -- ${spec}`, cwd) || '').split(/\r?\n/).filter(Boolean);
450
+ for (const f of candidates) {
451
+ const upstreamRaw = tryGit(`show ${TEMPLATE_REMOTE}/${branch}:${f}`, cwd);
452
+ let current = null;
453
+ try { current = readFileSync(resolve(cwd, f), 'utf8'); } catch { /* a file the site doesn't have yet */ }
454
+ const lf = (t) => t.replace(/\r\n/g, '\n'); // a checkout's line endings aren't a change
455
+ if (upstreamRaw === null || current === null || lf(scaffoldFileAfterUpdate(f, lf(upstreamRaw), lf(current), range)) !== lf(current)) dryScaffold.push(f);
456
+ }
419
457
  console.log('ferst-update: --dry-run — scaffold changes that WOULD apply:');
420
- console.log(diff ? diff : ' (no scaffold changes)');
458
+ console.log(dryScaffold.length ? dryScaffold.map((f) => ` ${f}`).join('\n') : ' (no scaffold changes)');
421
459
  } else {
422
460
  // package.json is template-owned, but its identity fields are the client's — keep them.
423
461
  const pkgPath = resolve(cwd, 'package.json');
@@ -430,6 +468,10 @@ function main() {
430
468
  console.warn(`ferst-update: could not sync "${p}" (not present upstream?) — skipped.`);
431
469
  }
432
470
  }
471
+ // `git checkout <tree> -- path` also stages the template's copy. Unstage it, so the index
472
+ // never holds a copy that the steps below put back (OLR #23 saw `MM`), and a commit takes
473
+ // only what the update really changed.
474
+ tryGit(`reset -q -- ${syncPaths.map(q).join(' ')}`, cwd);
433
475
  if (clientPkg) {
434
476
  try {
435
477
  const synced = readFileSync(pkgPath, 'utf8');
@@ -522,7 +564,10 @@ function main() {
522
564
  if (dryRun) {
523
565
  const wouldCommit = templateCommit ? templateCommit.slice(0, 7) : '(unknown)';
524
566
  const wouldCore = (noCore ? readInstalledCoreVersion(cwd) : dryLatest) ?? '(unknown)';
525
- console.log(`ferst-update: --dry-run — WOULD stamp ${SITE_META}: template ${wouldCommit}, core ${wouldCore} (no write).`);
567
+ const prev = readSiteMeta(cwd)?.lastUpdate;
568
+ const wouldMove = stampChanged(prev, { ...(prev ?? {}), templateCommit: templateCommit ?? null, coreVersion: wouldCore === '(unknown)' ? prev?.coreVersion ?? null : wouldCore });
569
+ if (!dryScaffold.length && !wouldMove) console.log('ferst-update: --dry-run — nothing to update (scaffold and engine current).');
570
+ else console.log(`ferst-update: --dry-run — WOULD stamp ${SITE_META}: template ${wouldCommit}, core ${wouldCore} (no write).`);
526
571
  console.log('ferst-update: --dry-run complete (no writes, no verify).');
527
572
  return;
528
573
  }
@@ -542,10 +587,15 @@ function main() {
542
587
  // and any other existing ferst-site.json fields.
543
588
  const coreVersion = coreBump?.after ?? readInstalledCoreVersion(cwd);
544
589
  const provenance = buildProvenance({ upstream: upstream.url, commit: templateCommit, coreVersion, templateVersion: templateVersion ?? readTemplateVersion(raw) });
545
- writeFileSync(
546
- resolve(cwd, SITE_META),
547
- `${JSON.stringify(mergeSiteMeta(readSiteMeta(cwd), provenance), null, 2)}\n`,
548
- );
590
+ // Only when the site actually moved: re-stamping just the time left a pointless commit (and
591
+ // a test-copy redeploy) on every scheduled run.
592
+ const existingMeta = readSiteMeta(cwd);
593
+ const restamp = stampChanged(existingMeta?.lastUpdate, provenance);
594
+ if (restamp) {
595
+ writeFileSync(resolve(cwd, SITE_META), `${JSON.stringify(mergeSiteMeta(existingMeta, provenance), null, 2)}\n`);
596
+ } else {
597
+ console.log(`ferst-update: ${SITE_META} already records this template and engine — not re-stamped.`);
598
+ }
549
599
  const stampSha = templateCommit ? templateCommit.slice(0, 7) : '(unknown)';
550
600
 
551
601
  // 6. Summary.
@@ -554,7 +604,7 @@ function main() {
554
604
  : 'ferst-core unchanged (--no-core)';
555
605
  console.log(
556
606
  `ferst-update: DONE — scaffold files updated: ${scaffoldCount}; ${coreLine}; ` +
557
- `stamped ${SITE_META}: template ${stampSha}, core ${coreVersion ?? '(unknown)'}; build + check:thin green.`,
607
+ `${restamp ? 'stamped' : 'already recorded in'} ${SITE_META}: template ${stampSha}, core ${coreVersion ?? '(unknown)'}; build + check:thin green.`,
558
608
  );
559
609
  }
560
610
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import ContactForm from './ContactForm.astro';
9
9
  import { loadFormDefinition } from '../../lib/forms';
10
- import { resolveGitBase } from '../../bin/cms-config.mjs';
10
+ import { resolveGitBase } from '../../lib/git-base.mjs';
11
11
 
12
12
  interface Props {
13
13
  form: string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The platform git-proxy origin for this build (the CMS `base_url`, and where the Form block
3
+ * reads a form's fields). Kept apart from bin/cms-config.mjs so components can import it: that
4
+ * file is a build-time script whose module scope needs a real filesystem, which an on-request
5
+ * renderer (Cloudflare Workers) doesn't have. Nothing here runs at import time.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { execSync } from 'node:child_process';
9
+ import { resolve } from 'node:path';
10
+
11
+ function env(name) {
12
+ const v = typeof process !== 'undefined' ? process.env?.[name] : undefined;
13
+ return v && v.trim() ? v.trim() : '';
14
+ }
15
+
16
+ export const PROD_GIT_BASE = 'https://signin.ferst.co.uk';
17
+ export const DEV_GIT_BASE = 'https://signin-dev.ferst.co.uk';
18
+
19
+ /**
20
+ * The platform git-proxy origin (CMS `base_url` / `api_root`) for THIS build:
21
+ * 1. `FERST_GIT_BASE` env — explicit override, always wins.
22
+ * 2. committed `ferst-site.json` `gitBase` — an explicit per-site pin (e.g. a
23
+ * custom proxy); a BYO/seeded site can hard-bind its origin this way.
24
+ * 3. otherwise DERIVE from the deploy environment: the production branch talks to
25
+ * the prod git-proxy, every other (preview/dev) branch talks to the dev
26
+ * git-proxy. So a templated repo self-configures per environment on deploy — a
27
+ * dev-branch build points its CMS at the dev worker and main at prod — with NO
28
+ * per-client config. (The production branch defaults to `main`; override via
29
+ * `FERST_PROD_BRANCH` env or `ferst-site.json` `productionBranch`.)
30
+ * This restores the "site respects the environment it's deployed in" binding the old
31
+ * per-branch site config gave, without a per-client file to maintain.
32
+ */
33
+ export function resolveGitBase(cwd = process.cwd(), branch = deploymentBranch()) {
34
+ const fromEnv = env('FERST_GIT_BASE');
35
+ if (fromEnv) return fromEnv;
36
+ let prodBranch = env('FERST_PROD_BRANCH') || 'main';
37
+ try {
38
+ const site = JSON.parse(readFileSync(resolve(cwd, 'ferst-site.json'), 'utf8'));
39
+ if (site && typeof site.gitBase === 'string' && site.gitBase.trim()) return site.gitBase.trim();
40
+ if (!env('FERST_PROD_BRANCH') && site && typeof site.productionBranch === 'string' && site.productionBranch.trim()) {
41
+ prodBranch = site.productionBranch.trim();
42
+ }
43
+ } catch {
44
+ /* no ferst-site.json (e.g. the template repo itself) — derive from the branch */
45
+ }
46
+ return branch === prodBranch ? PROD_GIT_BASE : DEV_GIT_BASE;
47
+ }
48
+
49
+ /** The deployment branch: CF Pages env → current git branch → main. */
50
+ export function deploymentBranch() {
51
+ const cf = env('CF_PAGES_BRANCH');
52
+ if (cf) return cf;
53
+ try {
54
+ const git = execSync('git rev-parse --abbrev-ref HEAD', {
55
+ stdio: ['ignore', 'pipe', 'ignore'],
56
+ }).toString().trim();
57
+ if (git && git !== 'HEAD') return git;
58
+ } catch {
59
+ /* not a git checkout — fall through */
60
+ }
61
+ return 'main';
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "type": "module",
5
5
  "description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
6
6
  "license": "BUSL-1.1",