@lightnet/cli 4.1.0 → 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.
@@ -4,6 +4,21 @@ import { readFile } from "node:fs/promises"
4
4
  import { resolve } from "node:path"
5
5
  import { cwd } from "node:process"
6
6
 
7
+ /**
8
+ * @typedef {{
9
+ * type: "lightnet" | "user" | "map"
10
+ * key: string
11
+ * values: Record<string, string | undefined>
12
+ * }} Translation
13
+ */
14
+
15
+ /**
16
+ * @typedef {{
17
+ * defaultLocale: string
18
+ * locales: string[]
19
+ * }} Languages
20
+ */
21
+
7
22
  const lightnetCachePath = resolve(cwd(), "node_modules", ".cache", "lightnet")
8
23
 
9
24
  /** @type {{type:Translation["type"], title:string, action:string}[]} */
package/src/index.js CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
 
4
- import { exit } from "node:process"
5
-
6
4
  import { Command } from "commander"
7
5
 
8
6
  import pkg from "../package.json" with { type: "json" }
7
+ import { checkFiles } from "./check-files.js"
9
8
  import { checkTranslations } from "./check-translations.js"
9
+ import { CliError } from "./support/cli-error.js"
10
10
  const { version } = pkg
11
11
 
12
12
  const program = new Command()
13
+ let commandExitCode = 0
13
14
 
14
15
  program
15
16
  .name("lightnet-cli")
@@ -21,7 +22,37 @@ program
21
22
  .description("check if last build has been missing any translations")
22
23
  .action(async () => {
23
24
  const checkSuccessful = await checkTranslations()
24
- exit(checkSuccessful ? 0 : 1)
25
+ commandExitCode = checkSuccessful ? 0 : 1
26
+ })
27
+
28
+ program
29
+ .command("check-files")
30
+ .description(
31
+ "check for missing and orphaned content files and thumbnails in a LightNet site",
32
+ )
33
+ .option("--fix", "remove orphaned files")
34
+ .option("--yes", "skip deletion confirmation when used with --fix")
35
+ .option(
36
+ "--r2",
37
+ "validate remote content files in Cloudflare R2 instead of public/files",
38
+ )
39
+ .option(
40
+ "--scope <values>",
41
+ "comma-separated scopes: content-files,thumbnails",
42
+ )
43
+ .action(async (options) => {
44
+ try {
45
+ const checkSuccessful = await checkFiles(options)
46
+ commandExitCode = checkSuccessful ? 0 : 1
47
+ } catch (error) {
48
+ if (error instanceof CliError) {
49
+ console.error(error.message)
50
+ commandExitCode = 1
51
+ return
52
+ }
53
+ throw error
54
+ }
25
55
  })
26
56
 
27
57
  await program.parseAsync()
