@lightnet/cli 4.4.1 → 4.5.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 +20 -0
- package/package.json +3 -3
- package/src/check-files.js +86 -20
- package/src/check-links.js +21 -11
- package/src/check-translations.js +16 -8
- package/src/index.js +72 -15
- package/src/r2.js +196 -0
- package/src/support/prompt-cancel.js +13 -0
- package/src/support/r2.js +112 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @lightnet/cli
|
|
2
2
|
|
|
3
|
+
## 4.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#437](https://github.com/LightNetDev/LightNet/pull/437) [`91446d3`](https://github.com/LightNetDev/LightNet/commit/91446d3102da0383dfffa836578c02286d0258c9) - Add `lightnet r2` commands for listing, deleting, and copying Cloudflare R2 files through the project R2 configuration.
|
|
8
|
+
|
|
9
|
+
## 4.4.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Make `check-files` summaries clearer by explaining what was compared and what needs attention.
|
|
14
|
+
|
|
15
|
+
- [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Handle Ctrl+C cancellation consistently for interactive `check-*` command prompts.
|
|
16
|
+
|
|
17
|
+
- [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Replace `check-files --fix --no-confirm` with `check-files --fix-without-confirm` for non-interactive cleanup.
|
|
18
|
+
|
|
19
|
+
- [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Treat warning-only `check-files` runs as successful when no local content file references are found.
|
|
20
|
+
|
|
21
|
+
- [#433](https://github.com/LightNetDev/LightNet/pull/433) [`dbde36e`](https://github.com/LightNetDev/LightNet/commit/dbde36e7a25bf6470783e9594260b666e09c2783) - Improve `check-files` and `check-links` output for long referenced paths and URLs.
|
|
22
|
+
|
|
3
23
|
## 4.4.1
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "CLI for managing LightNet projects",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.5.0",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
9
9
|
},
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"CHANGELOG.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"
|
|
29
|
-
"
|
|
28
|
+
"@clack/prompts": "^1.7.0",
|
|
29
|
+
"commander": "^15.0.0"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
32
|
"node": ">=22"
|
package/src/check-files.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
pathExists,
|
|
22
22
|
toLocalContentDisplayPath,
|
|
23
23
|
} from "./support/filesystem.js"
|
|
24
|
+
import { cancelPrompt } from "./support/prompt-cancel.js"
|
|
24
25
|
import { createR2FileStorage } from "./support/r2.js"
|
|
25
26
|
|
|
26
27
|
const supportedScopes = new Set(["content-files", "thumbnails"])
|
|
@@ -33,7 +34,7 @@ const categoryImagesDir = "src/content/categories/images"
|
|
|
33
34
|
* @typedef {{
|
|
34
35
|
* scope?: string
|
|
35
36
|
* fix?: boolean
|
|
36
|
-
*
|
|
37
|
+
* fixWithoutConfirm?: boolean
|
|
37
38
|
* r2?: boolean
|
|
38
39
|
* }} CheckFilesOptions
|
|
39
40
|
*/
|
|
@@ -170,7 +171,7 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
170
171
|
orphanedContentFiles = localContentResult.orphanedFiles
|
|
171
172
|
if (localContentResult.referenceCount === 0) {
|
|
172
173
|
warnings.push(
|
|
173
|
-
'No local "/files" content file references found.
|
|
174
|
+
'No local "/files" content file references found. If your content files are stored in R2, consider re-running with "--r2".',
|
|
174
175
|
)
|
|
175
176
|
}
|
|
176
177
|
}
|
|
@@ -180,8 +181,7 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
180
181
|
const thumbnailsResult = await runSpinner({
|
|
181
182
|
error: "Thumbnail check failed.",
|
|
182
183
|
start: "Checking thumbnails",
|
|
183
|
-
stop: (result) =>
|
|
184
|
-
`Checked thumbnails: ${result.orphanedMediaThumbnails.length} orphaned media, ${result.orphanedCategoryThumbnails.length} orphaned categories.`,
|
|
184
|
+
stop: (result) => formatThumbnailCheckSummary(result),
|
|
185
185
|
task: () =>
|
|
186
186
|
validateThumbnails(
|
|
187
187
|
mediaItems,
|
|
@@ -194,7 +194,7 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
194
194
|
orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
if (options.fix) {
|
|
197
|
+
if (options.fix || options.fixWithoutConfirm) {
|
|
198
198
|
/** @type {{displayPath:string, target:string}[]} */
|
|
199
199
|
const deletions = []
|
|
200
200
|
/** @type {{displayPath:string, target:string}[]} */
|
|
@@ -230,10 +230,10 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
if (deletions.length > 0) {
|
|
233
|
-
const shouldSkipConfirmation = options.
|
|
233
|
+
const shouldSkipConfirmation = options.fixWithoutConfirm === true
|
|
234
234
|
if (!shouldSkipConfirmation && !interactive) {
|
|
235
235
|
throw new CliError(
|
|
236
|
-
'Deletion requires confirmation. Re-run with "--fix
|
|
236
|
+
'Deletion requires confirmation. Re-run with "--fix-without-confirm" in non-interactive environments.',
|
|
237
237
|
)
|
|
238
238
|
}
|
|
239
239
|
const shouldDelete =
|
|
@@ -315,22 +315,21 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
315
315
|
}
|
|
316
316
|
|
|
317
317
|
const hasIssues =
|
|
318
|
-
warnings.length > 0 ||
|
|
319
318
|
missingContentFiles.length > 0 ||
|
|
320
319
|
wrongTypeR2ContentFiles.length > 0 ||
|
|
321
320
|
orphanedContentFiles.length > 0 ||
|
|
322
321
|
orphanedMediaThumbnails.length > 0 ||
|
|
323
322
|
orphanedCategoryThumbnails.length > 0
|
|
324
323
|
|
|
324
|
+
for (const warning of warnings) {
|
|
325
|
+
log.warn(warning)
|
|
326
|
+
}
|
|
327
|
+
|
|
325
328
|
if (!hasIssues && removedItems.length === 0) {
|
|
326
329
|
outro("No issues found. 🎉")
|
|
327
330
|
return true
|
|
328
331
|
}
|
|
329
332
|
|
|
330
|
-
for (const warning of warnings) {
|
|
331
|
-
log.warn(warning)
|
|
332
|
-
}
|
|
333
|
-
|
|
334
333
|
printMissingReferenceSection(
|
|
335
334
|
"Missing referenced content files",
|
|
336
335
|
missingContentFiles,
|
|
@@ -378,9 +377,7 @@ function printMissingReferenceSection(title, items) {
|
|
|
378
377
|
}
|
|
379
378
|
log.error(`${title} (${items.length})`)
|
|
380
379
|
for (const item of items) {
|
|
381
|
-
log.message(
|
|
382
|
-
`• ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
|
|
383
|
-
)
|
|
380
|
+
log.message(`• ${item.path}\n${formatReferenceSources(item.sources)}`)
|
|
384
381
|
}
|
|
385
382
|
}
|
|
386
383
|
|
|
@@ -395,11 +392,25 @@ function printWrongTypeReferenceSection(title, items) {
|
|
|
395
392
|
log.warn(`${title} (${items.length})`)
|
|
396
393
|
for (const item of items) {
|
|
397
394
|
log.message(
|
|
398
|
-
`• ${item.path}
|
|
395
|
+
`• ${item.path}\n Expected type: "upload"\n${formatReferenceSources(item.sources)}`,
|
|
399
396
|
)
|
|
400
397
|
}
|
|
401
398
|
}
|
|
402
399
|
|
|
400
|
+
/**
|
|
401
|
+
* @param {string[]} sources
|
|
402
|
+
*/
|
|
403
|
+
function formatReferenceSources(sources) {
|
|
404
|
+
const sortedSources = sources.toSorted()
|
|
405
|
+
if (sortedSources.length === 1) {
|
|
406
|
+
return ` Referenced by: ${sortedSources[0]}`
|
|
407
|
+
}
|
|
408
|
+
return [
|
|
409
|
+
" Referenced by:",
|
|
410
|
+
...sortedSources.map((source) => ` - ${source}`),
|
|
411
|
+
].join("\n")
|
|
412
|
+
}
|
|
413
|
+
|
|
403
414
|
/**
|
|
404
415
|
* @template T
|
|
405
416
|
* @param {{
|
|
@@ -425,13 +436,58 @@ async function runSpinner({ error, start, stop, task }) {
|
|
|
425
436
|
|
|
426
437
|
/**
|
|
427
438
|
* @param {{
|
|
439
|
+
* fileCount: number
|
|
428
440
|
* missingReferences: MissingReference[]
|
|
429
441
|
* orphanedFiles: string[]
|
|
430
442
|
* referenceCount: number
|
|
431
443
|
* }} result
|
|
432
444
|
*/
|
|
433
445
|
function formatContentCheckSummary(result) {
|
|
434
|
-
|
|
446
|
+
const issues = [
|
|
447
|
+
formatFindingCount(result.missingReferences.length, "missing reference"),
|
|
448
|
+
formatFindingCount(result.orphanedFiles.length, "orphaned file"),
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
if (result.referenceCount === 0 && result.fileCount === 0) {
|
|
452
|
+
return `Content files: none found.`
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return `Content files: ${formatCount(result.referenceCount, "reference")}, ${formatCount(result.fileCount, "file")}; ${issues.join(", ")}.`
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* @param {{
|
|
460
|
+
* fileCount: number
|
|
461
|
+
* orphanedCategoryThumbnails: string[]
|
|
462
|
+
* orphanedMediaThumbnails: string[]
|
|
463
|
+
* referenceCount: number
|
|
464
|
+
* }} result
|
|
465
|
+
*/
|
|
466
|
+
function formatThumbnailCheckSummary(result) {
|
|
467
|
+
const orphanedThumbnails =
|
|
468
|
+
result.orphanedMediaThumbnails.length +
|
|
469
|
+
result.orphanedCategoryThumbnails.length
|
|
470
|
+
|
|
471
|
+
return `Thumbnails: ${formatCount(result.referenceCount, "reference")}, ${formatCount(result.fileCount, "file")}; orphaned: ${orphanedThumbnails} (${result.orphanedMediaThumbnails.length} media, ${result.orphanedCategoryThumbnails.length} categories).`
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* @param {number} count
|
|
476
|
+
* @param {string} singular
|
|
477
|
+
*/
|
|
478
|
+
function formatFindingCount(count, singular) {
|
|
479
|
+
if (count === 0) {
|
|
480
|
+
return `no ${singular}s`
|
|
481
|
+
}
|
|
482
|
+
return formatCount(count, singular)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* @param {number} count
|
|
487
|
+
* @param {string} singular
|
|
488
|
+
*/
|
|
489
|
+
function formatCount(count, singular) {
|
|
490
|
+
return `${count} ${singular}${count === 1 ? "" : "s"}`
|
|
435
491
|
}
|
|
436
492
|
|
|
437
493
|
/**
|
|
@@ -515,12 +571,14 @@ async function validateThumbnails(
|
|
|
515
571
|
])
|
|
516
572
|
|
|
517
573
|
return {
|
|
574
|
+
fileCount: mediaFiles.length + categoryFiles.length,
|
|
518
575
|
orphanedMediaThumbnails: mediaFiles.filter(
|
|
519
576
|
(file) => !mediaReferences.has(file),
|
|
520
577
|
),
|
|
521
578
|
orphanedCategoryThumbnails: categoryFiles.filter(
|
|
522
579
|
(file) => !categoryReferences.has(file),
|
|
523
580
|
),
|
|
581
|
+
referenceCount: mediaReferences.size + categoryReferences.size,
|
|
524
582
|
}
|
|
525
583
|
}
|
|
526
584
|
|
|
@@ -542,8 +600,10 @@ async function validateContentFiles({
|
|
|
542
600
|
initializedStorage,
|
|
543
601
|
{ includeLinkReferences },
|
|
544
602
|
)
|
|
603
|
+
const files = await initializedStorage.list()
|
|
545
604
|
if (references.size === 0) {
|
|
546
605
|
return {
|
|
606
|
+
fileCount: files.length,
|
|
547
607
|
missingReferences: /** @type {MissingReference[]} */ ([]),
|
|
548
608
|
orphanedFiles: /** @type {string[]} */ ([]),
|
|
549
609
|
referenceCount: 0,
|
|
@@ -551,7 +611,6 @@ async function validateContentFiles({
|
|
|
551
611
|
}
|
|
552
612
|
}
|
|
553
613
|
|
|
554
|
-
const files = await initializedStorage.list()
|
|
555
614
|
const fileSet = new Set(files)
|
|
556
615
|
|
|
557
616
|
const missingReferences = [...references.entries()]
|
|
@@ -575,6 +634,7 @@ async function validateContentFiles({
|
|
|
575
634
|
.sort((a, b) => a.path.localeCompare(b.path))
|
|
576
635
|
|
|
577
636
|
return {
|
|
637
|
+
fileCount: files.length,
|
|
578
638
|
missingReferences,
|
|
579
639
|
orphanedFiles,
|
|
580
640
|
referenceCount: references.size,
|
|
@@ -695,7 +755,10 @@ function toPosixPath(filePath) {
|
|
|
695
755
|
*/
|
|
696
756
|
async function defaultPromptText(message) {
|
|
697
757
|
const answer = await text({ message })
|
|
698
|
-
|
|
758
|
+
if (isCancel(answer)) {
|
|
759
|
+
cancelPrompt()
|
|
760
|
+
}
|
|
761
|
+
return String(answer)
|
|
699
762
|
}
|
|
700
763
|
|
|
701
764
|
/**
|
|
@@ -706,5 +769,8 @@ async function defaultPromptConfirm(message) {
|
|
|
706
769
|
message,
|
|
707
770
|
initialValue: false,
|
|
708
771
|
})
|
|
709
|
-
|
|
772
|
+
if (isCancel(answer)) {
|
|
773
|
+
cancelPrompt()
|
|
774
|
+
}
|
|
775
|
+
return answer
|
|
710
776
|
}
|
package/src/check-links.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { CliError } from "./support/cli-error.js"
|
|
14
14
|
import { contentCollections } from "./support/content-collections.js"
|
|
15
15
|
import { pathExists } from "./support/filesystem.js"
|
|
16
|
+
import { cancelPrompt } from "./support/prompt-cancel.js"
|
|
16
17
|
|
|
17
18
|
const mediaDir = "src/content/media"
|
|
18
19
|
const defaultConcurrency = 24
|
|
@@ -126,11 +127,7 @@ export async function checkLinks(options, runtime = {}) {
|
|
|
126
127
|
log.error(`Unavailable links (${unavailableLinks.length})`)
|
|
127
128
|
for (const { reference, result } of unavailableLinks) {
|
|
128
129
|
log.message(
|
|
129
|
-
|
|
130
|
-
...reference.sources,
|
|
131
|
-
]
|
|
132
|
-
.toSorted()
|
|
133
|
-
.join(", ")})`,
|
|
130
|
+
`• ${reference.displayUrl}\n Result: ${formatFailure(result)}\n${formatReferenceSources(reference.sources)}`,
|
|
134
131
|
)
|
|
135
132
|
}
|
|
136
133
|
|
|
@@ -332,15 +329,25 @@ function printProtectedLinks(links) {
|
|
|
332
329
|
log.warn(`Protected or forbidden links (${links.length})`)
|
|
333
330
|
for (const { reference, result } of links) {
|
|
334
331
|
log.message(
|
|
335
|
-
|
|
336
|
-
...reference.sources,
|
|
337
|
-
]
|
|
338
|
-
.toSorted()
|
|
339
|
-
.join(", ")})`,
|
|
332
|
+
`• ${reference.displayUrl}\n Result: ${formatFailure(result)}\n${formatReferenceSources(reference.sources)}`,
|
|
340
333
|
)
|
|
341
334
|
}
|
|
342
335
|
}
|
|
343
336
|
|
|
337
|
+
/**
|
|
338
|
+
* @param {Set<string>} sources
|
|
339
|
+
*/
|
|
340
|
+
function formatReferenceSources(sources) {
|
|
341
|
+
const sortedSources = [...sources].toSorted()
|
|
342
|
+
if (sortedSources.length === 1) {
|
|
343
|
+
return ` Referenced by: ${sortedSources[0]}`
|
|
344
|
+
}
|
|
345
|
+
return [
|
|
346
|
+
" Referenced by:",
|
|
347
|
+
...sortedSources.map((source) => ` - ${source}`),
|
|
348
|
+
].join("\n")
|
|
349
|
+
}
|
|
350
|
+
|
|
344
351
|
/**
|
|
345
352
|
* @param {string} url
|
|
346
353
|
* @param {{
|
|
@@ -483,7 +490,10 @@ function toPosixPath(filePath) {
|
|
|
483
490
|
*/
|
|
484
491
|
async function defaultPromptText(message) {
|
|
485
492
|
const answer = await text({ message })
|
|
486
|
-
|
|
493
|
+
if (isCancel(answer)) {
|
|
494
|
+
cancelPrompt()
|
|
495
|
+
}
|
|
496
|
+
return String(answer)
|
|
487
497
|
}
|
|
488
498
|
|
|
489
499
|
/**
|
|
@@ -5,7 +5,9 @@ import { readFile } from "node:fs/promises"
|
|
|
5
5
|
import { resolve } from "node:path"
|
|
6
6
|
import { cwd } from "node:process"
|
|
7
7
|
|
|
8
|
-
import { confirm, intro, log, outro, taskLog } from "@clack/prompts"
|
|
8
|
+
import { confirm, intro, isCancel, log, outro, taskLog } from "@clack/prompts"
|
|
9
|
+
|
|
10
|
+
import { cancelPrompt } from "./support/prompt-cancel.js"
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* @typedef {{
|
|
@@ -100,13 +102,7 @@ export async function checkTranslations(options = {}) {
|
|
|
100
102
|
* @returns
|
|
101
103
|
*/
|
|
102
104
|
async function runBuild(build) {
|
|
103
|
-
const shouldRunBuild =
|
|
104
|
-
build ??
|
|
105
|
-
(await confirm({
|
|
106
|
-
message:
|
|
107
|
-
"Run pnpm build now? Command requires an up-to-date dist/ directory.",
|
|
108
|
-
initialValue: false,
|
|
109
|
-
}))
|
|
105
|
+
const shouldRunBuild = build ?? (await promptRunBuild())
|
|
110
106
|
if (!shouldRunBuild) {
|
|
111
107
|
return true
|
|
112
108
|
}
|
|
@@ -158,6 +154,18 @@ async function runBuild(build) {
|
|
|
158
154
|
}
|
|
159
155
|
}
|
|
160
156
|
|
|
157
|
+
async function promptRunBuild() {
|
|
158
|
+
const answer = await confirm({
|
|
159
|
+
message:
|
|
160
|
+
"Run pnpm build now? Command requires an up-to-date dist/ directory.",
|
|
161
|
+
initialValue: false,
|
|
162
|
+
})
|
|
163
|
+
if (isCancel(answer)) {
|
|
164
|
+
cancelPrompt()
|
|
165
|
+
}
|
|
166
|
+
return answer
|
|
167
|
+
}
|
|
168
|
+
|
|
161
169
|
/**
|
|
162
170
|
*
|
|
163
171
|
* @param {{title:string, action:string}} source
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,9 @@ import pkg from "../package.json" with { type: "json" }
|
|
|
7
7
|
import { checkFiles } from "./check-files.js"
|
|
8
8
|
import { checkLinks } from "./check-links.js"
|
|
9
9
|
import { checkTranslations } from "./check-translations.js"
|
|
10
|
+
import { copyR2, listR2, removeR2 } from "./r2.js"
|
|
10
11
|
import { CliError } from "./support/cli-error.js"
|
|
12
|
+
import { PromptCancelled } from "./support/prompt-cancel.js"
|
|
11
13
|
const { version } = pkg
|
|
12
14
|
|
|
13
15
|
const program = new Command()
|
|
@@ -24,8 +26,12 @@ program
|
|
|
24
26
|
.option("--build", "run pnpm build before checking translations")
|
|
25
27
|
.option("--no-build", "skip the build prompt and use the latest dist/ output")
|
|
26
28
|
.action(async (options) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
try {
|
|
30
|
+
const checkSuccessful = await checkTranslations(options)
|
|
31
|
+
commandExitCode = checkSuccessful ? 0 : 1
|
|
32
|
+
} catch (error) {
|
|
33
|
+
handleCommandError(error)
|
|
34
|
+
}
|
|
29
35
|
})
|
|
30
36
|
|
|
31
37
|
program
|
|
@@ -34,7 +40,7 @@ program
|
|
|
34
40
|
"check for missing and orphaned content files and thumbnails in a LightNet site",
|
|
35
41
|
)
|
|
36
42
|
.option("--fix", "remove orphaned files")
|
|
37
|
-
.option("--
|
|
43
|
+
.option("--fix-without-confirm", "remove orphaned files without confirmation")
|
|
38
44
|
.option(
|
|
39
45
|
"--r2",
|
|
40
46
|
"validate remote content files in Cloudflare R2 instead of public/files",
|
|
@@ -48,12 +54,7 @@ program
|
|
|
48
54
|
const checkSuccessful = await checkFiles(options)
|
|
49
55
|
commandExitCode = checkSuccessful ? 0 : 1
|
|
50
56
|
} catch (error) {
|
|
51
|
-
|
|
52
|
-
console.error(error.message)
|
|
53
|
-
commandExitCode = 1
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
throw error
|
|
57
|
+
handleCommandError(error)
|
|
57
58
|
}
|
|
58
59
|
})
|
|
59
60
|
|
|
@@ -66,14 +67,70 @@ program
|
|
|
66
67
|
const checkSuccessful = await checkLinks(options)
|
|
67
68
|
commandExitCode = checkSuccessful ? 0 : 1
|
|
68
69
|
} catch (error) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
handleCommandError(error)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const r2Command = program
|
|
75
|
+
.command("r2")
|
|
76
|
+
.description("manage Cloudflare R2 files with rclone")
|
|
77
|
+
|
|
78
|
+
r2Command
|
|
79
|
+
.command("ls")
|
|
80
|
+
.description("list R2 objects")
|
|
81
|
+
.argument("[path]", "R2 path or prefix")
|
|
82
|
+
.action(async (path) => {
|
|
83
|
+
try {
|
|
84
|
+
await listR2(path)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
handleCommandError(error)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
r2Command
|
|
91
|
+
.command("rm")
|
|
92
|
+
.description("delete an R2 file; use -r to delete a directory/prefix")
|
|
93
|
+
.argument("<path>", "R2 file path, or directory/prefix with -r")
|
|
94
|
+
.option("-r, --recursive", "delete a directory/prefix recursively")
|
|
95
|
+
.option("-f, --force", "delete without confirmation")
|
|
96
|
+
.action(async (path, options) => {
|
|
97
|
+
try {
|
|
98
|
+
await removeR2(path, options)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
handleCommandError(error)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
r2Command
|
|
105
|
+
.command("cp")
|
|
106
|
+
.description(
|
|
107
|
+
'copy files between R2 and the local filesystem; prefix the R2 side with "r2:"',
|
|
108
|
+
)
|
|
109
|
+
.argument("<source>", 'source path; use "r2:<path>" for R2')
|
|
110
|
+
.argument("<destination>", 'destination path; use "r2:<path>" for R2')
|
|
111
|
+
.action(async (source, destination) => {
|
|
112
|
+
try {
|
|
113
|
+
await copyR2(source, destination)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
handleCommandError(error)
|
|
75
116
|
}
|
|
76
117
|
})
|
|
77
118
|
|
|
78
119
|
await program.parseAsync()
|
|
79
120
|
process.exitCode = commandExitCode
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {unknown} error
|
|
124
|
+
*/
|
|
125
|
+
function handleCommandError(error) {
|
|
126
|
+
if (error instanceof PromptCancelled) {
|
|
127
|
+
commandExitCode = 0
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if (error instanceof CliError) {
|
|
131
|
+
console.error(error.message)
|
|
132
|
+
commandExitCode = 1
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
throw error
|
|
136
|
+
}
|
package/src/r2.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { resolve } from "node:path"
|
|
4
|
+
import {
|
|
5
|
+
cwd as processCwd,
|
|
6
|
+
env as processEnv,
|
|
7
|
+
stdin,
|
|
8
|
+
stdout,
|
|
9
|
+
} from "node:process"
|
|
10
|
+
|
|
11
|
+
import { confirm, isCancel, text } from "@clack/prompts"
|
|
12
|
+
|
|
13
|
+
import { CliError } from "./support/cli-error.js"
|
|
14
|
+
import { cancelPrompt } from "./support/prompt-cancel.js"
|
|
15
|
+
import { createR2Client } from "./support/r2.js"
|
|
16
|
+
|
|
17
|
+
const remotePathPrefix = "r2:"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* force?: boolean
|
|
22
|
+
* recursive?: boolean
|
|
23
|
+
* }} R2RemoveOptions
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{
|
|
28
|
+
* cwd?: string
|
|
29
|
+
* invocationCwd?: string
|
|
30
|
+
* isInteractive?: boolean
|
|
31
|
+
* promptConfirm?: (message: string) => Promise<boolean>
|
|
32
|
+
* promptText?: (message: string) => Promise<string>
|
|
33
|
+
* writeStdout?: (message: string) => void
|
|
34
|
+
* }} R2Runtime
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string|undefined} path
|
|
39
|
+
* @param {R2Runtime} [runtime]
|
|
40
|
+
*/
|
|
41
|
+
export async function listR2(path, runtime = {}) {
|
|
42
|
+
const client = createR2CommandClient(runtime)
|
|
43
|
+
const output = await client.list(path)
|
|
44
|
+
const writeStdout =
|
|
45
|
+
runtime.writeStdout ??
|
|
46
|
+
((message) => {
|
|
47
|
+
stdout.write(message)
|
|
48
|
+
})
|
|
49
|
+
writeStdout(output)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} path
|
|
54
|
+
* @param {R2RemoveOptions} options
|
|
55
|
+
* @param {R2Runtime} [runtime]
|
|
56
|
+
*/
|
|
57
|
+
export async function removeR2(path, options, runtime = {}) {
|
|
58
|
+
const r2Path = normalizeR2Path(path)
|
|
59
|
+
if (!r2Path) {
|
|
60
|
+
throw new CliError("Refusing to delete the R2 bucket root.")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const interactive = getInteractive(runtime)
|
|
64
|
+
if (!options.force && !interactive) {
|
|
65
|
+
throw new CliError(
|
|
66
|
+
'R2 deletion requires confirmation. Re-run with "--force" in non-interactive environments.',
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const shouldDelete =
|
|
71
|
+
options.force ||
|
|
72
|
+
(await getPromptConfirm(runtime)(`Delete R2 path "${r2Path}"? [y/N] `))
|
|
73
|
+
|
|
74
|
+
if (!shouldDelete) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const client = createR2CommandClient(runtime)
|
|
79
|
+
await client.remove(r2Path, {
|
|
80
|
+
recursive: options.recursive === true,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} source
|
|
86
|
+
* @param {string} destination
|
|
87
|
+
* @param {R2Runtime} [runtime]
|
|
88
|
+
*/
|
|
89
|
+
export async function copyR2(source, destination, runtime = {}) {
|
|
90
|
+
const sourcePath = parseCopyPath(source)
|
|
91
|
+
const destinationPath = parseCopyPath(destination)
|
|
92
|
+
if (sourcePath.remote === destinationPath.remote) {
|
|
93
|
+
throw new CliError(
|
|
94
|
+
`R2 copy requires exactly one R2 path. Prefix the remote side with "${remotePathPrefix}".`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const client = createR2CommandClient(runtime)
|
|
99
|
+
const rcloneSource = sourcePath.remote
|
|
100
|
+
? await client.toRemotePath(sourcePath.path)
|
|
101
|
+
: resolveLocalPath(sourcePath.path, runtime)
|
|
102
|
+
const rcloneDestination = destinationPath.remote
|
|
103
|
+
? await client.toRemotePath(destinationPath.path)
|
|
104
|
+
: resolveLocalPath(destinationPath.path, runtime)
|
|
105
|
+
|
|
106
|
+
await client.copy(rcloneSource, rcloneDestination)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {R2Runtime} runtime
|
|
111
|
+
*/
|
|
112
|
+
function createR2CommandClient(runtime) {
|
|
113
|
+
return createR2Client({
|
|
114
|
+
cwd: runtime.cwd ?? processCwd(),
|
|
115
|
+
interactive: getInteractive(runtime),
|
|
116
|
+
promptText: getPromptText(runtime),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {R2Runtime} runtime
|
|
122
|
+
*/
|
|
123
|
+
function getInteractive(runtime) {
|
|
124
|
+
return runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {R2Runtime} runtime
|
|
129
|
+
*/
|
|
130
|
+
function getPromptText(runtime) {
|
|
131
|
+
return runtime.promptText ?? defaultPromptText
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {R2Runtime} runtime
|
|
136
|
+
*/
|
|
137
|
+
function getPromptConfirm(runtime) {
|
|
138
|
+
return runtime.promptConfirm ?? defaultPromptConfirm
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} value
|
|
143
|
+
*/
|
|
144
|
+
function parseCopyPath(value) {
|
|
145
|
+
if (!value.startsWith(remotePathPrefix)) {
|
|
146
|
+
return { remote: false, path: value }
|
|
147
|
+
}
|
|
148
|
+
return { remote: true, path: normalizeR2Path(value) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} path
|
|
153
|
+
* @param {R2Runtime} runtime
|
|
154
|
+
*/
|
|
155
|
+
function resolveLocalPath(path, runtime) {
|
|
156
|
+
return resolve(getInvocationCwd(runtime), path)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {R2Runtime} runtime
|
|
161
|
+
*/
|
|
162
|
+
function getInvocationCwd(runtime) {
|
|
163
|
+
return runtime.invocationCwd ?? processEnv.INIT_CWD ?? processCwd()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string|undefined} path
|
|
168
|
+
*/
|
|
169
|
+
function normalizeR2Path(path) {
|
|
170
|
+
return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} message
|
|
175
|
+
*/
|
|
176
|
+
async function defaultPromptText(message) {
|
|
177
|
+
const answer = await text({ message })
|
|
178
|
+
if (isCancel(answer)) {
|
|
179
|
+
cancelPrompt()
|
|
180
|
+
}
|
|
181
|
+
return String(answer)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} message
|
|
186
|
+
*/
|
|
187
|
+
async function defaultPromptConfirm(message) {
|
|
188
|
+
const answer = await confirm({
|
|
189
|
+
message,
|
|
190
|
+
initialValue: false,
|
|
191
|
+
})
|
|
192
|
+
if (isCancel(answer)) {
|
|
193
|
+
cancelPrompt()
|
|
194
|
+
}
|
|
195
|
+
return answer
|
|
196
|
+
}
|
package/src/support/r2.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
writeCliConfig,
|
|
16
16
|
} from "./cli-config.js"
|
|
17
17
|
import { CliError } from "./cli-error.js"
|
|
18
|
+
import { cancelPrompt } from "./prompt-cancel.js"
|
|
18
19
|
|
|
19
20
|
const execFileAsync = promisify(execFile)
|
|
20
21
|
const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
|
|
@@ -89,7 +90,7 @@ export function createR2FileStorage({
|
|
|
89
90
|
/** @returns {Promise<string>} */
|
|
90
91
|
const getSecretAccessKey = async () => {
|
|
91
92
|
if (!secretAccessKey) {
|
|
92
|
-
secretAccessKey = await promptRequiredSecret()
|
|
93
|
+
secretAccessKey = await promptRequiredSecret({ interactive })
|
|
93
94
|
}
|
|
94
95
|
return secretAccessKey
|
|
95
96
|
}
|
|
@@ -165,6 +166,99 @@ export function createR2FileStorage({
|
|
|
165
166
|
return storage
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* @param {{
|
|
171
|
+
* cwd: string
|
|
172
|
+
* interactive: boolean
|
|
173
|
+
* promptText: (message: string) => Promise<string>
|
|
174
|
+
* }} args
|
|
175
|
+
*/
|
|
176
|
+
export function createR2Client({ cwd, interactive, promptText }) {
|
|
177
|
+
/** @type {R2Config|undefined} */
|
|
178
|
+
let config
|
|
179
|
+
/** @type {string|undefined} */
|
|
180
|
+
let secretAccessKey
|
|
181
|
+
|
|
182
|
+
/** @returns {Promise<R2Config>} */
|
|
183
|
+
const getConfig = async () => {
|
|
184
|
+
if (config) {
|
|
185
|
+
return config
|
|
186
|
+
}
|
|
187
|
+
const initializedConfig = await initR2Config({
|
|
188
|
+
cwd,
|
|
189
|
+
interactive,
|
|
190
|
+
promptText,
|
|
191
|
+
})
|
|
192
|
+
config = initializedConfig
|
|
193
|
+
return initializedConfig
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @returns {Promise<string>} */
|
|
197
|
+
const getSecretAccessKey = async () => {
|
|
198
|
+
if (!secretAccessKey) {
|
|
199
|
+
secretAccessKey = await promptRequiredSecret({ interactive })
|
|
200
|
+
}
|
|
201
|
+
return secretAccessKey
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
/**
|
|
206
|
+
* @param {string} [path]
|
|
207
|
+
*/
|
|
208
|
+
async list(path) {
|
|
209
|
+
const r2Config = await getConfig()
|
|
210
|
+
const result = await runConfiguredRclone({
|
|
211
|
+
args: [
|
|
212
|
+
"lsf",
|
|
213
|
+
makeR2Path(r2Config, normalizeR2ObjectPath(path)),
|
|
214
|
+
"--recursive",
|
|
215
|
+
"--files-only",
|
|
216
|
+
],
|
|
217
|
+
cwd,
|
|
218
|
+
config: r2Config,
|
|
219
|
+
secretAccessKey: await getSecretAccessKey(),
|
|
220
|
+
})
|
|
221
|
+
return result.stdout
|
|
222
|
+
},
|
|
223
|
+
/**
|
|
224
|
+
* @param {string} path
|
|
225
|
+
* @param {{recursive?: boolean}} [options]
|
|
226
|
+
*/
|
|
227
|
+
async remove(path, options = {}) {
|
|
228
|
+
const r2Config = await getConfig()
|
|
229
|
+
const secretAccessKey = await getSecretAccessKey()
|
|
230
|
+
const r2Path = makeR2Path(r2Config, normalizeR2ObjectPath(path))
|
|
231
|
+
await runConfiguredRclone({
|
|
232
|
+
args: options.recursive
|
|
233
|
+
? ["delete", r2Path, "--rmdirs"]
|
|
234
|
+
: ["deletefile", r2Path],
|
|
235
|
+
cwd,
|
|
236
|
+
config: r2Config,
|
|
237
|
+
secretAccessKey,
|
|
238
|
+
})
|
|
239
|
+
},
|
|
240
|
+
/**
|
|
241
|
+
* @param {string} source
|
|
242
|
+
* @param {string} destination
|
|
243
|
+
*/
|
|
244
|
+
async copy(source, destination) {
|
|
245
|
+
const r2Config = await getConfig()
|
|
246
|
+
await runConfiguredRclone({
|
|
247
|
+
args: ["copy", source, destination],
|
|
248
|
+
cwd,
|
|
249
|
+
config: r2Config,
|
|
250
|
+
secretAccessKey: await getSecretAccessKey(),
|
|
251
|
+
})
|
|
252
|
+
},
|
|
253
|
+
/**
|
|
254
|
+
* @param {string} path
|
|
255
|
+
*/
|
|
256
|
+
async toRemotePath(path) {
|
|
257
|
+
return makeR2Path(await getConfig(), normalizeR2ObjectPath(path))
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
168
262
|
/**
|
|
169
263
|
* @param {string} path
|
|
170
264
|
*/
|
|
@@ -365,6 +459,7 @@ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
|
|
|
365
459
|
"auto",
|
|
366
460
|
"--s3-endpoint",
|
|
367
461
|
getR2Endpoint(config.accountId),
|
|
462
|
+
"--s3-no-check-bucket",
|
|
368
463
|
],
|
|
369
464
|
cwd,
|
|
370
465
|
env,
|
|
@@ -399,20 +494,27 @@ async function promptForR2Config({ cwd, promptText }) {
|
|
|
399
494
|
}
|
|
400
495
|
|
|
401
496
|
/**
|
|
497
|
+
* @param {{interactive?: boolean}} [args]
|
|
402
498
|
* @returns {Promise<string>}
|
|
403
499
|
*/
|
|
404
|
-
async function promptRequiredSecret() {
|
|
500
|
+
async function promptRequiredSecret({ interactive = true } = {}) {
|
|
405
501
|
const sessionSecret = processEnv[sessionSecretEnvName]
|
|
406
502
|
if (typeof sessionSecret === "string" && sessionSecret.trim()) {
|
|
407
503
|
return sessionSecret
|
|
408
504
|
}
|
|
409
505
|
|
|
506
|
+
if (!interactive) {
|
|
507
|
+
throw new CliError(
|
|
508
|
+
`Missing R2 secret access key. Set ${sessionSecretEnvName} in non-interactive environments.`,
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
410
512
|
while (true) {
|
|
411
513
|
const value = await password({
|
|
412
514
|
message: "R2 secretAccessKey:",
|
|
413
515
|
})
|
|
414
516
|
if (isCancel(value)) {
|
|
415
|
-
|
|
517
|
+
cancelPrompt()
|
|
416
518
|
}
|
|
417
519
|
if (value.trim()) {
|
|
418
520
|
processEnv[sessionSecretEnvName] = value
|
|
@@ -421,6 +523,13 @@ async function promptRequiredSecret() {
|
|
|
421
523
|
}
|
|
422
524
|
}
|
|
423
525
|
|
|
526
|
+
/**
|
|
527
|
+
* @param {string} [path]
|
|
528
|
+
*/
|
|
529
|
+
function normalizeR2ObjectPath(path) {
|
|
530
|
+
return path?.replace(/^r2:/, "").replace(/^\/+/, "") ?? ""
|
|
531
|
+
}
|
|
532
|
+
|
|
424
533
|
/**
|
|
425
534
|
* @param {(message:string)=>Promise<string>} promptText
|
|
426
535
|
* @param {string} message
|