@lightnet/cli 4.2.0 → 4.4.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/src/support/r2.js CHANGED
@@ -1,13 +1,19 @@
1
1
  // @ts-check
2
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"
3
+ import { execFile } from "node:child_process"
4
+ import { env as processEnv } from "node:process"
5
+ import { promisify } from "node:util"
6
6
 
7
+ import { isCancel, log, password } from "@clack/prompts"
8
+
9
+ import {
10
+ cliConfigFileName,
11
+ readCliConfig,
12
+ writeCliConfig,
13
+ } from "./cli-config.js"
7
14
  import { CliError } from "./cli-error.js"
8
15
 
9
- const cliConfigFileName = ".lightnet-cli.config.json"
10
- const gitIgnoreEntry = `${cliConfigFileName}\n`
16
+ const execFileAsync = promisify(execFile)
11
17
  const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
12
18
 
13
19
  /**
@@ -19,26 +25,14 @@ const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
19
25
  * }} R2Config
20
26
  */
21
27
 
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
28
  /**
34
29
  * @param {{
35
30
  * cwd: string
36
31
  * interactive: boolean
37
- * logger: {error: (...args: unknown[]) => void}
38
32
  * promptText: (message: string) => Promise<string>
39
33
  * }} args
40
34
  */
41
- export async function ensureR2Config({ cwd, interactive, logger, promptText }) {
35
+ async function initR2Config({ cwd, interactive, promptText }) {
42
36
  const config = await readCliConfig(cwd)
43
37
  const parsed = parseR2Config(config)
44
38
  if (parsed) {
@@ -51,7 +45,7 @@ export async function ensureR2Config({ cwd, interactive, logger, promptText }) {
51
45
  )
52
46
  }
53
47
 
54
- logger.error(`Missing R2 config in "${cliConfigFileName}".`)
48
+ log.error(`Missing R2 config in "${cliConfigFileName}".`)
55
49
  return promptForR2Config({ cwd, promptText })
56
50
  }
57
51
 
@@ -59,157 +53,131 @@ export async function ensureR2Config({ cwd, interactive, logger, promptText }) {
59
53
  * @param {{
60
54
  * cwd: string
61
55
  * interactive: boolean
62
- * logger: {error: (...args: unknown[]) => void}
63
56
  * promptConfirm: (message: string) => Promise<boolean>
64
57
  * 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
58
  * }} args
70
59
  */
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
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
89
75
  }
90
- const refreshedConfig = await promptForR2Config({
91
- cwd: args.cwd,
92
- promptText: args.promptText,
76
+ const initializedConfig = await initR2Config({
77
+ cwd,
78
+ interactive,
79
+ promptText,
93
80
  })
94
- return {
95
- ...(await validateR2References({
96
- ...args,
97
- config: refreshedConfig,
98
- })),
99
- config: refreshedConfig,
100
- }
81
+ config = initializedConfig
82
+ return initializedConfig
101
83
  }
102
- }
103
84
 
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
85
+ /** @returns {Promise<string>} */
86
+ const getSecretAccessKey = async () => {
87
+ if (!secretAccessKey) {
88
+ secretAccessKey = await promptRequiredSecret()
137
89
  }
90
+ return secretAccessKey
138
91
  }
139
92
 
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,
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)
164
114
  }
165
115
  }
166
116
 
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,
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
+ },
204
172
  }
173
+ return storage
205
174
  }
206
175
 
207
176
  /**
208
177
  * @param {{
209
178
  * cwd: string
210
179
  * config: R2Config
211
- * promptSecret: (message: string) => Promise<string>
212
- * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
180
+ * secretAccessKey: string
213
181
  * }} args
214
182
  */
215
183
  async function assertR2Access(args) {
@@ -231,8 +199,7 @@ async function assertR2Access(args) {
231
199
  * @param {{
232
200
  * cwd: string
233
201
  * config: R2Config
234
- * promptSecret: (message: string) => Promise<string>
235
- * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
202
+ * secretAccessKey: string
236
203
  * }} args
237
204
  */
238
205
  async function listR2Objects(args) {
@@ -273,25 +240,17 @@ async function listR2Objects(args) {
273
240
  * args: string[]
274
241
  * cwd: string
275
242
  * config: R2Config
276
- * promptSecret: (message: string) => Promise<string>
277
- * runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
243
+ * secretAccessKey: string
278
244
  * }} args
279
245
  */
280
- async function runConfiguredRclone({
281
- args,
282
- cwd,
283
- config,
284
- promptSecret,
285
- runRclone,
286
- }) {
287
- const secretAccessKey = await promptSecret("R2 secretAccessKey: ")
246
+ async function runConfiguredRclone({ args, cwd, config, secretAccessKey }) {
288
247
  const env = {
289
248
  ...processEnv,
290
249
  RCLONE_S3_SECRET_ACCESS_KEY: secretAccessKey,
291
250
  }
292
251
 
293
252
  try {
294
- return await runRclone(
253
+ return await defaultRunRclone(
295
254
  [
296
255
  ...args,
297
256
  "--s3-provider",
@@ -330,37 +289,27 @@ async function promptForR2Config({ cwd, promptText }) {
330
289
  },
331
290
  }
332
291
 
333
- await writeFile(
334
- resolve(cwd, cliConfigFileName),
335
- `${JSON.stringify(nextConfig, null, 2)}\n`,
336
- "utf8",
337
- )
338
- await ensureGitignoreContainsConfig(cwd)
292
+ await writeCliConfig(cwd, nextConfig)
339
293
 
340
294
  return nextConfig.r2
341
295
  }
342
296
 
343
297
  /**
344
- * @param {((message:string)=>Promise<string>) | undefined} promptSecret
345
298
  * @returns {Promise<string>}
346
299
  */
347
- async function promptRequiredSecret(promptSecret) {
300
+ async function promptRequiredSecret() {
348
301
  const sessionSecret = processEnv[sessionSecretEnvName]
349
302
  if (typeof sessionSecret === "string" && sessionSecret.trim()) {
350
303
  return sessionSecret
351
304
  }
352
305
 
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
306
  while (true) {
363
- const value = await prompt("R2 secretAccessKey: ")
307
+ const value = await password({
308
+ message: "R2 secretAccessKey:",
309
+ })
310
+ if (isCancel(value)) {
311
+ throw new CliError("Secret access key prompt cancelled.")
312
+ }
364
313
  if (value.trim()) {
365
314
  processEnv[sessionSecretEnvName] = value
366
315
  return value
@@ -381,22 +330,6 @@ async function promptRequiredText(promptText, message) {
381
330
  }
382
331
  }
383
332
 
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
333
  /**
401
334
  * @param {unknown} config
402
335
  * @returns {R2Config|undefined}
@@ -426,23 +359,6 @@ function parseR2Config(config) {
426
359
  return { publicUrl, accountId, accessKeyId, bucket }
427
360
  }
428
361
 
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
362
  /**
447
363
  * @param {R2Config} config
448
364
  * @param {string} [objectKey]
@@ -460,6 +376,18 @@ function getR2Endpoint(accountId) {
460
376
  return `https://${accountId}.r2.cloudflarestorage.com`
461
377
  }
462
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
+
463
391
  /**
464
392
  * @param {unknown} error
465
393
  */
@@ -478,84 +406,9 @@ function getSanitizedRcloneError(error) {
478
406
  return "rclone command failed."
479
407
  }
480
408
 
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
409
  /**
494
410
  * @param {unknown} value
495
411
  */
496
412
  function isPlainObject(value) {
497
413
  return typeof value === "object" && value !== null && !Array.isArray(value)
498
414
  }
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
- }