@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.
- package/CHANGELOG.md +12 -0
- package/README.md +6 -55
- package/package.json +2 -2
- 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
|
@@ -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
|
-
|
|
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
|
+
}
|