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/dist/bin.mjs +576 -39
- package/package.json +1 -1
- package/src/bin.ts +1 -1
- package/src/cli.ts +17 -2
- package/src/index.ts +19 -0
- package/src/scaffold.ts +195 -26
- package/src/update.ts +489 -0
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
|
+
}
|