@lightnet/cli 4.1.1 → 4.3.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/README.md +2 -2
- package/package.json +3 -2
- package/src/check-files.js +526 -0
- package/src/check-links.js +456 -0
- package/src/check-translations.js +96 -18
- package/src/index.js +53 -3
- package/src/support/cli-config.js +57 -0
- package/src/support/cli-error.js +1 -0
- package/src/support/content-collections.js +191 -0
- package/src/support/filesystem.js +216 -0
- package/src/support/r2.js +414 -0
- package/src/types.d.ts +0 -10
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { execFile } from "node:child_process"
|
|
4
|
+
import { env as processEnv } from "node:process"
|
|
5
|
+
import { promisify } from "node:util"
|
|
6
|
+
|
|
7
|
+
import { isCancel, log, password } from "@clack/prompts"
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
cliConfigFileName,
|
|
11
|
+
readCliConfig,
|
|
12
|
+
writeCliConfig,
|
|
13
|
+
} from "./cli-config.js"
|
|
14
|
+
import { CliError } from "./cli-error.js"
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile)
|
|
17
|
+
const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* publicUrl: string
|
|
22
|
+
* accountId: string
|
|
23
|
+
* accessKeyId: string
|
|
24
|
+
* bucket: string
|
|
25
|
+
* }} R2Config
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{
|
|
30
|
+
* cwd: string
|
|
31
|
+
* interactive: boolean
|
|
32
|
+
* promptText: (message: string) => Promise<string>
|
|
33
|
+
* }} args
|
|
34
|
+
*/
|
|
35
|
+
async function initR2Config({ cwd, interactive, promptText }) {
|
|
36
|
+
const config = await readCliConfig(cwd)
|
|
37
|
+
const parsed = parseR2Config(config)
|
|
38
|
+
if (parsed) {
|
|
39
|
+
return parsed
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!interactive) {
|
|
43
|
+
throw new CliError(
|
|
44
|
+
`Missing valid ${cliConfigFileName} with "r2.publicUrl", "r2.accountId", "r2.accessKeyId", and "r2.bucket".`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
log.error(`Missing R2 config in "${cliConfigFileName}".`)
|
|
49
|
+
return promptForR2Config({ cwd, promptText })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{
|
|
54
|
+
* cwd: string
|
|
55
|
+
* interactive: boolean
|
|
56
|
+
* promptConfirm: (message: string) => Promise<boolean>
|
|
57
|
+
* promptText: (message: string) => Promise<string>
|
|
58
|
+
* }} args
|
|
59
|
+
*/
|
|
60
|
+
export function createR2FileStorage({
|
|
61
|
+
cwd,
|
|
62
|
+
interactive,
|
|
63
|
+
promptConfirm,
|
|
64
|
+
promptText,
|
|
65
|
+
}) {
|
|
66
|
+
/** @type {R2Config|undefined} */
|
|
67
|
+
let config
|
|
68
|
+
/** @type {string|undefined} */
|
|
69
|
+
let secretAccessKey
|
|
70
|
+
|
|
71
|
+
/** @returns {Promise<R2Config>} */
|
|
72
|
+
const getConfig = async () => {
|
|
73
|
+
if (config) {
|
|
74
|
+
return config
|
|
75
|
+
}
|
|
76
|
+
const initializedConfig = await initR2Config({
|
|
77
|
+
cwd,
|
|
78
|
+
interactive,
|
|
79
|
+
promptText,
|
|
80
|
+
})
|
|
81
|
+
config = initializedConfig
|
|
82
|
+
return initializedConfig
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @returns {Promise<string>} */
|
|
86
|
+
const getSecretAccessKey = async () => {
|
|
87
|
+
if (!secretAccessKey) {
|
|
88
|
+
secretAccessKey = await promptRequiredSecret()
|
|
89
|
+
}
|
|
90
|
+
return secretAccessKey
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @template T
|
|
95
|
+
* @param {(config: R2Config) => Promise<T>} operation
|
|
96
|
+
*/
|
|
97
|
+
const runWithRefresh = async (operation) => {
|
|
98
|
+
try {
|
|
99
|
+
return await operation(await getConfig())
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!interactive) {
|
|
102
|
+
throw error
|
|
103
|
+
}
|
|
104
|
+
log.error(error instanceof Error ? error.message : "R2 operation failed.")
|
|
105
|
+
const shouldRefresh = await promptConfirm(
|
|
106
|
+
"Refresh saved R2 settings and try again? [y/N] ",
|
|
107
|
+
)
|
|
108
|
+
if (!shouldRefresh) {
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
const refreshedConfig = await promptForR2Config({ cwd, promptText })
|
|
112
|
+
config = refreshedConfig
|
|
113
|
+
return operation(refreshedConfig)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const storage = {
|
|
118
|
+
async init() {
|
|
119
|
+
await getConfig()
|
|
120
|
+
return storage
|
|
121
|
+
},
|
|
122
|
+
async list() {
|
|
123
|
+
return runWithRefresh(async (config) => {
|
|
124
|
+
const secretAccessKey = await getSecretAccessKey()
|
|
125
|
+
const sharedArgs = {
|
|
126
|
+
cwd,
|
|
127
|
+
config,
|
|
128
|
+
secretAccessKey,
|
|
129
|
+
}
|
|
130
|
+
await assertR2Access(sharedArgs)
|
|
131
|
+
const bucketObjects = await listR2Objects(sharedArgs)
|
|
132
|
+
return bucketObjects.map((item) => item.key).sort()
|
|
133
|
+
})
|
|
134
|
+
},
|
|
135
|
+
/**
|
|
136
|
+
* @param {string[]} paths
|
|
137
|
+
*/
|
|
138
|
+
async delete(paths) {
|
|
139
|
+
const r2Config = await getConfig()
|
|
140
|
+
const secretAccessKey = await getSecretAccessKey()
|
|
141
|
+
/** @type {string[]} */
|
|
142
|
+
const removed = []
|
|
143
|
+
for (const path of paths) {
|
|
144
|
+
try {
|
|
145
|
+
await runConfiguredRclone({
|
|
146
|
+
args: ["deletefile", makeR2Path(r2Config, path)],
|
|
147
|
+
cwd,
|
|
148
|
+
config: r2Config,
|
|
149
|
+
secretAccessKey,
|
|
150
|
+
})
|
|
151
|
+
removed.push(path)
|
|
152
|
+
} catch {
|
|
153
|
+
// The caller prints per-file failures based on the returned paths.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return removed
|
|
157
|
+
},
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} url
|
|
160
|
+
*/
|
|
161
|
+
toPath(url) {
|
|
162
|
+
if (!config) {
|
|
163
|
+
throw new CliError("R2 storage must be initialized before use.")
|
|
164
|
+
}
|
|
165
|
+
const normalizedBase = config.publicUrl.replace(/\/+$/, "")
|
|
166
|
+
if (!url.startsWith(normalizedBase)) {
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
const path = url.slice(normalizedBase.length).replace(/^\/+/, "")
|
|
170
|
+
return path || undefined
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
return storage
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {{
|
|
178
|
+
* cwd: string
|
|
179
|
+
* config: R2Config
|
|
180
|
+
* secretAccessKey: string
|
|
181
|
+
* }} args
|
|
182
|
+
*/
|
|
183
|
+
async function assertR2Access(args) {
|
|
184
|
+
try {
|
|
185
|
+
await runConfiguredRclone({
|
|
186
|
+
args: ["lsjson", makeR2Path(args.config), "--files-only"],
|
|
187
|
+
...args,
|
|
188
|
+
})
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new CliError(
|
|
191
|
+
error instanceof Error
|
|
192
|
+
? `Unable to access R2 bucket "${args.config.bucket}": ${error.message}`
|
|
193
|
+
: `Unable to access R2 bucket "${args.config.bucket}".`,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {{
|
|
200
|
+
* cwd: string
|
|
201
|
+
* config: R2Config
|
|
202
|
+
* secretAccessKey: string
|
|
203
|
+
* }} args
|
|
204
|
+
*/
|
|
205
|
+
async function listR2Objects(args) {
|
|
206
|
+
const { stdout: listJson } = await runConfiguredRclone({
|
|
207
|
+
args: [
|
|
208
|
+
"lsjson",
|
|
209
|
+
makeR2Path(args.config),
|
|
210
|
+
"--recursive",
|
|
211
|
+
"--files-only",
|
|
212
|
+
"--fast-list",
|
|
213
|
+
"--no-mimetype",
|
|
214
|
+
"--no-modtime",
|
|
215
|
+
],
|
|
216
|
+
...args,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
let parsed
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(listJson)
|
|
222
|
+
} catch {
|
|
223
|
+
throw new CliError(
|
|
224
|
+
`Unable to parse rclone listing for bucket "${args.config.bucket}".`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
if (!Array.isArray(parsed)) {
|
|
228
|
+
throw new CliError(
|
|
229
|
+
`Unexpected rclone listing output for bucket "${args.config.bucket}".`,
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return parsed
|
|
234
|
+
.filter((item) => isPlainObject(item) && typeof item.Path === "string")
|
|
235
|
+
.map((item) => ({ key: item.Path }))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {{
|
|
240
|
+
* args: string[]
|
|
241
|
+
* cwd: string
|
|
242
|
+
* config: R2Config
|
|
243
|
+
* secretAccessKey: string
|
|
244
|
+
* }} args
|
|
245
|
+
*/
|
|
246
|
+
async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
|
|
247
|
+
const env = {
|
|
248
|
+
...processEnv,
|
|
249
|
+
RCLONE_S3_SECRET_ACCESS_KEY: secretAccessKey,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
return await defaultRunRclone(
|
|
254
|
+
[
|
|
255
|
+
...args,
|
|
256
|
+
"--s3-provider",
|
|
257
|
+
"Cloudflare",
|
|
258
|
+
"--s3-access-key-id",
|
|
259
|
+
config.accessKeyId,
|
|
260
|
+
"--s3-region",
|
|
261
|
+
"auto",
|
|
262
|
+
"--s3-endpoint",
|
|
263
|
+
getR2Endpoint(config.accountId),
|
|
264
|
+
],
|
|
265
|
+
cwd,
|
|
266
|
+
env,
|
|
267
|
+
)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
throw new CliError(getSanitizedRcloneError(error))
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {{cwd:string, promptText:(message:string)=>Promise<string>}} args
|
|
275
|
+
*/
|
|
276
|
+
async function promptForR2Config({ cwd, promptText }) {
|
|
277
|
+
const publicUrl = await promptRequiredText(promptText, "R2 publicUrl: ")
|
|
278
|
+
const accountId = await promptRequiredText(promptText, "R2 accountId: ")
|
|
279
|
+
const accessKeyId = await promptRequiredText(promptText, "R2 accessKeyId: ")
|
|
280
|
+
const bucket = await promptRequiredText(promptText, "R2 bucket: ")
|
|
281
|
+
|
|
282
|
+
const nextConfig = {
|
|
283
|
+
...(await readCliConfig(cwd)),
|
|
284
|
+
r2: {
|
|
285
|
+
publicUrl,
|
|
286
|
+
accountId,
|
|
287
|
+
accessKeyId,
|
|
288
|
+
bucket,
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await writeCliConfig(cwd, nextConfig)
|
|
293
|
+
|
|
294
|
+
return nextConfig.r2
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @returns {Promise<string>}
|
|
299
|
+
*/
|
|
300
|
+
async function promptRequiredSecret() {
|
|
301
|
+
const sessionSecret = processEnv[sessionSecretEnvName]
|
|
302
|
+
if (typeof sessionSecret === "string" && sessionSecret.trim()) {
|
|
303
|
+
return sessionSecret
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
while (true) {
|
|
307
|
+
const value = await password({
|
|
308
|
+
message: "R2 secretAccessKey:",
|
|
309
|
+
})
|
|
310
|
+
if (isCancel(value)) {
|
|
311
|
+
throw new CliError("Secret access key prompt cancelled.")
|
|
312
|
+
}
|
|
313
|
+
if (value.trim()) {
|
|
314
|
+
processEnv[sessionSecretEnvName] = value
|
|
315
|
+
return value
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {(message:string)=>Promise<string>} promptText
|
|
322
|
+
* @param {string} message
|
|
323
|
+
*/
|
|
324
|
+
async function promptRequiredText(promptText, message) {
|
|
325
|
+
while (true) {
|
|
326
|
+
const value = (await promptText(message)).trim()
|
|
327
|
+
if (value) {
|
|
328
|
+
return value
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @param {unknown} config
|
|
335
|
+
* @returns {R2Config|undefined}
|
|
336
|
+
*/
|
|
337
|
+
function parseR2Config(config) {
|
|
338
|
+
if (!isPlainObject(config)) {
|
|
339
|
+
return undefined
|
|
340
|
+
}
|
|
341
|
+
const rootConfig = /** @type {Record<string, unknown>} */ (config)
|
|
342
|
+
if (!isPlainObject(rootConfig.r2)) {
|
|
343
|
+
return undefined
|
|
344
|
+
}
|
|
345
|
+
const r2Config = /** @type {Record<string, unknown>} */ (rootConfig.r2)
|
|
346
|
+
const { publicUrl, accountId, accessKeyId, bucket } = r2Config
|
|
347
|
+
if (
|
|
348
|
+
typeof publicUrl !== "string" ||
|
|
349
|
+
typeof accountId !== "string" ||
|
|
350
|
+
typeof accessKeyId !== "string" ||
|
|
351
|
+
typeof bucket !== "string" ||
|
|
352
|
+
!publicUrl ||
|
|
353
|
+
!accountId ||
|
|
354
|
+
!accessKeyId ||
|
|
355
|
+
!bucket
|
|
356
|
+
) {
|
|
357
|
+
return undefined
|
|
358
|
+
}
|
|
359
|
+
return { publicUrl, accountId, accessKeyId, bucket }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* @param {R2Config} config
|
|
364
|
+
* @param {string} [objectKey]
|
|
365
|
+
*/
|
|
366
|
+
function makeR2Path(config, objectKey = "") {
|
|
367
|
+
return objectKey
|
|
368
|
+
? `:s3:${config.bucket}/${objectKey}`
|
|
369
|
+
: `:s3:${config.bucket}`
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {string} accountId
|
|
374
|
+
*/
|
|
375
|
+
function getR2Endpoint(accountId) {
|
|
376
|
+
return `https://${accountId}.r2.cloudflarestorage.com`
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* @param {string[]} args
|
|
381
|
+
* @param {string} cwd
|
|
382
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
383
|
+
*/
|
|
384
|
+
async function defaultRunRclone(args, cwd, env) {
|
|
385
|
+
return execFileAsync("rclone", args, {
|
|
386
|
+
cwd,
|
|
387
|
+
env: env ?? processEnv,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @param {unknown} error
|
|
393
|
+
*/
|
|
394
|
+
function getSanitizedRcloneError(error) {
|
|
395
|
+
if (isPlainObject(error)) {
|
|
396
|
+
const processError = /** @type {{stderr?: unknown, stdout?: unknown}} */ (
|
|
397
|
+
error
|
|
398
|
+
)
|
|
399
|
+
if (typeof processError.stderr === "string" && processError.stderr.trim()) {
|
|
400
|
+
return processError.stderr.trim()
|
|
401
|
+
}
|
|
402
|
+
if (typeof processError.stdout === "string" && processError.stdout.trim()) {
|
|
403
|
+
return processError.stdout.trim()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return "rclone command failed."
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* @param {unknown} value
|
|
411
|
+
*/
|
|
412
|
+
function isPlainObject(value) {
|
|
413
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
414
|
+
}
|