58
+ process.exitCode = commandExitCode
@@ -0,0 +1 @@
1
+ export class CliError extends Error {}
@@ -0,0 +1,561 @@
1
+ // @ts-check
2
+
3
+ import { access, readFile, writeFile } from "node:fs/promises"
4
+ import { resolve } from "node:path"
5
+ import { env as processEnv, stdin, stdout } from "node:process"
6
+
7
+ import { CliError } from "./cli-error.js"
8
+
9
+ const cliConfigFileName = ".lightnet-cli.config.json"
10
+ const gitIgnoreEntry = `${cliConfigFileName}\n`
11
+ const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
12
+
13
+ /**
14
+ * @typedef {{
15
+ * publicUrl: string
16
+ * accountId: string
17
+ * accessKeyId: string
18
+ * bucket: string
19
+ * }} R2Config
20
+ */
21
+
22
+ /**
23
+ * @typedef {{
24
+ * path: string
25
+ * sources: string[]
26
+ * }} MissingReference
27
+ */
28
+
29
+ /**
30
+ * @typedef {Map<string, {key: string, sources: Set<string>}>} R2ReferenceMap
31
+ */
32
+
33
+ /**
34
+ * @param {{
35
+ * cwd: string
36
+ * interactive: boolean
37
+ * logger: {error: (...args: unknown[]) => void}
38
+ * promptText: (message: string) => Promise<string>
39
+ * }} args
40
+ */
41
+ export async function ensureR2Config({ cwd, interactive, logger, promptText }) {
42
+ const config = await readCliConfig(cwd)
43
+ const parsed = parseR2Config(config)
44
+ if (parsed) {
45
+ return parsed
46
+ }
47
+
48
+ if (!interactive) {
49
+ throw new CliError(
50
+ `Missing valid ${cliConfigFileName} with "r2.publicUrl", "r2.accountId", "r2.accessKeyId", and "r2.bucket".`,
51
+ )
52
+ }
53
+
54
+ logger.error(`Missing R2 config in "${cliConfigFileName}".`)
55
+ return promptForR2Config({ cwd, promptText })
56
+ }
57
+
58
+ /**
59
+ * @param {{
60
+ * cwd: string
61
+ * interactive: boolean
62
+ * logger: {error: (...args: unknown[]) => void}
63
+ * promptConfirm: (message: string) => Promise<boolean>
64
+ * promptText: (message: string) => Promise<string>
65
+ * promptSecret?: (message: string) => Promise<string>
66
+ * config: R2Config
67
+ * references: R2ReferenceMap
68
+ * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
69
+ * }} args
70
+ */
71
+ export async function validateR2ReferencesWithRefresh(args) {
72
+ try {
73
+ return {
74
+ ...(await validateR2References(args)),
75
+ config: args.config,
76
+ }
77
+ } catch (error) {
78
+ if (!args.interactive) {
79
+ throw error
80
+ }
81
+ args.logger.error(
82
+ error instanceof Error ? error.message : "R2 validation failed.",
83
+ )
84
+ const shouldRefresh = await args.promptConfirm(
85
+ "Refresh saved R2 settings and try again? [y/N] ",
86
+ )
87
+ if (!shouldRefresh) {
88
+ throw error
89
+ }
90
+ const refreshedConfig = await promptForR2Config({
91
+ cwd: args.cwd,
92
+ promptText: args.promptText,
93
+ })
94
+ return {
95
+ ...(await validateR2References({
96
+ ...args,
97
+ config: refreshedConfig,
98
+ })),
99
+ config: refreshedConfig,
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @param {{
106
+ * cwd: string
107
+ * config: R2Config
108
+ * objectKeys: string[]
109
+ * promptSecret?: (message: string) => Promise<string>
110
+ * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
111
+ * }} args
112
+ */
113
+ export async function deleteR2Objects({
114
+ cwd,
115
+ config,
116
+ objectKeys,
117
+ promptSecret,
118
+ runRclone,
119
+ }) {
120
+ /** @type {string} */
121
+ const secretAccessKey = await promptRequiredSecret(promptSecret)
122
+ /** @type {string[]} */
123
+ const removed = []
124
+
125
+ for (const objectKey of objectKeys) {
126
+ try {
127
+ await runConfiguredRclone({
128
+ args: ["deletefile", makeR2Path(config, objectKey)],
129
+ config,
130
+ cwd,
131
+ promptSecret: async () => secretAccessKey,
132
+ runRclone,
133
+ })
134
+ removed.push(objectKey)
135
+ } catch {
136
+ // caller prints a per-file failure message
137
+ }
138
+ }
139
+
140
+ return removed
141
+ }
142
+
143
+ /**
144
+ * @param {{
145
+ * cwd: string
146
+ * config: R2Config
147
+ * references: R2ReferenceMap
148
+ * promptSecret?: (message: string) => Promise<string>
149
+ * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
150
+ * }} args
151
+ */
152
+ async function validateR2References({
153
+ cwd,
154
+ config,
155
+ references,
156
+ promptSecret,
157
+ runRclone,
158
+ }) {
159
+ if (references.size === 0) {
160
+ return {
161
+ missingReferences: /** @type {MissingReference[]} */ ([]),
162
+ orphanedObjects: /** @type {string[]} */ ([]),
163
+ referenceCount: 0,
164
+ }
165
+ }
166
+
167
+ /** @type {string} */
168
+ const secretAccessKey = await promptRequiredSecret(promptSecret)
169
+ await assertR2Access({
170
+ cwd,
171
+ config,
172
+ promptSecret: async () => secretAccessKey,
173
+ runRclone,
174
+ })
175
+ const bucketObjects = await listR2Objects({
176
+ cwd,
177
+ config,
178
+ promptSecret: async () => secretAccessKey,
179
+ runRclone,
180
+ })
181
+
182
+ const objectKeys = new Set(bucketObjects.map((item) => item.key))
183
+ const referencedKeys = new Set(
184
+ [...references.values()].map((reference) => reference.key),
185
+ )
186
+
187
+ const missingReferences = [...references.entries()]
188
+ .filter(([, reference]) => !objectKeys.has(reference.key))
189
+ .map(([url, { sources }]) => ({
190
+ path: url,
191
+ sources: [...sources].toSorted(),
192
+ }))
193
+ .sort((a, b) => a.path.localeCompare(b.path))
194
+
195
+ const orphanedObjects = bucketObjects
196
+ .filter((item) => !referencedKeys.has(item.key))
197
+ .map((item) => item.key)
198
+ .sort()
199
+
200
+ return {
201
+ missingReferences,
202
+ orphanedObjects,
203
+ referenceCount: references.size,
204
+ }
205
+ }
206
+
207
+ /**
208
+ * @param {{
209
+ * cwd: string
210
+ * config: R2Config
211
+ * promptSecret: (message: string) => Promise<string>
212
+ * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
213
+ * }} args
214
+ */
215
+ async function assertR2Access(args) {
216
+ try {
217
+ await runConfiguredRclone({
218
+ args: ["lsjson", makeR2Path(args.config), "--files-only"],
219
+ ...args,
220
+ })
221
+ } catch (error) {
222
+ throw new CliError(
223
+ error instanceof Error
224
+ ? `Unable to access R2 bucket "${args.config.bucket}": ${error.message}`
225
+ : `Unable to access R2 bucket "${args.config.bucket}".`,
226
+ )
227
+ }
228
+ }
229
+
230
+ /**
231
+ * @param {{
232
+ * cwd: string
233
+ * config: R2Config
234
+ * promptSecret: (message: string) => Promise<string>
235
+ * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
236
+ * }} args
237
+ */
238
+ async function listR2Objects(args) {
239
+ const { stdout: listJson } = await runConfiguredRclone({
240
+ args: [
241
+ "lsjson",
242
+ makeR2Path(args.config),
243
+ "--recursive",
244
+ "--files-only",
245
+ "--fast-list",
246
+ "--no-mimetype",
247
+ "--no-modtime",
248
+ ],
249
+ ...args,
250
+ })
251
+
252
+ let parsed
253
+ try {
254
+ parsed = JSON.parse(listJson)
255
+ } catch {
256
+ throw new CliError(
257
+ `Unable to parse rclone listing for bucket "${args.config.bucket}".`,
258
+ )
259
+ }
260
+ if (!Array.isArray(parsed)) {
261
+ throw new CliError(
262
+ `Unexpected rclone listing output for bucket "${args.config.bucket}".`,
263
+ )
264
+ }
265
+
266
+ return parsed
267
+ .filter((item) => isPlainObject(item) && typeof item.Path === "string")
268
+ .map((item) => ({ key: item.Path }))
269
+ }
270
+
271
+ /**
272
+ * @param {{
273
+ * args: string[]
274
+ * cwd: string
275
+ * config: R2Config
276
+ * promptSecret: (message: string) => Promise<string>
277
+ * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
278
+ * }} args
279
+ */
280
+ async function runConfiguredRclone({
281
+ args,
282
+ cwd,
283
+ config,
284
+ promptSecret,
285
+ runRclone,
286
+ }) {
287
+ const secretAccessKey = await promptSecret("R2 secretAccessKey: ")
288
+ const env = {
289
+ ...processEnv,
290
+ RCLONE_S3_SECRET_ACCESS_KEY: secretAccessKey,
291
+ }
292
+
293
+ try {
294
+ return await runRclone(
295
+ [
296
+ ...args,
297
+ "--s3-provider",
298
+ "Cloudflare",
299
+ "--s3-access-key-id",
300
+ config.accessKeyId,
301
+ "--s3-region",
302
+ "auto",
303
+ "--s3-endpoint",
304
+ getR2Endpoint(config.accountId),
305
+ ],
306
+ cwd,
307
+ env,
308
+ )
309
+ } catch (error) {
310
+ throw new CliError(getSanitizedRcloneError(error))
311
+ }
312
+ }
313
+
314
+ /**
315
+ * @param {{cwd:string, promptText:(message:string)=>Promise<string>}} args
316
+ */
317
+ async function promptForR2Config({ cwd, promptText }) {
318
+ const publicUrl = await promptRequiredText(promptText, "R2 publicUrl: ")
319
+ const accountId = await promptRequiredText(promptText, "R2 accountId: ")
320
+ const accessKeyId = await promptRequiredText(promptText, "R2 accessKeyId: ")
321
+ const bucket = await promptRequiredText(promptText, "R2 bucket: ")
322
+
323
+ const nextConfig = {
324
+ ...(await readCliConfig(cwd)),
325
+ r2: {
326
+ publicUrl,
327
+ accountId,
328
+ accessKeyId,
329
+ bucket,
330
+ },
331
+ }
332
+
333
+ await writeFile(
334
+ resolve(cwd, cliConfigFileName),
335
+ `${JSON.stringify(nextConfig, null, 2)}\n`,
336
+ "utf8",
337
+ )
338
+ await ensureGitignoreContainsConfig(cwd)
339
+
340
+ return nextConfig.r2
341
+ }
342
+
343
+ /**
344
+ * @param {((message:string)=>Promise<string>) | undefined} promptSecret
345
+ * @returns {Promise<string>}
346
+ */
347
+ async function promptRequiredSecret(promptSecret) {
348
+ const sessionSecret = processEnv[sessionSecretEnvName]
349
+ if (typeof sessionSecret === "string" && sessionSecret.trim()) {
350
+ return sessionSecret
351
+ }
352
+
353
+ /** @type {(message:string)=>Promise<string>} */
354
+ const prompt =
355
+ promptSecret ??
356
+ ((message) =>
357
+ defaultPromptSecret(message, {
358
+ input: stdin,
359
+ output: stdout,
360
+ }))
361
+
362
+ while (true) {
363
+ const value = await prompt("R2 secretAccessKey: ")
364
+ if (value.trim()) {
365
+ processEnv[sessionSecretEnvName] = value
366
+ return value
367
+ }
368
+ }
369
+ }
370
+
371
+ /**
372
+ * @param {(message:string)=>Promise<string>} promptText
373
+ * @param {string} message
374
+ */
375
+ async function promptRequiredText(promptText, message) {
376
+ while (true) {
377
+ const value = (await promptText(message)).trim()
378
+ if (value) {
379
+ return value
380
+ }
381
+ }
382
+ }
383
+
384
+ /**
385
+ * @param {string} cwd
386
+ */
387
+ async function readCliConfig(cwd) {
388
+ const filePath = resolve(cwd, cliConfigFileName)
389
+ if (!(await pathExists(filePath))) {
390
+ return undefined
391
+ }
392
+ const contents = await readFile(filePath, "utf8")
393
+ try {
394
+ return JSON.parse(contents)
395
+ } catch {
396
+ throw new CliError(`Invalid JSON in "${cliConfigFileName}".`)
397
+ }
398
+ }
399
+
400
+ /**
401
+ * @param {unknown} config
402
+ * @returns {R2Config|undefined}
403
+ */
404
+ function parseR2Config(config) {
405
+ if (!isPlainObject(config)) {
406
+ return undefined
407
+ }
408
+ const rootConfig = /** @type {Record<string, unknown>} */ (config)
409
+ if (!isPlainObject(rootConfig.r2)) {
410
+ return undefined
411
+ }
412
+ const r2Config = /** @type {Record<string, unknown>} */ (rootConfig.r2)
413
+ const { publicUrl, accountId, accessKeyId, bucket } = r2Config
414
+ if (
415
+ typeof publicUrl !== "string" ||
416
+ typeof accountId !== "string" ||
417
+ typeof accessKeyId !== "string" ||
418
+ typeof bucket !== "string" ||
419
+ !publicUrl ||
420
+ !accountId ||
421
+ !accessKeyId ||
422
+ !bucket
423
+ ) {
424
+ return undefined
425
+ }
426
+ return { publicUrl, accountId, accessKeyId, bucket }
427
+ }
428
+
429
+ /**
430
+ * @param {string} cwd
431
+ */
432
+ async function ensureGitignoreContainsConfig(cwd) {
433
+ const filePath = resolve(cwd, ".gitignore")
434
+ const contents = (await pathExists(filePath))
435
+ ? await readFile(filePath, "utf8")
436
+ : ""
437
+ if (contents.includes(cliConfigFileName)) {
438
+ return
439
+ }
440
+ const next = contents
441
+ ? `${contents}${contents.endsWith("\n") ? "" : "\n"}${gitIgnoreEntry}`
442
+ : gitIgnoreEntry
443
+ await writeFile(filePath, next, "utf8")
444
+ }
445
+
446
+ /**
447
+ * @param {R2Config} config
448
+ * @param {string} [objectKey]
449
+ */
450
+ function makeR2Path(config, objectKey = "") {
451
+ return objectKey
452
+ ? `:s3:${config.bucket}/${objectKey}`
453
+ : `:s3:${config.bucket}`
454
+ }
455
+
456
+ /**
457
+ * @param {string} accountId
458
+ */
459
+ function getR2Endpoint(accountId) {
460
+ return `https://${accountId}.r2.cloudflarestorage.com`
461
+ }
462
+
463
+ /**
464
+ * @param {unknown} error
465
+ */
466
+ function getSanitizedRcloneError(error) {
467
+ if (isPlainObject(error)) {
468
+ const processError = /** @type {{stderr?: unknown, stdout?: unknown}} */ (
469
+ error
470
+ )
471
+ if (typeof processError.stderr === "string" && processError.stderr.trim()) {
472
+ return processError.stderr.trim()
473
+ }
474
+ if (typeof processError.stdout === "string" && processError.stdout.trim()) {
475
+ return processError.stdout.trim()
476
+ }
477
+ }
478
+ return "rclone command failed."
479
+ }
480
+
481
+ /**
482
+ * @param {string} filePath
483
+ */
484
+ async function pathExists(filePath) {
485
+ try {
486
+ await access(filePath)
487
+ return true
488
+ } catch {
489
+ return false
490
+ }
491
+ }
492
+
493
+ /**
494
+ * @param {unknown} value
495
+ */
496
+ function isPlainObject(value) {
497
+ return typeof value === "object" && value !== null && !Array.isArray(value)
498
+ }
499
+
500
+ /**
501
+ * @param {string} message
502
+ * @param {{input: NodeJS.ReadStream, output: NodeJS.WriteStream}} io
503
+ */
504
+ async function defaultPromptSecret(message, io) {
505
+ if (
506
+ !io.input.isTTY ||
507
+ !io.output.isTTY ||
508
+ typeof io.input.setRawMode !== "function"
509
+ ) {
510
+ throw new CliError(
511
+ "Secret access key prompt requires an interactive terminal.",
512
+ )
513
+ }
514
+
515
+ return await new Promise((resolve, reject) => {
516
+ let value = ""
517
+ const wasRaw = io.input.isRaw
518
+
519
+ io.output.write(message)
520
+ io.input.resume()
521
+ io.input.setEncoding("utf8")
522
+ io.input.setRawMode(true)
523
+
524
+ /**
525
+ * @param {string} chunk
526
+ */
527
+ const onData = (chunk) => {
528
+ for (const character of chunk) {
529
+ if (character === "\r" || character === "\n") {
530
+ cleanup()
531
+ io.output.write("\n")
532
+ resolve(value)
533
+ return
534
+ }
535
+ if (character === "\u0003" || character === "\u0004") {
536
+ cleanup()
537
+ io.output.write("\n")
538
+ reject(new CliError("Secret access key prompt cancelled."))
539
+ return
540
+ }
541
+ if (
542
+ character === "\u007f" ||
543
+ character === "\b" ||
544
+ character === "\x08"
545
+ ) {
546
+ value = value.slice(0, -1)
547
+ continue
548
+ }
549
+ value += character
550
+ }
551
+ }
552
+
553
+ const cleanup = () => {
554
+ io.input.off("data", onData)
555
+ io.input.setRawMode(Boolean(wasRaw))
556
+ io.input.pause()
557
+ }
558
+
559
+ io.input.on("data", onData)
560
+ })
561
+ }
package/src/types.d.ts DELETED
@@ -1,10 +0,0 @@
1
- type Translation = {
2
- type: "lightnet" | "user" | "map"
3
- key: string
4
- values: Record<string, string | undefined>
5
- }
6
-
7
- type Languages = {
8
- defaultLocale: string
9
- locales: string[]
10
- }