ferst-core 0.7.12 → 0.8.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.
@@ -11,8 +11,9 @@
11
11
  // npx --no-install ferst-update --dry-run # fetch + show what WOULD change; no writes
12
12
  // npx --no-install ferst-update --no-core # scaffold sync only; skip the npm bump
13
13
  //
14
- // The template-owned paths and the git upstream come from the repo's own
15
- // `ferst-template.json` (the ownership manifest). Client paths are NEVER touched —
14
+ // The git upstream comes from the repo's own `ferst-template.json` (the ownership
15
+ // manifest); the paths to sync come from the TEMPLATE's manifest at its latest version, so
16
+ // a path it newly owns arrives in the same run (falling back to the repo's list). Client paths are NEVER touched —
16
17
  // the scaffold sync is a per-path OVERWRITE from upstream, conflict-free by
17
18
  // construction (see docs/CLIENT-TEMPLATE-ARCHITECTURE.md, Workstream D). It never
18
19
  // merges/rebases (the CMS bot commits to client repos continuously; a history merge
@@ -87,6 +88,35 @@ export function resolveOwnedPaths(manifest) {
87
88
  return owned;
88
89
  }
89
90
 
91
+ /**
92
+ * The paths to sync: the TEMPLATE's own list at its latest version, so a path it newly
93
+ * owns arrives in the same run. Falls back to the site's list when the template's
94
+ * manifest can't be read or declares none (an older template, a fetch problem).
95
+ */
96
+ export function ownedPathsToSync(localOwned, upstreamManifestRaw) {
97
+ try {
98
+ return resolveOwnedPaths(parseManifest(upstreamManifestRaw));
99
+ } catch {
100
+ return localOwned;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The manifest text to build on after the sync. The manifest is itself template-owned, so
106
+ * the sync has just replaced it: build on that, or the site's old ownedPaths would be
107
+ * written back over the template's new ones. Falls back to the pre-sync text when the file
108
+ * is missing or unreadable.
109
+ */
110
+ export function manifestAfterSync(preSyncRaw, postSyncRaw) {
111
+ if (typeof postSyncRaw !== 'string') return preSyncRaw;
112
+ try {
113
+ parseManifest(postSyncRaw);
114
+ return postSyncRaw;
115
+ } catch {
116
+ return preSyncRaw;
117
+ }
118
+ }
119
+
90
120
  /**
91
121
  * The template git URL to sync the scaffold from. Uses the manifest's `upstream`
92
122
  * when present; otherwise falls back to the canonical template repo and flags it so
@@ -280,11 +310,27 @@ function readSiteMeta(cwd) {
280
310
  }
281
311
  }
282
312
 
283
- /** Ensure an `upstream` remote points at `url` (add, or set-url if it already exists). */
284
- function ensureUpstream(url, cwd) {
313
+ /**
314
+ * The git remote the template is fetched through. Never `upstream`, `github` or `origin`:
315
+ * `gh` takes the first of those it finds for "this repository", so a template remote named
316
+ * `upstream` made `gh` act on the public template instead of the site.
317
+ */
318
+ export const TEMPLATE_REMOTE = 'ferst-template';
319
+
320
+ /** Is this an older run's `upstream` remote that points at the template (safe to drop)? */
321
+ export function isLeftoverTemplateRemote(name, remoteUrl, templateUrl) {
322
+ const norm = (u) => String(u ?? '').trim().replace(/\/+$/, '').replace(/\.git$/, '').toLowerCase();
323
+ return name === 'upstream' && norm(remoteUrl) !== '' && norm(remoteUrl) === norm(templateUrl);
324
+ }
325
+
326
+ /** Ensure TEMPLATE_REMOTE points at `url`, and drop an older run's `upstream` template remote. */
327
+ function ensureTemplateRemote(url, cwd) {
285
328
  const remotes = (tryGit('remote', cwd) || '').split(/\r?\n/).map((s) => s.trim());
286
- if (remotes.includes('upstream')) git(`remote set-url upstream ${q(url)}`, cwd);
287
- else git(`remote add upstream ${q(url)}`, cwd);
329
+ if (remotes.includes('upstream') && isLeftoverTemplateRemote('upstream', tryGit('remote get-url upstream', cwd), url)) {
330
+ git('remote remove upstream', cwd);
331
+ }
332
+ if (remotes.includes(TEMPLATE_REMOTE)) git(`remote set-url ${TEMPLATE_REMOTE} ${q(url)}`, cwd);
333
+ else git(`remote add ${TEMPLATE_REMOTE} ${q(url)}`, cwd);
288
334
  }
289
335
 
290
336
  function printHelp() {
@@ -330,23 +376,26 @@ function main() {
330
376
  // 2. Git scaffold sync — overwrite the template-owned paths from upstream's default
331
377
  // branch. Never a merge/rebase; path-checkout works across unrelated histories.
332
378
  try {
333
- ensureUpstream(upstream.url, cwd);
379
+ ensureTemplateRemote(upstream.url, cwd);
334
380
  console.log(`ferst-update: fetching ${upstream.url} ...`);
335
- git('fetch upstream', cwd);
381
+ git(`fetch ${TEMPLATE_REMOTE}`, cwd);
336
382
  } catch (e) {
337
383
  console.error(`ferst-update: git fetch failed — ${tail(e.stderr || e.message, 10)}`);
338
384
  process.exit(1);
339
385
  }
340
- const branch = parseDefaultBranch(tryGit('ls-remote --symref upstream HEAD', cwd)) || 'main';
386
+ const branch = parseDefaultBranch(tryGit(`ls-remote --symref ${TEMPLATE_REMOTE} HEAD`, cwd)) || 'main';
341
387
  console.log(`ferst-update: upstream default branch = ${branch}`);
342
388
  // The exact template commit this run syncs from (for the provenance stamp / #18 ledger).
343
- const templateCommit = (tryGit(`rev-parse upstream/${branch}`, cwd) || '').trim() || null;
389
+ const templateCommit = (tryGit(`rev-parse ${TEMPLATE_REMOTE}/${branch}`, cwd) || '').trim() || null;
390
+ // The template's own manifest decides what it owns now (see ownedPathsToSync).
391
+ const upstreamManifestRaw = tryGit(`show ${TEMPLATE_REMOTE}/${branch}:${MANIFEST}`, cwd) || '';
392
+ const syncPaths = ownedPathsToSync(ownedPaths, upstreamManifestRaw);
344
393
 
345
394
  let templateVersion = null; // the template version this sync brought (for the manifest + stamp)
346
395
  let scaffoldCount = 0; // files the scaffold sync touched (counted BEFORE the npm churn)
347
396
  if (dryRun) {
348
- const spec = ownedPaths.map(q).join(' ');
349
- const diff = (tryGit(`diff --stat upstream/${branch} -- ${spec}`, cwd) || '').trim();
397
+ const spec = syncPaths.map(q).join(' ');
398
+ const diff = (tryGit(`diff --stat ${TEMPLATE_REMOTE}/${branch} -- ${spec}`, cwd) || '').trim();
350
399
  console.log('ferst-update: --dry-run — scaffold changes that WOULD apply:');
351
400
  console.log(diff ? diff : ' (no scaffold changes)');
352
401
  } else {
@@ -354,10 +403,10 @@ function main() {
354
403
  const pkgPath = resolve(cwd, 'package.json');
355
404
  let clientPkg = null;
356
405
  try { clientPkg = readFileSync(pkgPath, 'utf8'); } catch { /* none yet */ }
357
- for (const p of ownedPaths) {
406
+ for (const p of syncPaths) {
358
407
  // Per-path so one path that upstream no longer ships (pathspec no-match) can't
359
408
  // abort the whole sync — warn and continue.
360
- if (tryGit(`checkout upstream/${branch} -- ${q(p)}`, cwd) === null) {
409
+ if (tryGit(`checkout ${TEMPLATE_REMOTE}/${branch} -- ${q(p)}`, cwd) === null) {
361
410
  console.warn(`ferst-update: could not sync "${p}" (not present upstream?) — skipped.`);
362
411
  }
363
412
  }
@@ -371,9 +420,15 @@ function main() {
371
420
  }
372
421
  } catch { /* package.json not synced — nothing to restore */ }
373
422
  }
374
- // The manifest isn't a synced path (it holds the client's own settings), so carry the
375
- // template's version across explicitly; `raw` is what the coreRange step builds on.
376
- const upstreamVersion = readTemplateVersion(tryGit(`show upstream/${branch}:${MANIFEST}`, cwd) || '');
423
+ // The manifest is itself template-owned and was just synced: build on that text (see
424
+ // manifestAfterSync), so the coreRange step and the stamp see the template's manifest.
425
+ let syncedManifest = null;
426
+ try { syncedManifest = readFileSync(manifestPath, 'utf8'); } catch { /* not synced */ }
427
+ raw = manifestAfterSync(raw, syncedManifest);
428
+ manifest = parseManifest(raw);
429
+ // Carry the template's version across explicitly too, for a site whose manifest isn't
430
+ // among the synced paths; `raw` is what the coreRange step builds on.
431
+ const upstreamVersion = readTemplateVersion(upstreamManifestRaw);
377
432
  if (upstreamVersion) {
378
433
  const bumped = replaceTemplateVersion(raw, upstreamVersion);
379
434
  if (bumped && bumped !== raw) {