create-meith 0.30.1 → 0.31.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.
package/src/update.ts ADDED
@@ -0,0 +1,489 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { promisify } from 'node:util'
5
+ import { gunzipSync } from 'node:zlib'
6
+
7
+ import { DEFAULT_REPOSITORY_URL, type ScaffoldTarget, scaffold } from './scaffold'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ export const TEMPLATE_BOARD_NAME = 'meith-board'
12
+
13
+ export const TEMPLATE_REPOSITORIES: Readonly<Record<ScaffoldTarget, string>> = {
14
+ 'self-host': 'meith-dev/template',
15
+ vercel: 'meith-dev/vercel-template',
16
+ }
17
+
18
+ export const RELEASE_NOTES_URL = 'https://github.com/meith-dev/meith/releases/tag'
19
+
20
+ export function templateTarballUrl(target: ScaffoldTarget, version: string): string {
21
+ return `https://codeload.github.com/${TEMPLATE_REPOSITORIES[target]}/tar.gz/refs/tags/v${version}`
22
+ }
23
+
24
+ export function parseExactVersion(value: string): readonly [number, number, number] | null {
25
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value)
26
+ if (match === null) return null
27
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
28
+ }
29
+
30
+ export function compareExactVersions(left: string, right: string): number {
31
+ const a = parseExactVersion(left)
32
+ const b = parseExactVersion(right)
33
+ if (a === null || b === null) return 0
34
+ for (let part = 0; part < 3; part++) {
35
+ if ((a[part] as number) !== (b[part] as number))
36
+ return (a[part] as number) - (b[part] as number)
37
+ }
38
+ return 0
39
+ }
40
+
41
+ export function substituteBoardName(content: string, name: string): string {
42
+ if (name === TEMPLATE_BOARD_NAME) return content
43
+ return content.split(TEMPLATE_BOARD_NAME).join(name)
44
+ }
45
+
46
+ export function normalizeActionPins(content: string): string {
47
+ return content
48
+ .split('\n')
49
+ .map((line) => (/^\s*(- )?uses:\s/.test(line) ? line.replace(/@\S+(\s*#.*)?$/, '@pin') : line))
50
+ .join('\n')
51
+ }
52
+
53
+ function comparable(path: string, content: string): string {
54
+ return path.startsWith('.github/workflows/') ? normalizeActionPins(content) : content
55
+ }
56
+
57
+ function tarText(block: Uint8Array, start: number, length: number): string {
58
+ const slice = block.subarray(start, start + length)
59
+ const end = slice.indexOf(0)
60
+ return new TextDecoder().decode(end === -1 ? slice : slice.subarray(0, end))
61
+ }
62
+
63
+ export function unpackTemplateTarball(data: Uint8Array, name: string): Map<string, string> {
64
+ const tar = gunzipSync(data)
65
+ const files = new Map<string, string>()
66
+
67
+ for (let at = 0; at + 512 <= tar.length; ) {
68
+ const header = tar.subarray(at, at + 512)
69
+ at += 512
70
+ if (header.every((byte) => byte === 0)) break
71
+
72
+ const size = Number.parseInt(tarText(header, 124, 12).trim() || '0', 8)
73
+ const type = header[156] ?? 0
74
+ const prefix = tarText(header, 345, 155)
75
+ const path = prefix === '' ? tarText(header, 0, 100) : `${prefix}/${tarText(header, 0, 100)}`
76
+
77
+ if (type === 48 || type === 0) {
78
+ const relative = path.split('/').slice(1).join('/')
79
+ if (relative !== '') {
80
+ const content = new TextDecoder().decode(tar.subarray(at, at + size))
81
+ files.set(relative, substituteBoardName(content, name))
82
+ }
83
+ }
84
+
85
+ at += Math.ceil(size / 512) * 512
86
+ }
87
+
88
+ return files
89
+ }
90
+
91
+ export async function fetchPreviousTree(
92
+ target: ScaffoldTarget,
93
+ version: string,
94
+ name: string,
95
+ ): Promise<ReadonlyMap<string, string> | null> {
96
+ try {
97
+ const response = await fetch(templateTarballUrl(target, version), {
98
+ redirect: 'follow',
99
+ signal: AbortSignal.timeout(30_000),
100
+ })
101
+ if (!response.ok) return null
102
+ return unpackTemplateTarball(new Uint8Array(await response.arrayBuffer()), name)
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
108
+ type DependencyMap = Readonly<Record<string, string>>
109
+
110
+ interface ManifestShape {
111
+ readonly [key: string]: unknown
112
+ readonly dependencies?: DependencyMap
113
+ readonly devDependencies?: DependencyMap
114
+ readonly scripts?: Readonly<Record<string, string>>
115
+ }
116
+
117
+ function mergeDependencies(
118
+ current: DependencyMap,
119
+ next: DependencyMap,
120
+ newVersion: string,
121
+ ): Record<string, string> {
122
+ const merged: Record<string, string> = {}
123
+ for (const [name, range] of Object.entries(current)) {
124
+ merged[name] = next[name] ?? (name.startsWith('@meith/') ? newVersion : range)
125
+ }
126
+ for (const [name, range] of Object.entries(next)) {
127
+ if (!(name in merged)) merged[name] = range
128
+ }
129
+ return merged
130
+ }
131
+
132
+ function mergeScripts(
133
+ current: Readonly<Record<string, string>> | undefined,
134
+ previous: Readonly<Record<string, string>> | undefined,
135
+ next: Readonly<Record<string, string>>,
136
+ degraded: boolean,
137
+ ): Record<string, string> {
138
+ const merged: Record<string, string> = { ...(current ?? {}) }
139
+ for (const [key, value] of Object.entries(next)) {
140
+ const held = current?.[key]
141
+ if (held === undefined) {
142
+ if (degraded || previous?.[key] === undefined) merged[key] = value
143
+ continue
144
+ }
145
+ if (held === value || held === previous?.[key]) merged[key] = value
146
+ }
147
+ return merged
148
+ }
149
+
150
+ export function mergeManifest(
151
+ currentText: string,
152
+ previousText: string | null,
153
+ nextText: string,
154
+ newVersion: string,
155
+ ): string {
156
+ const current = JSON.parse(currentText) as ManifestShape
157
+ const previous = previousText === null ? null : (JSON.parse(previousText) as ManifestShape)
158
+ const next = JSON.parse(nextText) as ManifestShape
159
+
160
+ const merged: Record<string, unknown> = { ...current }
161
+
162
+ merged.dependencies = mergeDependencies(
163
+ current.dependencies ?? {},
164
+ next.dependencies ?? {},
165
+ newVersion,
166
+ )
167
+ if (current.devDependencies !== undefined) {
168
+ merged.devDependencies = mergeDependencies(current.devDependencies, {}, newVersion)
169
+ }
170
+ if (next.scripts !== undefined) {
171
+ merged.scripts = mergeScripts(
172
+ current.scripts,
173
+ previous?.scripts,
174
+ next.scripts,
175
+ previous === null,
176
+ )
177
+ }
178
+ for (const field of ['type', 'engines']) {
179
+ if (!(field in next)) continue
180
+ const untouched =
181
+ previous !== null && JSON.stringify(current[field]) === JSON.stringify(previous[field])
182
+ if (!(field in current) || untouched) merged[field] = next[field]
183
+ }
184
+
185
+ return `${JSON.stringify(merged, null, 2)}\n`
186
+ }
187
+
188
+ export interface UpdateInputs {
189
+ readonly current: ReadonlyMap<string, string>
190
+ readonly previous: ReadonlyMap<string, string> | null
191
+ readonly next: ReadonlyMap<string, string>
192
+ readonly newVersion: string
193
+ }
194
+
195
+ export interface UpdatePlan {
196
+ readonly writes: ReadonlyMap<string, string>
197
+ readonly deletes: readonly string[]
198
+ readonly created: readonly string[]
199
+ readonly updated: readonly string[]
200
+ readonly skipped: readonly string[]
201
+ readonly review: readonly string[]
202
+ }
203
+
204
+ export function planUpdate({ current, previous, next, newVersion }: UpdateInputs): UpdatePlan {
205
+ const writes = new Map<string, string>()
206
+ const deletes: string[] = []
207
+ const created: string[] = []
208
+ const updated: string[] = []
209
+ const skipped: string[] = []
210
+ const review: string[] = []
211
+
212
+ const currentManifest = current.get('package.json')
213
+ const nextManifest = next.get('package.json')
214
+ if (currentManifest !== undefined && nextManifest !== undefined) {
215
+ const merged = mergeManifest(
216
+ currentManifest,
217
+ previous?.get('package.json') ?? null,
218
+ nextManifest,
219
+ newVersion,
220
+ )
221
+ if (merged !== currentManifest) {
222
+ writes.set('package.json', merged)
223
+ updated.push('package.json')
224
+ }
225
+ }
226
+
227
+ if (previous === null) {
228
+ for (const [path, content] of next) {
229
+ if (path === 'package.json') continue
230
+ if (current.get(path) !== content) review.push(path)
231
+ }
232
+ return { writes, deletes, created, updated, skipped, review: review.sort() }
233
+ }
234
+
235
+ const paths = new Set([...next.keys(), ...previous.keys()])
236
+ paths.delete('package.json')
237
+
238
+ for (const path of [...paths].sort()) {
239
+ const held = current.get(path)
240
+ const before = previous.get(path)
241
+ const after = next.get(path)
242
+
243
+ if (after !== undefined) {
244
+ if (held === undefined) {
245
+ if (before === undefined) {
246
+ writes.set(path, after)
247
+ created.push(path)
248
+ }
249
+ continue
250
+ }
251
+ if (held === after) continue
252
+ if (before !== undefined && comparable(path, held) === comparable(path, before)) {
253
+ writes.set(path, after)
254
+ updated.push(path)
255
+ } else {
256
+ skipped.push(path)
257
+ }
258
+ continue
259
+ }
260
+
261
+ if (held === undefined || before === undefined) continue
262
+ if (comparable(path, held) === comparable(path, before)) deletes.push(path)
263
+ else skipped.push(path)
264
+ }
265
+
266
+ return { writes, deletes, created, updated, skipped: skipped.sort(), review }
267
+ }
268
+
269
+ async function exists(path: string): Promise<boolean> {
270
+ try {
271
+ await access(path)
272
+ return true
273
+ } catch {
274
+ return false
275
+ }
276
+ }
277
+
278
+ async function readIfPresent(path: string): Promise<string | null> {
279
+ try {
280
+ return await readFile(path, 'utf8')
281
+ } catch {
282
+ return null
283
+ }
284
+ }
285
+
286
+ async function refreshNpmLockfile(cwd: string): Promise<string | null> {
287
+ try {
288
+ await execFileAsync(
289
+ 'npm',
290
+ ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'],
291
+ { cwd },
292
+ )
293
+ return null
294
+ } catch {
295
+ return 'package-lock.json could not be refreshed — run `npm install` here to bring it up to date.'
296
+ }
297
+ }
298
+
299
+ export interface RunUpdateOptions {
300
+ readonly cwd?: string
301
+ readonly loadPrevious?: (
302
+ target: ScaffoldTarget,
303
+ version: string,
304
+ name: string,
305
+ ) => Promise<ReadonlyMap<string, string> | null>
306
+ readonly refreshLockfile?: (cwd: string) => Promise<string | null>
307
+ }
308
+
309
+ export interface UpdateResult {
310
+ readonly code: number
311
+ readonly lines: readonly string[]
312
+ }
313
+
314
+ export async function runUpdate(
315
+ newVersion: string,
316
+ options: RunUpdateOptions = {},
317
+ ): Promise<UpdateResult> {
318
+ const cwd = options.cwd ?? process.cwd()
319
+
320
+ const manifestText = await readIfPresent(join(cwd, 'package.json'))
321
+ if (manifestText === null) {
322
+ return {
323
+ code: 1,
324
+ lines: [
325
+ 'create-meith update: no package.json here.',
326
+ 'Run this inside the board directory — the one `npx create-meith` scaffolded,',
327
+ 'or the clone of your board repository.',
328
+ ],
329
+ }
330
+ }
331
+
332
+ let manifest: ManifestShape
333
+ try {
334
+ manifest = JSON.parse(manifestText) as ManifestShape
335
+ } catch {
336
+ return { code: 1, lines: ['create-meith update: package.json here is not valid JSON.'] }
337
+ }
338
+
339
+ const currentVersion = manifest.dependencies?.['@meith/web']
340
+ if (currentVersion === undefined) {
341
+ return {
342
+ code: 1,
343
+ lines: [
344
+ 'create-meith update: this package.json does not depend on @meith/web,',
345
+ 'so this does not look like a Meith board.',
346
+ ],
347
+ }
348
+ }
349
+
350
+ const parsedCurrent = parseExactVersion(currentVersion)
351
+ if (parsedCurrent === null) {
352
+ return {
353
+ code: 1,
354
+ lines: [
355
+ `create-meith update: @meith/web is pinned to '${currentVersion}', not an exact X.Y.Z version.`,
356
+ 'Pin it first — `npm install --save-exact @meith/web@<version>` — so the updater',
357
+ 'can tell which release this board is on.',
358
+ ],
359
+ }
360
+ }
361
+
362
+ const parsedNew = parseExactVersion(newVersion)
363
+ if (parsedNew === null) {
364
+ return {
365
+ code: 1,
366
+ lines: [`create-meith update: cannot update to '${newVersion}' — not an exact version.`],
367
+ }
368
+ }
369
+
370
+ if (compareExactVersions(currentVersion, newVersion) === 0) {
371
+ return { code: 0, lines: [`Already at ${newVersion} — nothing to update.`] }
372
+ }
373
+
374
+ if (compareExactVersions(currentVersion, newVersion) > 0) {
375
+ return {
376
+ code: 1,
377
+ lines: [
378
+ `create-meith update: this board is on ${currentVersion}, newer than ${newVersion}.`,
379
+ 'Downgrades are refused — migrations are forward-only. To update, run the',
380
+ 'newest updater: `npx create-meith@latest update`.',
381
+ ],
382
+ }
383
+ }
384
+
385
+ if ((parsedNew[0] as number) - (parsedCurrent[0] as number) > 2) {
386
+ const stage = (parsedCurrent[0] as number) + 2
387
+ return {
388
+ code: 1,
389
+ lines: [
390
+ `create-meith update: ${currentVersion} to ${newVersion} jumps more than two majors,`,
391
+ 'which is further than upgrades are tested to span. Update in stages instead —',
392
+ `\`npx create-meith@${stage} update\` first, deploy and run \`meith upgrade\`,`,
393
+ 'then come back to `npx create-meith@latest update`.',
394
+ ],
395
+ }
396
+ }
397
+
398
+ const name =
399
+ typeof manifest.name === 'string' && manifest.name !== '' ? manifest.name : TEMPLATE_BOARD_NAME
400
+ const target: ScaffoldTarget = (await exists(join(cwd, 'vercel.json'))) ? 'vercel' : 'self-host'
401
+
402
+ const next = scaffold({
403
+ name,
404
+ version: newVersion,
405
+ repositoryUrl: DEFAULT_REPOSITORY_URL,
406
+ target,
407
+ })
408
+ const previous = await (options.loadPrevious ?? fetchPreviousTree)(target, currentVersion, name)
409
+
410
+ const paths = new Set(['package.json', ...next.keys(), ...(previous?.keys() ?? [])])
411
+ const current = new Map<string, string>()
412
+ for (const path of paths) {
413
+ const content = await readIfPresent(join(cwd, path))
414
+ if (content !== null) current.set(path, content)
415
+ }
416
+
417
+ const plan = planUpdate({ current, previous, next, newVersion })
418
+
419
+ for (const [path, content] of plan.writes) {
420
+ const absolute = join(cwd, path)
421
+ await mkdir(dirname(absolute), { recursive: true })
422
+ await writeFile(absolute, content, 'utf8')
423
+ }
424
+ for (const path of plan.deletes) {
425
+ await rm(join(cwd, path), { force: true })
426
+ }
427
+
428
+ const lockfileWarning =
429
+ plan.writes.size > 0 && (await exists(join(cwd, 'package-lock.json')))
430
+ ? await (options.refreshLockfile ?? refreshNpmLockfile)(cwd)
431
+ : null
432
+
433
+ const templateUrl = `https://github.com/${TEMPLATE_REPOSITORIES[target]}`
434
+ const nextManifest = JSON.parse(next.get('package.json') ?? '{}') as ManifestShape
435
+ const nextPin = nextManifest.dependencies?.next
436
+
437
+ const lines: string[] = []
438
+ const changed = plan.writes.size + plan.deletes.length
439
+
440
+ if (changed === 0) {
441
+ lines.push(`Nothing to write — every file already matches ${newVersion}.`)
442
+ } else {
443
+ lines.push(
444
+ `Updated ${name} from ${currentVersion} to ${newVersion} — ${changed} file${changed === 1 ? '' : 's'} changed.`,
445
+ '',
446
+ )
447
+ }
448
+
449
+ for (const path of plan.updated) {
450
+ lines.push(
451
+ path === 'package.json'
452
+ ? ` updated package.json — every @meith/* pin to ${newVersion}${nextPin === undefined ? '' : `, next to ${nextPin}`}`
453
+ : ` updated ${path}`,
454
+ )
455
+ }
456
+ for (const path of plan.created) lines.push(` added ${path}`)
457
+ for (const path of plan.deletes) lines.push(` removed ${path} — this release no longer ships it`)
458
+ for (const path of plan.skipped) {
459
+ lines.push(
460
+ ` kept ${path} — it differs from the ${currentVersion} scaffold; compare it with ${templateUrl} at v${newVersion}`,
461
+ )
462
+ }
463
+
464
+ if (previous === null) {
465
+ lines.push(
466
+ '',
467
+ `Could not read the v${currentVersion} template from ${templateUrl}, so only`,
468
+ 'package.json was updated. Review these files against that repository at',
469
+ `v${newVersion} — this release may have changed them:`,
470
+ ...plan.review.map((path) => ` ${path}`),
471
+ )
472
+ }
473
+
474
+ if (lockfileWarning !== null) lines.push('', lockfileWarning)
475
+
476
+ if (changed > 0) {
477
+ lines.push(
478
+ '',
479
+ 'Next:',
480
+ ' 1. Review the diff, commit, and push.',
481
+ ' 2. Take a backup, then deploy. The release notes name the migrations:',
482
+ ` ${RELEASE_NOTES_URL}/v${newVersion}`,
483
+ ' 3. Once the new version serves, run `meith upgrade` against the board —',
484
+ ' the admin panel shows a notice until it has run.',
485
+ )
486
+ }
487
+
488
+ return { code: 0, lines }
489
+ }