@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.
@@ -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
+ }
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
- }