ferst-core 0.6.0 → 0.6.2
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/bin/ferst-update.mjs +388 -0
- package/package.json +3 -2
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ferst-update — bring a seeded thin-client repo up to date, from the repo alone:
|
|
3
|
+
//
|
|
4
|
+
// 1. latest template SCAFFOLD (via git: overwrite the template-owned paths)
|
|
5
|
+
// 2. latest ENGINE (ferst-core) (via npm: install @latest, align coreRange)
|
|
6
|
+
// 3. VERIFY (npm run build && npm run check:thin)
|
|
7
|
+
//
|
|
8
|
+
// Run it in a client repo root:
|
|
9
|
+
//
|
|
10
|
+
// npx --no-install ferst-update # sync scaffold + bump core + verify
|
|
11
|
+
// npx --no-install ferst-update --dry-run # fetch + show what WOULD change; no writes
|
|
12
|
+
// npx --no-install ferst-update --no-core # scaffold sync only; skip the npm bump
|
|
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 —
|
|
16
|
+
// the scaffold sync is a per-path OVERWRITE from upstream, conflict-free by
|
|
17
|
+
// construction (see docs/CLIENT-TEMPLATE-ARCHITECTURE.md, Workstream D). It never
|
|
18
|
+
// merges/rebases (the CMS bot commits to client repos continuously; a history merge
|
|
19
|
+
// would race live content).
|
|
20
|
+
//
|
|
21
|
+
// On a failed build/check after a `@latest` bump the tree is LEFT AS-IS and the exit
|
|
22
|
+
// is non-zero: a failed latest bump is a real incompatibility to surface for a
|
|
23
|
+
// human/agent to fix, not to hide behind a silent revert.
|
|
24
|
+
import { readFileSync, writeFileSync, realpathSync } from 'node:fs';
|
|
25
|
+
import { execSync } from 'node:child_process';
|
|
26
|
+
import { resolve } from 'node:path';
|
|
27
|
+
import { pathToFileURL } from 'node:url';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when this file is being run as a CLI (not imported). Handles npm's
|
|
31
|
+
* `node_modules/.bin` **symlink**: there `process.argv[1]` is the symlink path but
|
|
32
|
+
* `import.meta.url` is the realpath, so a plain `===` check silently skips `main()`
|
|
33
|
+
* — the bug this guards against.
|
|
34
|
+
*/
|
|
35
|
+
export function isDirectInvocation(argv1) {
|
|
36
|
+
if (!argv1) return false;
|
|
37
|
+
if (import.meta.url === pathToFileURL(argv1).href) return true;
|
|
38
|
+
try {
|
|
39
|
+
return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fallback template upstream when the manifest doesn't pin one (the template is the SSOT). */
|
|
46
|
+
export const DEFAULT_UPSTREAM = 'https://github.com/MyAI4WebDev/ferst-client-template.git';
|
|
47
|
+
/** The ownership manifest a seeded client repo carries at its root. */
|
|
48
|
+
export const MANIFEST = 'ferst-template.json';
|
|
49
|
+
/** The per-site provenance/config file a client repo carries at its root (gitBase, etc.). */
|
|
50
|
+
export const SITE_META = 'ferst-site.json';
|
|
51
|
+
|
|
52
|
+
// ── pure helpers (unit-tested; no shell, no fs writes) ───────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Parse the flags this bin understands. Pass `process.argv`. */
|
|
55
|
+
export function parseArgs(argv) {
|
|
56
|
+
const args = argv.slice(2);
|
|
57
|
+
return {
|
|
58
|
+
dryRun: args.includes('--dry-run'),
|
|
59
|
+
noCore: args.includes('--no-core'),
|
|
60
|
+
help: args.includes('--help') || args.includes('-h'),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parse + shallow-validate the manifest JSON. Throws with a clear message. */
|
|
65
|
+
export function parseManifest(raw) {
|
|
66
|
+
let obj;
|
|
67
|
+
try {
|
|
68
|
+
obj = JSON.parse(raw);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new Error(`ferst-update: ${MANIFEST} is not valid JSON — ${e.message}`);
|
|
71
|
+
}
|
|
72
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
73
|
+
throw new Error(`ferst-update: ${MANIFEST} must be a JSON object.`);
|
|
74
|
+
}
|
|
75
|
+
return obj;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The template-owned paths to overwrite from upstream. Throws if missing/empty. */
|
|
79
|
+
export function resolveOwnedPaths(manifest) {
|
|
80
|
+
const owned = manifest.ownedPaths;
|
|
81
|
+
if (!Array.isArray(owned) || owned.length === 0) {
|
|
82
|
+
throw new Error(`ferst-update: ${MANIFEST} declares no "ownedPaths" — nothing to sync.`);
|
|
83
|
+
}
|
|
84
|
+
if (owned.some((p) => typeof p !== 'string' || !p.trim())) {
|
|
85
|
+
throw new Error(`ferst-update: ${MANIFEST} "ownedPaths" must all be non-empty strings.`);
|
|
86
|
+
}
|
|
87
|
+
return owned;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The template git URL to sync the scaffold from. Uses the manifest's `upstream`
|
|
92
|
+
* when present; otherwise falls back to the canonical template repo and flags it so
|
|
93
|
+
* the caller can warn (older templates predate the `upstream` field).
|
|
94
|
+
* @returns {{ url: string, defaulted: boolean }}
|
|
95
|
+
*/
|
|
96
|
+
export function resolveUpstream(manifest) {
|
|
97
|
+
const u = manifest.upstream;
|
|
98
|
+
if (typeof u === 'string' && u.trim()) return { url: u.trim(), defaulted: false };
|
|
99
|
+
return { url: DEFAULT_UPSTREAM, defaulted: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The caret range that pins an installed version to its major.minor (e.g. 0.6.3 → ^0.6). */
|
|
103
|
+
export function coreRangeFor(version) {
|
|
104
|
+
const m = String(version).trim().replace(/^v/, '').match(/^(\d+)\.(\d+)/);
|
|
105
|
+
if (!m) throw new Error(`ferst-update: could not parse ferst-core version "${version}".`);
|
|
106
|
+
return `^${m[1]}.${m[2]}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read the `version` out of a package.json's text. Throws if absent. */
|
|
110
|
+
export function extractVersion(pkgJsonText) {
|
|
111
|
+
let v;
|
|
112
|
+
try {
|
|
113
|
+
v = JSON.parse(pkgJsonText)?.version;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
throw new Error(`ferst-update: could not parse installed package.json — ${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
if (typeof v !== 'string' || !v.trim()) {
|
|
118
|
+
throw new Error('ferst-update: installed ferst-core has no version.');
|
|
119
|
+
}
|
|
120
|
+
return v.trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Decide whether/what the manifest's `coreRange` should become for a freshly
|
|
125
|
+
* installed version. Pure: the write is the caller's job.
|
|
126
|
+
* @returns {{ next: string, changed: boolean }}
|
|
127
|
+
*/
|
|
128
|
+
export function computeCoreRangeUpdate(currentRange, installedVersion) {
|
|
129
|
+
const next = coreRangeFor(installedVersion);
|
|
130
|
+
return { next, changed: currentRange !== next };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Replace the manifest's `coreRange` value in place, preserving the file's exact
|
|
135
|
+
* formatting/comments (regex, like ferst-set-cms-branch stamps `branch:`). Returns
|
|
136
|
+
* the new text, or null when there is no `coreRange` field to replace.
|
|
137
|
+
*/
|
|
138
|
+
export function replaceCoreRange(raw, next) {
|
|
139
|
+
const RANGE = /("coreRange"\s*:\s*")[^"]*(")/;
|
|
140
|
+
if (!RANGE.test(raw)) return null;
|
|
141
|
+
return raw.replace(RANGE, `$1${next}$2`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The default branch name from `git ls-remote --symref <remote> HEAD` output
|
|
146
|
+
* (`ref: refs/heads/<branch>\tHEAD`). Null when it can't be read. Pure.
|
|
147
|
+
*/
|
|
148
|
+
export function parseDefaultBranch(symrefOutput) {
|
|
149
|
+
if (!symrefOutput) return null;
|
|
150
|
+
const m = symrefOutput.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/);
|
|
151
|
+
return m ? m[1] : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The last `n` lines of a (possibly huge) command output, for a concise failure tail. */
|
|
155
|
+
export function tail(text, n = 40) {
|
|
156
|
+
const lines = String(text ?? '').replace(/\s+$/, '').split(/\r?\n/);
|
|
157
|
+
return lines.slice(-n).join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the provenance "drift stamp" for a completed update: WHICH template commit and
|
|
162
|
+
* core version this repo landed on, and when. Pure — the write is the caller's job.
|
|
163
|
+
* @param {{ upstream?: string, commit?: string|null, coreVersion?: string|null, now?: Date|string }} p
|
|
164
|
+
* @returns {{ templateUpstream: string|null, templateCommit: string|null, coreVersion: string|null, at: string }}
|
|
165
|
+
*/
|
|
166
|
+
export function buildProvenance({ upstream, commit, coreVersion, now = new Date() } = {}) {
|
|
167
|
+
return {
|
|
168
|
+
templateUpstream: upstream ?? null,
|
|
169
|
+
templateCommit: commit ?? null,
|
|
170
|
+
coreVersion: coreVersion ?? null,
|
|
171
|
+
at: typeof now === 'string' ? now : now.toISOString(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Merge a provenance stamp into an existing ferst-site.json object under `lastUpdate`,
|
|
177
|
+
* preserving every other field (gitBase, productionBranch, unknown keys) and overwriting
|
|
178
|
+
* any prior `lastUpdate`. Pass null (no ferst-site.json yet) → a fresh object with just
|
|
179
|
+
* the stamp. Pure — never mutates the input.
|
|
180
|
+
* @returns {object} the object to write back
|
|
181
|
+
*/
|
|
182
|
+
export function mergeSiteMeta(existing, provenance) {
|
|
183
|
+
const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
|
184
|
+
return { ...base, lastUpdate: provenance };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── thin side-effecting shell (kept out of the pure helpers above) ────────────────
|
|
188
|
+
|
|
189
|
+
const exec = (cmd, cwd) => execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
190
|
+
const tryExec = (cmd, cwd) => { try { return exec(cmd, cwd); } catch { return null; } };
|
|
191
|
+
const git = (args, cwd) => exec(`git ${args}`, cwd);
|
|
192
|
+
const tryGit = (args, cwd) => tryExec(`git ${args}`, cwd);
|
|
193
|
+
const q = (s) => `"${s}"`;
|
|
194
|
+
|
|
195
|
+
/** Run a command capturing combined stdout+stderr and the exit code (never throws). */
|
|
196
|
+
function capture(cmd, cwd) {
|
|
197
|
+
try {
|
|
198
|
+
return { code: 0, out: exec(cmd, cwd) };
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return { code: typeof e.status === 'number' ? e.status : 1, out: `${e.stdout ?? ''}${e.stderr ?? ''}` };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The ferst-core version currently installed in the client's node_modules, or null. */
|
|
205
|
+
function readInstalledCoreVersion(cwd) {
|
|
206
|
+
try {
|
|
207
|
+
return extractVersion(readFileSync(resolve(cwd, 'node_modules/ferst-core/package.json'), 'utf8'));
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The repo's ferst-site.json parsed to an object, or null when absent/unreadable/non-object. */
|
|
214
|
+
function readSiteMeta(cwd) {
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(readFileSync(resolve(cwd, SITE_META), 'utf8'));
|
|
217
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Ensure an `upstream` remote points at `url` (add, or set-url if it already exists). */
|
|
224
|
+
function ensureUpstream(url, cwd) {
|
|
225
|
+
const remotes = (tryGit('remote', cwd) || '').split(/\r?\n/).map((s) => s.trim());
|
|
226
|
+
if (remotes.includes('upstream')) git(`remote set-url upstream ${q(url)}`, cwd);
|
|
227
|
+
else git(`remote add upstream ${q(url)}`, cwd);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function printHelp() {
|
|
231
|
+
console.log(`ferst-update — bring a seeded thin-client repo up to date (scaffold + ferst-core).
|
|
232
|
+
|
|
233
|
+
Usage (run in a client repo root):
|
|
234
|
+
ferst-update sync template scaffold, bump ferst-core@latest, then build + check:thin
|
|
235
|
+
ferst-update --dry-run fetch upstream and show what WOULD change; make no writes
|
|
236
|
+
ferst-update --no-core scaffold sync only; skip the ferst-core npm bump
|
|
237
|
+
ferst-update --help show this help
|
|
238
|
+
|
|
239
|
+
Reads ferst-template.json for the upstream template URL and the template-owned paths.
|
|
240
|
+
Client paths are never touched. On build/check failure the tree is left as-is (exit 1).`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function main() {
|
|
244
|
+
const { dryRun, noCore, help } = parseArgs(process.argv);
|
|
245
|
+
if (help) { printHelp(); return; }
|
|
246
|
+
const cwd = process.cwd();
|
|
247
|
+
|
|
248
|
+
// 1. Read + validate the ownership manifest.
|
|
249
|
+
const manifestPath = resolve(cwd, MANIFEST);
|
|
250
|
+
let raw;
|
|
251
|
+
try {
|
|
252
|
+
raw = readFileSync(manifestPath, 'utf8');
|
|
253
|
+
} catch {
|
|
254
|
+
console.error(`ferst-update: no ${MANIFEST} in ${cwd} — run this in a seeded client repo root.`);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
let manifest, ownedPaths, upstream;
|
|
258
|
+
try {
|
|
259
|
+
manifest = parseManifest(raw);
|
|
260
|
+
ownedPaths = resolveOwnedPaths(manifest);
|
|
261
|
+
upstream = resolveUpstream(manifest);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
console.error(String(e.message ?? e));
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
if (upstream.defaulted) {
|
|
267
|
+
console.warn(`ferst-update: ${MANIFEST} has no "upstream" — defaulting to ${DEFAULT_UPSTREAM}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Git scaffold sync — overwrite the template-owned paths from upstream's default
|
|
271
|
+
// branch. Never a merge/rebase; path-checkout works across unrelated histories.
|
|
272
|
+
try {
|
|
273
|
+
ensureUpstream(upstream.url, cwd);
|
|
274
|
+
console.log(`ferst-update: fetching ${upstream.url} ...`);
|
|
275
|
+
git('fetch upstream', cwd);
|
|
276
|
+
} catch (e) {
|
|
277
|
+
console.error(`ferst-update: git fetch failed — ${tail(e.stderr || e.message, 10)}`);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
const branch = parseDefaultBranch(tryGit('ls-remote --symref upstream HEAD', cwd)) || 'main';
|
|
281
|
+
console.log(`ferst-update: upstream default branch = ${branch}`);
|
|
282
|
+
// The exact template commit this run syncs from (for the provenance stamp / #18 ledger).
|
|
283
|
+
const templateCommit = (tryGit(`rev-parse upstream/${branch}`, cwd) || '').trim() || null;
|
|
284
|
+
|
|
285
|
+
let scaffoldCount = 0; // files the scaffold sync touched (counted BEFORE the npm churn)
|
|
286
|
+
if (dryRun) {
|
|
287
|
+
const spec = ownedPaths.map(q).join(' ');
|
|
288
|
+
const diff = (tryGit(`diff --stat upstream/${branch} -- ${spec}`, cwd) || '').trim();
|
|
289
|
+
console.log('ferst-update: --dry-run — scaffold changes that WOULD apply:');
|
|
290
|
+
console.log(diff ? diff : ' (no scaffold changes)');
|
|
291
|
+
} else {
|
|
292
|
+
for (const p of ownedPaths) {
|
|
293
|
+
// Per-path so one path that upstream no longer ships (pathspec no-match) can't
|
|
294
|
+
// abort the whole sync — warn and continue.
|
|
295
|
+
if (tryGit(`checkout upstream/${branch} -- ${q(p)}`, cwd) === null) {
|
|
296
|
+
console.warn(`ferst-update: could not sync "${p}" (not present upstream?) — skipped.`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const status = (tryGit('status --short', cwd) || '').trim();
|
|
300
|
+
scaffoldCount = status ? status.split(/\r?\n/).filter(Boolean).length : 0;
|
|
301
|
+
console.log('ferst-update: scaffold synced. git status --short:');
|
|
302
|
+
console.log(status ? status : ' (working tree clean — scaffold already current)');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 3. Engine (ferst-core) update.
|
|
306
|
+
let coreBump = null; // { before, after } once bumped
|
|
307
|
+
let dryLatest = null; // dry-run only: the version `@latest` WOULD install (for the stamp preview)
|
|
308
|
+
if (noCore) {
|
|
309
|
+
console.log('ferst-update: --no-core — skipping the ferst-core npm bump.');
|
|
310
|
+
} else if (dryRun) {
|
|
311
|
+
dryLatest = (tryExec('npm view ferst-core version', cwd) || '').trim() || null;
|
|
312
|
+
const current = readInstalledCoreVersion(cwd);
|
|
313
|
+
console.log(`ferst-update: ferst-core installed=${current ?? '(none)'} latest=${dryLatest || '(unknown)'}`);
|
|
314
|
+
} else {
|
|
315
|
+
const before = readInstalledCoreVersion(cwd);
|
|
316
|
+
console.log('ferst-update: npm install ferst-core@latest --save ...');
|
|
317
|
+
const r = capture('npm install ferst-core@latest --save', cwd);
|
|
318
|
+
if (r.code !== 0) {
|
|
319
|
+
console.error(`ferst-update: FAILED at "npm install ferst-core@latest" (exit ${r.code}).`);
|
|
320
|
+
console.error(tail(r.out, 40));
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
const after = readInstalledCoreVersion(cwd);
|
|
324
|
+
if (!after) {
|
|
325
|
+
console.error('ferst-update: ferst-core not found in node_modules after install.');
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
coreBump = { before, after };
|
|
329
|
+
|
|
330
|
+
// Align the manifest's coreRange to the installed major.minor.
|
|
331
|
+
const currentRange = typeof manifest.coreRange === 'string' ? manifest.coreRange : '';
|
|
332
|
+
const { next, changed } = computeCoreRangeUpdate(currentRange, after);
|
|
333
|
+
if (changed) {
|
|
334
|
+
const rewritten = replaceCoreRange(raw, next);
|
|
335
|
+
if (rewritten) {
|
|
336
|
+
writeFileSync(manifestPath, rewritten);
|
|
337
|
+
console.log(`ferst-update: coreRange ${currentRange || '(unset)'} → ${next}`);
|
|
338
|
+
} else {
|
|
339
|
+
console.warn(`ferst-update: no "coreRange" field in ${MANIFEST} to update (installed ${after}).`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 4. Verify — a failed @latest bump is a real incompatibility to surface, so on
|
|
345
|
+
// failure we print the tail and exit non-zero WITHOUT reverting.
|
|
346
|
+
if (dryRun) {
|
|
347
|
+
const wouldCommit = templateCommit ? templateCommit.slice(0, 7) : '(unknown)';
|
|
348
|
+
const wouldCore = (noCore ? readInstalledCoreVersion(cwd) : dryLatest) ?? '(unknown)';
|
|
349
|
+
console.log(`ferst-update: --dry-run — WOULD stamp ${SITE_META}: template ${wouldCommit}, core ${wouldCore} (no write).`);
|
|
350
|
+
console.log('ferst-update: --dry-run complete (no writes, no verify).');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
for (const step of ['build', 'check:thin']) {
|
|
354
|
+
console.log(`ferst-update: npm run ${step} ...`);
|
|
355
|
+
const r = capture(`npm run ${step}`, cwd);
|
|
356
|
+
if (r.code !== 0) {
|
|
357
|
+
console.error(`ferst-update: FAILED at "npm run ${step}" (exit ${r.code}). Tree left as-is for a human/agent to fix.`);
|
|
358
|
+
console.error(tail(r.out, 40));
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 5. Provenance drift stamp — record WHICH template commit + core version this repo
|
|
364
|
+
// landed on (only after a fully-successful update), so the repo and the future fleet
|
|
365
|
+
// drift ledger (#18) always know its state. MERGE: preserve gitBase/productionBranch
|
|
366
|
+
// and any other existing ferst-site.json fields.
|
|
367
|
+
const coreVersion = coreBump?.after ?? readInstalledCoreVersion(cwd);
|
|
368
|
+
const provenance = buildProvenance({ upstream: upstream.url, commit: templateCommit, coreVersion });
|
|
369
|
+
writeFileSync(
|
|
370
|
+
resolve(cwd, SITE_META),
|
|
371
|
+
`${JSON.stringify(mergeSiteMeta(readSiteMeta(cwd), provenance), null, 2)}\n`,
|
|
372
|
+
);
|
|
373
|
+
const stampSha = templateCommit ? templateCommit.slice(0, 7) : '(unknown)';
|
|
374
|
+
|
|
375
|
+
// 6. Summary.
|
|
376
|
+
const coreLine = coreBump
|
|
377
|
+
? `ferst-core ${coreBump.before ?? '(none)'} → ${coreBump.after}`
|
|
378
|
+
: 'ferst-core unchanged (--no-core)';
|
|
379
|
+
console.log(
|
|
380
|
+
`ferst-update: DONE — scaffold files updated: ${scaffoldCount}; ${coreLine}; ` +
|
|
381
|
+
`stamped ${SITE_META}: template ${stampSha}, core ${coreVersion ?? '(unknown)'}; build + check:thin green.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Run when invoked directly as a bin — including via npm's node_modules/.bin symlink.
|
|
386
|
+
if (isDirectInvocation(process.argv[1])) {
|
|
387
|
+
main();
|
|
388
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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",
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"bin": {
|
|
51
51
|
"ferst-check-thin": "bin/check-thin.mjs",
|
|
52
52
|
"ferst-set-cms-branch": "bin/set-cms-branch.mjs",
|
|
53
|
-
"ferst-cms-config": "bin/cms-config.mjs"
|
|
53
|
+
"ferst-cms-config": "bin/cms-config.mjs",
|
|
54
|
+
"ferst-update": "bin/ferst-update.mjs"
|
|
54
55
|
},
|
|
55
56
|
"dependencies": {
|
|
56
57
|
"zod": "^3.25.0"
|