@lightnet/cli 4.1.1 → 4.2.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/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/check-files.js +833 -0
- package/src/check-translations.js +15 -0
- package/src/index.js +34 -3
- package/src/support/cli-error.js +1 -0
- package/src/support/r2.js +561 -0
- package/src/types.d.ts +0 -10
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { execFile } from "node:child_process"
|
|
4
|
+
import { access, readdir, readFile, unlink } from "node:fs/promises"
|
|
5
|
+
import { join, posix, relative, resolve } from "node:path"
|
|
6
|
+
import {
|
|
7
|
+
cwd as processCwd,
|
|
8
|
+
env as processEnv,
|
|
9
|
+
stdin,
|
|
10
|
+
stdout,
|
|
11
|
+
} from "node:process"
|
|
12
|
+
import { createInterface } from "node:readline/promises"
|
|
13
|
+
import { promisify } from "node:util"
|
|
14
|
+
|
|
15
|
+
import { CliError } from "./support/cli-error.js"
|
|
16
|
+
import {
|
|
17
|
+
deleteR2Objects,
|
|
18
|
+
ensureR2Config,
|
|
19
|
+
validateR2ReferencesWithRefresh,
|
|
20
|
+
} from "./support/r2.js"
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile)
|
|
23
|
+
const localJunkFileNames = new Set([".DS_Store"])
|
|
24
|
+
const supportedScopes = new Set(["content-files", "thumbnails"])
|
|
25
|
+
|
|
26
|
+
const mediaDir = "src/content/media"
|
|
27
|
+
const mediaImagesDir = "src/content/media/images"
|
|
28
|
+
const categoriesDir = "src/content/categories"
|
|
29
|
+
const categoryImagesDir = "src/content/categories/images"
|
|
30
|
+
const publicFilesDir = "public/files"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{
|
|
34
|
+
* scope?: string
|
|
35
|
+
* fix?: boolean
|
|
36
|
+
* yes?: boolean
|
|
37
|
+
* r2?: boolean
|
|
38
|
+
* }} CheckFilesOptions
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{
|
|
43
|
+
* path: string
|
|
44
|
+
* content: Array<{type: "upload"|"link", url: string}>
|
|
45
|
+
* image?: string
|
|
46
|
+
* }} ParsedMediaItem
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {{
|
|
51
|
+
* path: string
|
|
52
|
+
* image?: string
|
|
53
|
+
* }} ParsedCategory
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {{
|
|
58
|
+
* log: (...args: unknown[]) => void
|
|
59
|
+
* error: (...args: unknown[]) => void
|
|
60
|
+
* }} Logger
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {{
|
|
65
|
+
* path: string
|
|
66
|
+
* sources: string[]
|
|
67
|
+
* }} MissingReference
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {{
|
|
72
|
+
* cwd?: string
|
|
73
|
+
* logger?: Logger
|
|
74
|
+
* isInteractive?: boolean
|
|
75
|
+
* promptText?: (message: string) => Promise<string>
|
|
76
|
+
* promptConfirm?: (message: string) => Promise<boolean>
|
|
77
|
+
* promptSecret?: (message: string) => Promise<string>
|
|
78
|
+
* runRclone?: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
|
|
79
|
+
* }} CheckFilesRuntime
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {CheckFilesOptions} options
|
|
84
|
+
* @param {CheckFilesRuntime} [runtime]
|
|
85
|
+
*/
|
|
86
|
+
export async function checkFiles(options, runtime = {}) {
|
|
87
|
+
const cwd = runtime.cwd ?? processCwd()
|
|
88
|
+
const logger = runtime.logger ?? console
|
|
89
|
+
const interactive =
|
|
90
|
+
runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
|
|
91
|
+
const promptText =
|
|
92
|
+
runtime.promptText ??
|
|
93
|
+
(async (message) =>
|
|
94
|
+
defaultPromptText(message, { input: stdin, output: stdout }))
|
|
95
|
+
const promptConfirm =
|
|
96
|
+
runtime.promptConfirm ??
|
|
97
|
+
(async (message) =>
|
|
98
|
+
defaultPromptConfirm(message, { input: stdin, output: stdout }))
|
|
99
|
+
const promptSecret = runtime.promptSecret
|
|
100
|
+
const runRclone =
|
|
101
|
+
runtime.runRclone ??
|
|
102
|
+
((args, commandCwd, commandEnv) =>
|
|
103
|
+
execFileAsync("rclone", args, {
|
|
104
|
+
cwd: commandCwd,
|
|
105
|
+
env: commandEnv ?? processEnv,
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
const scopes = parseScopes(options.scope)
|
|
109
|
+
const includeContentFiles = scopes.has("content-files")
|
|
110
|
+
const includeThumbnails = scopes.has("thumbnails")
|
|
111
|
+
|
|
112
|
+
await assertLightNetSiteRoot(cwd)
|
|
113
|
+
|
|
114
|
+
const mediaItems = await readMediaItems(cwd)
|
|
115
|
+
const categories = await readCategories(cwd)
|
|
116
|
+
|
|
117
|
+
/** @type {MissingReference[]} */
|
|
118
|
+
let missingContentFiles = []
|
|
119
|
+
/** @type {string[]} */
|
|
120
|
+
let orphanedContentFiles = []
|
|
121
|
+
/** @type {string[]} */
|
|
122
|
+
let orphanedMediaThumbnails = []
|
|
123
|
+
/** @type {string[]} */
|
|
124
|
+
let orphanedCategoryThumbnails = []
|
|
125
|
+
/** @type {string[]} */
|
|
126
|
+
let removedItems = []
|
|
127
|
+
/** @type {string[]} */
|
|
128
|
+
const warnings = []
|
|
129
|
+
|
|
130
|
+
let r2Config = undefined
|
|
131
|
+
|
|
132
|
+
if (includeContentFiles) {
|
|
133
|
+
if (options.r2) {
|
|
134
|
+
r2Config = await ensureR2Config({
|
|
135
|
+
cwd,
|
|
136
|
+
interactive,
|
|
137
|
+
logger,
|
|
138
|
+
promptText,
|
|
139
|
+
})
|
|
140
|
+
const r2Result = await validateR2ReferencesWithRefresh({
|
|
141
|
+
cwd,
|
|
142
|
+
interactive,
|
|
143
|
+
logger,
|
|
144
|
+
promptConfirm,
|
|
145
|
+
promptText,
|
|
146
|
+
promptSecret,
|
|
147
|
+
config: r2Config,
|
|
148
|
+
references: collectR2ContentReferences(mediaItems, r2Config.publicUrl),
|
|
149
|
+
runRclone,
|
|
150
|
+
})
|
|
151
|
+
r2Config = r2Result.config
|
|
152
|
+
missingContentFiles = r2Result.missingReferences
|
|
153
|
+
orphanedContentFiles = r2Result.orphanedObjects
|
|
154
|
+
if (r2Result.referenceCount === 0) {
|
|
155
|
+
warnings.push(
|
|
156
|
+
'No R2-backed content file references found. Use "--scope=thumbnails" or check your "--r2" setup.',
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
const localContentResult = await validateLocalContentFiles(
|
|
161
|
+
cwd,
|
|
162
|
+
mediaItems,
|
|
163
|
+
)
|
|
164
|
+
missingContentFiles = localContentResult.missingReferences
|
|
165
|
+
orphanedContentFiles = localContentResult.orphanedFiles
|
|
166
|
+
if (localContentResult.referenceCount === 0) {
|
|
167
|
+
warnings.push(
|
|
168
|
+
'No local "/files" content file references found. Use "--scope=thumbnails" or run with "--r2".',
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (includeThumbnails) {
|
|
175
|
+
const thumbnailsResult = await validateThumbnails(
|
|
176
|
+
cwd,
|
|
177
|
+
mediaItems,
|
|
178
|
+
categories,
|
|
179
|
+
)
|
|
180
|
+
orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
|
|
181
|
+
orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (options.fix) {
|
|
185
|
+
/** @type {{displayPath:string, kind:"local-file"|"r2-object", target:string}[]} */
|
|
186
|
+
const deletions = []
|
|
187
|
+
for (const filePath of orphanedContentFiles) {
|
|
188
|
+
if (options.r2) {
|
|
189
|
+
deletions.push({
|
|
190
|
+
displayPath: filePath,
|
|
191
|
+
kind: "r2-object",
|
|
192
|
+
target: filePath,
|
|
193
|
+
})
|
|
194
|
+
} else {
|
|
195
|
+
deletions.push({
|
|
196
|
+
displayPath: filePath,
|
|
197
|
+
kind: "local-file",
|
|
198
|
+
target: resolve(cwd, filePath),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const filePath of orphanedMediaThumbnails) {
|
|
203
|
+
deletions.push({
|
|
204
|
+
displayPath: filePath,
|
|
205
|
+
kind: "local-file",
|
|
206
|
+
target: resolve(cwd, filePath),
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
for (const filePath of orphanedCategoryThumbnails) {
|
|
210
|
+
deletions.push({
|
|
211
|
+
displayPath: filePath,
|
|
212
|
+
kind: "local-file",
|
|
213
|
+
target: resolve(cwd, filePath),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (deletions.length > 0) {
|
|
218
|
+
if (!options.yes && !interactive) {
|
|
219
|
+
throw new CliError(
|
|
220
|
+
'Deletion requires confirmation. Re-run with "--fix --yes" in non-interactive environments.',
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
const shouldDelete =
|
|
224
|
+
options.yes ||
|
|
225
|
+
(await confirmDeletion({
|
|
226
|
+
deletions,
|
|
227
|
+
logger,
|
|
228
|
+
promptConfirm,
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
if (shouldDelete) {
|
|
232
|
+
removedItems = await deleteItems({
|
|
233
|
+
cwd,
|
|
234
|
+
logger,
|
|
235
|
+
r2Config,
|
|
236
|
+
deletions,
|
|
237
|
+
options,
|
|
238
|
+
promptSecret,
|
|
239
|
+
runRclone,
|
|
240
|
+
})
|
|
241
|
+
orphanedContentFiles = orphanedContentFiles.filter(
|
|
242
|
+
(item) => !removedItems.includes(item),
|
|
243
|
+
)
|
|
244
|
+
orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
|
|
245
|
+
(item) => !removedItems.includes(item),
|
|
246
|
+
)
|
|
247
|
+
orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
|
|
248
|
+
(item) => !removedItems.includes(item),
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const hasIssues =
|
|
255
|
+
warnings.length > 0 ||
|
|
256
|
+
missingContentFiles.length > 0 ||
|
|
257
|
+
orphanedContentFiles.length > 0 ||
|
|
258
|
+
orphanedMediaThumbnails.length > 0 ||
|
|
259
|
+
orphanedCategoryThumbnails.length > 0
|
|
260
|
+
|
|
261
|
+
if (!hasIssues && removedItems.length === 0) {
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const warning of warnings) {
|
|
266
|
+
logger.error(warning)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
printMissingReferenceSection(
|
|
270
|
+
logger,
|
|
271
|
+
"Missing referenced content files",
|
|
272
|
+
missingContentFiles,
|
|
273
|
+
)
|
|
274
|
+
printSection(
|
|
275
|
+
logger,
|
|
276
|
+
options.r2 ? "Orphaned R2 objects" : "Orphaned local content files",
|
|
277
|
+
orphanedContentFiles,
|
|
278
|
+
)
|
|
279
|
+
printSection(logger, "Orphaned media thumbnails", orphanedMediaThumbnails)
|
|
280
|
+
printSection(
|
|
281
|
+
logger,
|
|
282
|
+
"Orphaned category thumbnails",
|
|
283
|
+
orphanedCategoryThumbnails,
|
|
284
|
+
)
|
|
285
|
+
printSection(logger, "Removed items", removedItems)
|
|
286
|
+
|
|
287
|
+
return !hasIssues
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @param {Logger} logger
|
|
292
|
+
* @param {string} title
|
|
293
|
+
* @param {string[]} items
|
|
294
|
+
*/
|
|
295
|
+
function printSection(logger, title, items) {
|
|
296
|
+
if (items.length === 0) {
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
logger.error("")
|
|
300
|
+
logger.error(`${title} (${items.length})`)
|
|
301
|
+
for (const item of items) {
|
|
302
|
+
logger.error(`- ${item}`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @param {Logger} logger
|
|
308
|
+
* @param {string} title
|
|
309
|
+
* @param {MissingReference[]} items
|
|
310
|
+
*/
|
|
311
|
+
function printMissingReferenceSection(logger, title, items) {
|
|
312
|
+
if (items.length === 0) {
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
logger.error("")
|
|
316
|
+
logger.error(`${title} (${items.length})`)
|
|
317
|
+
for (const item of items) {
|
|
318
|
+
logger.error(
|
|
319
|
+
`- ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {string|undefined} rawScope
|
|
326
|
+
*/
|
|
327
|
+
function parseScopes(rawScope) {
|
|
328
|
+
const values = (rawScope ?? "content-files,thumbnails")
|
|
329
|
+
.split(",")
|
|
330
|
+
.map((value) => value.trim())
|
|
331
|
+
.filter(Boolean)
|
|
332
|
+
|
|
333
|
+
if (values.length === 0) {
|
|
334
|
+
throw new CliError(
|
|
335
|
+
// intentionally kept as a CLI-facing validation error
|
|
336
|
+
'Expected "--scope" to include at least one of "content-files" or "thumbnails".',
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const scopes = new Set(values)
|
|
341
|
+
for (const scope of scopes) {
|
|
342
|
+
if (!supportedScopes.has(scope)) {
|
|
343
|
+
throw new CliError(
|
|
344
|
+
`Unsupported scope "${scope}". Allowed values: content-files, thumbnails.`,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return scopes
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @param {string} cwd
|
|
354
|
+
*/
|
|
355
|
+
async function assertLightNetSiteRoot(cwd) {
|
|
356
|
+
if (!(await pathExists(resolve(cwd, mediaDir)))) {
|
|
357
|
+
throw new CliError(
|
|
358
|
+
'Expected a LightNet site root with "src/content/media". Run this command inside a LightNet site project.',
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {string} cwd
|
|
365
|
+
*/
|
|
366
|
+
async function readMediaItems(cwd) {
|
|
367
|
+
const files = await listJsonFiles(resolve(cwd, mediaDir))
|
|
368
|
+
/** @type {ParsedMediaItem[]} */
|
|
369
|
+
const items = []
|
|
370
|
+
for (const filePath of files) {
|
|
371
|
+
items.push(await readMediaItem(filePath))
|
|
372
|
+
}
|
|
373
|
+
return items
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* @param {string} cwd
|
|
378
|
+
*/
|
|
379
|
+
async function readCategories(cwd) {
|
|
380
|
+
const absoluteDir = resolve(cwd, categoriesDir)
|
|
381
|
+
if (!(await pathExists(absoluteDir))) {
|
|
382
|
+
return []
|
|
383
|
+
}
|
|
384
|
+
const files = await listJsonFiles(absoluteDir)
|
|
385
|
+
/** @type {ParsedCategory[]} */
|
|
386
|
+
const items = []
|
|
387
|
+
for (const filePath of files) {
|
|
388
|
+
items.push(await readCategory(filePath))
|
|
389
|
+
}
|
|
390
|
+
return items
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @param {string} filePath
|
|
395
|
+
* @returns {Promise<ParsedMediaItem>}
|
|
396
|
+
*/
|
|
397
|
+
async function readMediaItem(filePath) {
|
|
398
|
+
const value = await readJsonFile(filePath)
|
|
399
|
+
if (!isPlainObject(value)) {
|
|
400
|
+
throw new CliError(
|
|
401
|
+
`Invalid media item in "${filePath}": expected an object.`,
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const image = readOptionalImageReference(value.image, filePath)
|
|
406
|
+
const content = readContentArray(value.content, filePath)
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
path: filePath,
|
|
410
|
+
content,
|
|
411
|
+
image,
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* @param {string} filePath
|
|
417
|
+
* @returns {Promise<ParsedCategory>}
|
|
418
|
+
*/
|
|
419
|
+
async function readCategory(filePath) {
|
|
420
|
+
const value = await readJsonFile(filePath)
|
|
421
|
+
if (!isPlainObject(value)) {
|
|
422
|
+
throw new CliError(`Invalid category in "${filePath}": expected an object.`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
path: filePath,
|
|
427
|
+
image: readOptionalImageReference(value.image, filePath),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* @param {string} cwd
|
|
433
|
+
* @param {ParsedMediaItem[]} mediaItems
|
|
434
|
+
*/
|
|
435
|
+
async function validateLocalContentFiles(cwd, mediaItems) {
|
|
436
|
+
const references = collectLocalContentReferences(mediaItems)
|
|
437
|
+
const files = await collectLocalContentFiles(cwd)
|
|
438
|
+
|
|
439
|
+
const missingReferences = [...references.entries()]
|
|
440
|
+
.filter(([path]) => !files.urlSet.has(path))
|
|
441
|
+
.map(([path, sources]) => ({
|
|
442
|
+
path,
|
|
443
|
+
sources: [...sources].toSorted(),
|
|
444
|
+
}))
|
|
445
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
446
|
+
const orphanedFiles = [...files.pathByUrl.entries()]
|
|
447
|
+
.filter(([url]) => !references.has(url))
|
|
448
|
+
.map(([, filePath]) => filePath)
|
|
449
|
+
.sort()
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
missingReferences,
|
|
453
|
+
orphanedFiles,
|
|
454
|
+
referenceCount: references.size,
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* @param {string} cwd
|
|
460
|
+
* @param {ParsedMediaItem[]} mediaItems
|
|
461
|
+
* @param {ParsedCategory[]} categories
|
|
462
|
+
*/
|
|
463
|
+
async function validateThumbnails(cwd, mediaItems, categories) {
|
|
464
|
+
const mediaReferences = new Set(
|
|
465
|
+
mediaItems
|
|
466
|
+
.map((item) => toThumbnailPath("media", item.image))
|
|
467
|
+
.filter((value) => value !== undefined),
|
|
468
|
+
)
|
|
469
|
+
const categoryReferences = new Set(
|
|
470
|
+
categories
|
|
471
|
+
.map((item) => toThumbnailPath("categories", item.image))
|
|
472
|
+
.filter((value) => value !== undefined),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
const mediaFiles = await collectLocalFiles(resolve(cwd, mediaImagesDir), {
|
|
476
|
+
includeRootPrefix: mediaImagesDir,
|
|
477
|
+
ignoreJunk: true,
|
|
478
|
+
})
|
|
479
|
+
const categoryFiles = await collectLocalFiles(
|
|
480
|
+
resolve(cwd, categoryImagesDir),
|
|
481
|
+
{
|
|
482
|
+
includeRootPrefix: categoryImagesDir,
|
|
483
|
+
ignoreJunk: true,
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
orphanedMediaThumbnails: mediaFiles.filter(
|
|
489
|
+
(file) => !mediaReferences.has(file),
|
|
490
|
+
),
|
|
491
|
+
orphanedCategoryThumbnails: categoryFiles.filter(
|
|
492
|
+
(file) => !categoryReferences.has(file),
|
|
493
|
+
),
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param {ParsedMediaItem[]} mediaItems
|
|
499
|
+
*/
|
|
500
|
+
function collectLocalContentReferences(mediaItems) {
|
|
501
|
+
const references = new Map()
|
|
502
|
+
for (const item of mediaItems) {
|
|
503
|
+
const sourceFileName = posix.basename(toPosixPath(item.path))
|
|
504
|
+
for (const contentItem of item.content) {
|
|
505
|
+
if (
|
|
506
|
+
contentItem.type === "upload" &&
|
|
507
|
+
contentItem.url.startsWith("/files")
|
|
508
|
+
) {
|
|
509
|
+
const sources = references.get(contentItem.url) ?? new Set()
|
|
510
|
+
sources.add(sourceFileName)
|
|
511
|
+
references.set(contentItem.url, sources)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return references
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* @param {ParsedMediaItem[]} mediaItems
|
|
520
|
+
* @param {string} publicUrl
|
|
521
|
+
*/
|
|
522
|
+
function collectR2ContentReferences(mediaItems, publicUrl) {
|
|
523
|
+
const references = new Map()
|
|
524
|
+
const normalizedBase = publicUrl.replace(/\/+$/, "")
|
|
525
|
+
for (const item of mediaItems) {
|
|
526
|
+
const sourceFileName = posix.basename(toPosixPath(item.path))
|
|
527
|
+
for (const contentItem of item.content) {
|
|
528
|
+
if (
|
|
529
|
+
contentItem.type !== "upload" ||
|
|
530
|
+
!contentItem.url.startsWith(normalizedBase)
|
|
531
|
+
) {
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
const relativePath = contentItem.url
|
|
535
|
+
.slice(normalizedBase.length)
|
|
536
|
+
.replace(/^\/+/, "")
|
|
537
|
+
const current = references.get(contentItem.url)
|
|
538
|
+
if (current) {
|
|
539
|
+
current.sources.add(sourceFileName)
|
|
540
|
+
} else {
|
|
541
|
+
references.set(contentItem.url, {
|
|
542
|
+
key: relativePath,
|
|
543
|
+
sources: new Set([sourceFileName]),
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return references
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* @param {string} cwd
|
|
553
|
+
*/
|
|
554
|
+
async function collectLocalContentFiles(cwd) {
|
|
555
|
+
const files = await collectLocalFiles(resolve(cwd, publicFilesDir), {
|
|
556
|
+
includeRootPrefix: publicFilesDir,
|
|
557
|
+
ignoreJunk: true,
|
|
558
|
+
})
|
|
559
|
+
const pathByUrl = new Map(
|
|
560
|
+
files.map((filePath) => [
|
|
561
|
+
`/${toPosixPath(relative(resolve(cwd, "public"), resolve(cwd, filePath)))}`,
|
|
562
|
+
filePath,
|
|
563
|
+
]),
|
|
564
|
+
)
|
|
565
|
+
return {
|
|
566
|
+
pathByUrl,
|
|
567
|
+
urlSet: new Set(pathByUrl.keys()),
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* @param {string} absoluteDir
|
|
573
|
+
* @param {{includeRootPrefix:string, ignoreJunk:boolean}} options
|
|
574
|
+
*/
|
|
575
|
+
async function collectLocalFiles(absoluteDir, options) {
|
|
576
|
+
if (!(await pathExists(absoluteDir))) {
|
|
577
|
+
return []
|
|
578
|
+
}
|
|
579
|
+
const files = await walkFiles(absoluteDir)
|
|
580
|
+
return files
|
|
581
|
+
.filter((filePath) =>
|
|
582
|
+
options.ignoreJunk
|
|
583
|
+
? !localJunkFileNames.has(posix.basename(filePath))
|
|
584
|
+
: true,
|
|
585
|
+
)
|
|
586
|
+
.map((filePath) =>
|
|
587
|
+
toPosixPath(
|
|
588
|
+
join(options.includeRootPrefix, relative(absoluteDir, filePath)),
|
|
589
|
+
),
|
|
590
|
+
)
|
|
591
|
+
.sort()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* @param {"media"|"categories"} kind
|
|
596
|
+
* @param {string|undefined} image
|
|
597
|
+
*/
|
|
598
|
+
function toThumbnailPath(kind, image) {
|
|
599
|
+
if (typeof image !== "string" || !image.startsWith("./images/")) {
|
|
600
|
+
return undefined
|
|
601
|
+
}
|
|
602
|
+
const relativePath = image.slice("./images/".length)
|
|
603
|
+
if (
|
|
604
|
+
!relativePath ||
|
|
605
|
+
relativePath.startsWith("/") ||
|
|
606
|
+
relativePath.includes("../")
|
|
607
|
+
) {
|
|
608
|
+
return undefined
|
|
609
|
+
}
|
|
610
|
+
return kind === "media"
|
|
611
|
+
? toPosixPath(join(mediaImagesDir, relativePath))
|
|
612
|
+
: toPosixPath(join(categoryImagesDir, relativePath))
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* @param {{
|
|
617
|
+
* deletions: {displayPath:string, kind:"local-file"|"r2-object", target:string}[]
|
|
618
|
+
* logger: Logger
|
|
619
|
+
* promptConfirm: (message:string)=>Promise<boolean>
|
|
620
|
+
* }} args
|
|
621
|
+
*/
|
|
622
|
+
async function confirmDeletion({ deletions, logger, promptConfirm }) {
|
|
623
|
+
logger.error("")
|
|
624
|
+
logger.error(`Files to remove (${deletions.length})`)
|
|
625
|
+
for (const deletion of deletions) {
|
|
626
|
+
logger.error(`- ${deletion.displayPath}`)
|
|
627
|
+
}
|
|
628
|
+
return promptConfirm("Delete these files? [y/N] ")
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* @param {{
|
|
633
|
+
* cwd: string
|
|
634
|
+
* logger: Logger
|
|
635
|
+
* r2Config: import("./support/r2.js").R2Config|undefined
|
|
636
|
+
* deletions: {displayPath:string, kind:"local-file"|"r2-object", target:string}[]
|
|
637
|
+
* options: CheckFilesOptions
|
|
638
|
+
* promptSecret?: (message: string) => Promise<string>
|
|
639
|
+
* runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
|
|
640
|
+
* }} args
|
|
641
|
+
*/
|
|
642
|
+
async function deleteItems({
|
|
643
|
+
cwd,
|
|
644
|
+
logger,
|
|
645
|
+
r2Config,
|
|
646
|
+
deletions,
|
|
647
|
+
options,
|
|
648
|
+
promptSecret,
|
|
649
|
+
runRclone,
|
|
650
|
+
}) {
|
|
651
|
+
/** @type {string[]} */
|
|
652
|
+
const removed = []
|
|
653
|
+
const r2ObjectKeys = []
|
|
654
|
+
for (const deletion of deletions) {
|
|
655
|
+
try {
|
|
656
|
+
if (deletion.kind === "local-file") {
|
|
657
|
+
await unlink(deletion.target)
|
|
658
|
+
removed.push(deletion.displayPath)
|
|
659
|
+
} else {
|
|
660
|
+
if (!options.r2 || !r2Config) {
|
|
661
|
+
throw new CliError("Missing R2 config for remote deletion.")
|
|
662
|
+
}
|
|
663
|
+
r2ObjectKeys.push(deletion.target)
|
|
664
|
+
}
|
|
665
|
+
} catch (error) {
|
|
666
|
+
logger.error(
|
|
667
|
+
error instanceof Error
|
|
668
|
+
? `Failed to delete "${deletion.displayPath}": ${error.message}`
|
|
669
|
+
: `Failed to delete "${deletion.displayPath}".`,
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (r2ObjectKeys.length > 0) {
|
|
674
|
+
if (!r2Config) {
|
|
675
|
+
throw new CliError("Missing R2 config for remote deletion.")
|
|
676
|
+
}
|
|
677
|
+
const removedRemoteItems = await deleteR2Objects({
|
|
678
|
+
cwd,
|
|
679
|
+
config: r2Config,
|
|
680
|
+
objectKeys: r2ObjectKeys,
|
|
681
|
+
promptSecret,
|
|
682
|
+
runRclone,
|
|
683
|
+
})
|
|
684
|
+
removed.push(...removedRemoteItems)
|
|
685
|
+
const failedRemoteItems = r2ObjectKeys.filter(
|
|
686
|
+
(item) => !removedRemoteItems.includes(item),
|
|
687
|
+
)
|
|
688
|
+
for (const item of failedRemoteItems) {
|
|
689
|
+
logger.error(`Failed to delete "${item}".`)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return removed
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* @param {string} filePath
|
|
697
|
+
*/
|
|
698
|
+
async function readJsonFile(filePath) {
|
|
699
|
+
const text = await readFile(filePath, "utf8")
|
|
700
|
+
try {
|
|
701
|
+
return JSON.parse(text)
|
|
702
|
+
} catch {
|
|
703
|
+
throw new CliError(`Invalid JSON in "${filePath}".`)
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* @param {string} dirPath
|
|
709
|
+
*/
|
|
710
|
+
async function listJsonFiles(dirPath) {
|
|
711
|
+
if (!(await pathExists(dirPath))) {
|
|
712
|
+
return []
|
|
713
|
+
}
|
|
714
|
+
const files = await walkFiles(dirPath)
|
|
715
|
+
return files.filter((filePath) => filePath.endsWith(".json")).sort()
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* @param {string} dirPath
|
|
720
|
+
*/
|
|
721
|
+
async function walkFiles(dirPath) {
|
|
722
|
+
/** @type {string[]} */
|
|
723
|
+
const files = []
|
|
724
|
+
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
725
|
+
for (const entry of entries) {
|
|
726
|
+
const entryPath = join(dirPath, entry.name)
|
|
727
|
+
if (entry.isDirectory()) {
|
|
728
|
+
files.push(...(await walkFiles(entryPath)))
|
|
729
|
+
} else if (entry.isFile()) {
|
|
730
|
+
files.push(entryPath)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return files
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* @param {unknown} value
|
|
738
|
+
* @param {string} filePath
|
|
739
|
+
*/
|
|
740
|
+
function readOptionalImageReference(value, filePath) {
|
|
741
|
+
if (value === undefined) {
|
|
742
|
+
return undefined
|
|
743
|
+
}
|
|
744
|
+
if (typeof value !== "string") {
|
|
745
|
+
throw new CliError(
|
|
746
|
+
`Invalid "image" in "${filePath}": expected a string when provided.`,
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
return value
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* @param {unknown} value
|
|
754
|
+
* @param {string} filePath
|
|
755
|
+
* @returns {Array<{type: "upload"|"link", url: string}>}
|
|
756
|
+
*/
|
|
757
|
+
function readContentArray(value, filePath) {
|
|
758
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
759
|
+
throw new CliError(
|
|
760
|
+
`Invalid "content" in "${filePath}": expected a non-empty array.`,
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return value.map((item) => {
|
|
765
|
+
if (!isPlainObject(item)) {
|
|
766
|
+
throw new CliError(
|
|
767
|
+
`Invalid "content" in "${filePath}": expected array items to be objects.`,
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
if (item.type !== "upload" && item.type !== "link") {
|
|
771
|
+
throw new CliError(
|
|
772
|
+
`Invalid "content.type" in "${filePath}": expected "upload" or "link".`,
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
if (typeof item.url !== "string") {
|
|
776
|
+
throw new CliError(
|
|
777
|
+
`Invalid "content.url" in "${filePath}": expected a string.`,
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
type: item.type,
|
|
782
|
+
url: item.url,
|
|
783
|
+
}
|
|
784
|
+
})
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* @param {unknown} value
|
|
789
|
+
*/
|
|
790
|
+
function isPlainObject(value) {
|
|
791
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* @param {string} filePath
|
|
796
|
+
*/
|
|
797
|
+
async function pathExists(filePath) {
|
|
798
|
+
try {
|
|
799
|
+
await access(filePath)
|
|
800
|
+
return true
|
|
801
|
+
} catch {
|
|
802
|
+
return false
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* @param {string} filePath
|
|
808
|
+
*/
|
|
809
|
+
function toPosixPath(filePath) {
|
|
810
|
+
return filePath.split("\\").join("/")
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* @param {string} message
|
|
815
|
+
* @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
|
|
816
|
+
*/
|
|
817
|
+
async function defaultPromptText(message, io) {
|
|
818
|
+
const rl = createInterface(io)
|
|
819
|
+
try {
|
|
820
|
+
return await rl.question(message)
|
|
821
|
+
} finally {
|
|
822
|
+
rl.close()
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* @param {string} message
|
|
828
|
+
* @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
|
|
829
|
+
*/
|
|
830
|
+
async function defaultPromptConfirm(message, io) {
|
|
831
|
+
const answer = (await defaultPromptText(message, io)).trim().toLowerCase()
|
|
832
|
+
return answer === "y" || answer === "yes"
|
|
833
|
+
}
|