s3-storage-cli 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -12,45 +12,73 @@ Runtime requirement:
12
12
 
13
13
  - `bun` must be installed because the published CLI executes with Bun
14
14
 
15
+ Install without global npm setup:
16
+
17
+ ```bash
18
+ npx s3-storage-cli status
19
+ ```
20
+
15
21
  Quick start:
16
22
 
17
23
  ```bash
24
+ s3-storage setup
18
25
  s3-storage status
19
26
  ```
20
27
 
21
28
  Commands:
22
29
 
30
+ - `setup` prompts for required scoped config values and saves them for this CLI
23
31
  - `status` verifies env, SQLite, and S3 connectivity
24
32
  - `list` shows only objects tracked by this CLI
25
33
  - `upload` uploads one or more files or directories
26
34
  - `delete` removes tracked objects
27
35
  - `share` returns a direct public URL or a signed private URL
28
36
 
29
- Required env:
37
+ Scoped config keys used by this CLI:
30
38
 
31
- - `S3_ENDPOINT`
32
- - `S3_REGION`
33
- - `S3_ACCESS_KEY_ID`
34
- - `S3_SECRET_ACCESS_KEY`
35
- - `S3_BUCKET`
39
+ - `S3_STORAGE_CLI_ENDPOINT`
40
+ - `S3_STORAGE_CLI_REGION`
41
+ - `S3_STORAGE_CLI_ACCESS_KEY_ID`
42
+ - `S3_STORAGE_CLI_SECRET_ACCESS_KEY`
43
+ - `S3_STORAGE_CLI_BUCKET`
36
44
 
37
45
  Required for full readiness and public sharing:
38
46
 
39
- - `S3_PUBLIC_BASE_URL`
47
+ - `S3_STORAGE_CLI_PUBLIC_BASE_URL`
40
48
 
41
49
  Optional env:
42
50
 
43
- - `S3_CLI_DB_PATH`
44
- - `S3_SHARE_TTL_SECONDS`
45
- - `S3_SESSION_TOKEN`
46
- - `S3_VIRTUAL_HOSTED_STYLE`
51
+ - `S3_STORAGE_CLI_DB_PATH`
52
+ - `S3_STORAGE_CLI_SHARE_TTL_SECONDS`
53
+ - `S3_STORAGE_CLI_SESSION_TOKEN`
54
+ - `S3_STORAGE_CLI_VIRTUAL_HOSTED_STYLE`
55
+ - `S3_STORAGE_CLI_ENV_PATH`
56
+
57
+ `setup` writes the required scoped config to the CLI-owned env file under `~/.s3-storage-cli/config.env` by default.
47
58
 
48
59
  Examples:
49
60
 
50
61
  ```bash
62
+ s3-storage setup
51
63
  s3-storage upload ./file.txt
52
64
  s3-storage upload ./assets --public --prefix site
53
65
  s3-storage list
54
66
  s3-storage share site/assets/logo.png
55
67
  s3-storage delete site/assets/logo.png
56
68
  ```
69
+
70
+ ## Agent Skill
71
+
72
+ This repo is also compatible with `skills.sh` style skill installers.
73
+
74
+ List the skill from GitHub:
75
+
76
+ ```bash
77
+ npx skills add muneebhashone/s3-storage-cli -l
78
+ ```
79
+
80
+ Install the skill:
81
+
82
+ ```bash
83
+ npx skills add muneebhashone/s3-storage-cli --skill s3-storage-cli
84
+ ```
package/SKILL.md CHANGED
@@ -9,24 +9,28 @@ Use this repo's CLI when the task is uploading files to the configured S3-compat
9
9
 
10
10
  ## Required env
11
11
 
12
- - `S3_ENDPOINT`
13
- - `S3_REGION`
14
- - `S3_ACCESS_KEY_ID`
15
- - `S3_SECRET_ACCESS_KEY`
16
- - `S3_BUCKET`
12
+ - `S3_STORAGE_CLI_ENDPOINT`
13
+ - `S3_STORAGE_CLI_REGION`
14
+ - `S3_STORAGE_CLI_ACCESS_KEY_ID`
15
+ - `S3_STORAGE_CLI_SECRET_ACCESS_KEY`
16
+ - `S3_STORAGE_CLI_BUCKET`
17
17
 
18
- `S3_PUBLIC_BASE_URL` is required for `status` to report fully ready and for public object sharing.
18
+ `S3_STORAGE_CLI_PUBLIC_BASE_URL` is required for `status` to report fully ready and for public object sharing.
19
19
 
20
20
  Optional env:
21
21
 
22
- - `S3_CLI_DB_PATH`
23
- - `S3_SHARE_TTL_SECONDS`
24
- - `S3_SESSION_TOKEN`
25
- - `S3_VIRTUAL_HOSTED_STYLE`
22
+ - `S3_STORAGE_CLI_DB_PATH`
23
+ - `S3_STORAGE_CLI_SHARE_TTL_SECONDS`
24
+ - `S3_STORAGE_CLI_SESSION_TOKEN`
25
+ - `S3_STORAGE_CLI_VIRTUAL_HOSTED_STYLE`
26
+ - `S3_STORAGE_CLI_ENV_PATH`
27
+
28
+ The preferred flow is `s3-storage setup`, which prompts for the required scoped values and writes them to the CLI-owned env file.
26
29
 
27
30
  ## Commands
28
31
 
29
32
  - Install first with `npm install -g s3-storage-cli`
33
+ - `s3-storage setup`
30
34
  - `s3-storage status`
31
35
  - `s3-storage list [prefix]`
32
36
  - `s3-storage upload <paths...> [--public|--private] [--prefix <remote-prefix>]`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3-storage-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Minimal tracked S3 CLI for Bun with list, upload, delete, share, and status commands.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -1,15 +1,21 @@
1
- import { dirname } from "node:path";
2
1
  import { mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { stdin as input, stdout as output } from "node:process";
3
5
  import { Catalog } from "./catalog";
4
6
  import {
5
7
  ensurePublicBaseUrl,
6
8
  getReadyEnvKeys,
7
9
  inspectEnv,
10
+ loadScopedEnv,
11
+ pickScopedEnv,
8
12
  requireCoreConfig,
9
13
  resolveCatalogPath,
14
+ resolveConfigEnvPath,
10
15
  type AppConfig,
11
16
  type EnvMap,
12
17
  type Visibility,
18
+ writeScopedEnvFile,
13
19
  } from "./config";
14
20
  import { resolveUploadTargets } from "./files";
15
21
  import { createDefaultIo, emitEnvCheck, emitError, emitJson, formatTimestamp, type CliIo } from "./output";
@@ -21,13 +27,20 @@ interface ParsedArgs {
21
27
  positionals: string[];
22
28
  }
23
29
 
30
+ interface PromptLike {
31
+ ask: (question: string, defaultValue?: string) => Promise<string>;
32
+ close?: () => void | Promise<void>;
33
+ }
34
+
24
35
  interface RunCliOptions {
25
36
  catalogPath?: string;
26
37
  cwd?: string;
27
38
  env?: EnvMap;
39
+ envFilePath?: string;
28
40
  homeDir?: string;
29
41
  io?: CliIo;
30
42
  now?: () => Date;
43
+ prompter?: PromptLike;
31
44
  storageFactory?: (config: AppConfig) => StorageClient;
32
45
  }
33
46
 
@@ -37,9 +50,22 @@ interface StatusCheckResult {
37
50
  ok: boolean;
38
51
  }
39
52
 
53
+ const SETUP_QUESTIONS: Array<{ key: string; label: string }> = [
54
+ { key: "S3_STORAGE_CLI_ENDPOINT", label: "S3 endpoint URL" },
55
+ { key: "S3_STORAGE_CLI_REGION", label: "S3 region" },
56
+ { key: "S3_STORAGE_CLI_ACCESS_KEY_ID", label: "Access key ID" },
57
+ { key: "S3_STORAGE_CLI_SECRET_ACCESS_KEY", label: "Secret access key" },
58
+ { key: "S3_STORAGE_CLI_BUCKET", label: "Bucket name" },
59
+ { key: "S3_STORAGE_CLI_PUBLIC_BASE_URL", label: "Public base URL" },
60
+ ] as const;
61
+
40
62
  export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> {
41
63
  const io = options.io ?? createDefaultIo();
42
- const env = options.env ?? process.env;
64
+ const processEnv = options.env ?? process.env;
65
+ const env = await loadScopedEnv(processEnv, {
66
+ envFilePath: options.envFilePath,
67
+ homeDir: options.homeDir,
68
+ });
43
69
  const cwd = options.cwd ?? process.cwd();
44
70
  const now = options.now ?? (() => new Date());
45
71
  const storageFactory = options.storageFactory ?? ((config: AppConfig) => new BunStorageClient(config));
@@ -52,15 +78,30 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi
52
78
  }
53
79
 
54
80
  switch (parsed.command) {
81
+ case "setup":
82
+ return await runSetup(parsed, {
83
+ env,
84
+ envFilePath: options.envFilePath,
85
+ homeDir: options.homeDir,
86
+ io,
87
+ prompter: options.prompter,
88
+ });
55
89
  case "list":
56
90
  case "ls":
57
- return await runList(parsed, { catalogPath: options.catalogPath, env, homeDir: options.homeDir, io });
91
+ return await runList(parsed, {
92
+ catalogPath: options.catalogPath,
93
+ env,
94
+ envFilePath: options.envFilePath,
95
+ homeDir: options.homeDir,
96
+ io,
97
+ });
58
98
  case "upload":
59
99
  case "up":
60
100
  return await runUpload(parsed, {
61
101
  catalogPath: options.catalogPath,
62
102
  cwd,
63
103
  env,
104
+ envFilePath: options.envFilePath,
64
105
  homeDir: options.homeDir,
65
106
  io,
66
107
  now,
@@ -71,6 +112,7 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi
71
112
  return await runDelete(parsed, {
72
113
  catalogPath: options.catalogPath,
73
114
  env,
115
+ envFilePath: options.envFilePath,
74
116
  homeDir: options.homeDir,
75
117
  io,
76
118
  now,
@@ -81,6 +123,7 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi
81
123
  return await runShare(parsed, {
82
124
  catalogPath: options.catalogPath,
83
125
  env,
126
+ envFilePath: options.envFilePath,
84
127
  homeDir: options.homeDir,
85
128
  io,
86
129
  storageFactory,
@@ -90,6 +133,7 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi
90
133
  return await runStatus(parsed, {
91
134
  catalogPath: options.catalogPath,
92
135
  env,
136
+ envFilePath: options.envFilePath,
93
137
  homeDir: options.homeDir,
94
138
  io,
95
139
  storageFactory,
@@ -103,9 +147,54 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi
103
147
  }
104
148
  }
105
149
 
150
+ async function runSetup(
151
+ parsed: ParsedArgs,
152
+ options: {
153
+ env: EnvMap;
154
+ envFilePath?: string;
155
+ homeDir?: string;
156
+ io: CliIo;
157
+ prompter?: PromptLike;
158
+ },
159
+ ): Promise<number> {
160
+ if (parsed.positionals.length > 0) {
161
+ throw new Error("setup does not accept positional arguments");
162
+ }
163
+
164
+ const envFilePath = options.envFilePath ?? resolveConfigEnvPath(options.env, options.homeDir);
165
+ await mkdir(dirname(envFilePath), { recursive: true });
166
+
167
+ const prompter = options.prompter ?? createPrompter();
168
+ const nextEnv = {
169
+ ...pickScopedEnv(options.env),
170
+ };
171
+
172
+ try {
173
+ for (const question of SETUP_QUESTIONS) {
174
+ const answer = await promptRequiredValue(prompter, question.label, nextEnv[question.key]);
175
+ nextEnv[question.key] = answer;
176
+ }
177
+ } finally {
178
+ await prompter.close?.();
179
+ }
180
+
181
+ await writeScopedEnvFile(envFilePath, nextEnv);
182
+
183
+ if (parsed.flags.json) {
184
+ emitJson(options.io, {
185
+ envFilePath,
186
+ savedKeys: SETUP_QUESTIONS.map((question) => question.key),
187
+ });
188
+ return 0;
189
+ }
190
+
191
+ options.io.stdout(`saved\t${envFilePath}`);
192
+ return 0;
193
+ }
194
+
106
195
  async function runList(
107
196
  parsed: ParsedArgs,
108
- options: { catalogPath?: string; env: EnvMap; homeDir?: string; io: CliIo },
197
+ options: { catalogPath?: string; env: EnvMap; envFilePath?: string; homeDir?: string; io: CliIo },
109
198
  ): Promise<number> {
110
199
  if (parsed.positionals.length > 1) {
111
200
  throw new Error("list accepts at most one prefix");
@@ -113,6 +202,7 @@ async function runList(
113
202
 
114
203
  const config = requireCoreConfig(options.env, {
115
204
  catalogPath: options.catalogPath,
205
+ envFilePath: options.envFilePath,
116
206
  homeDir: options.homeDir,
117
207
  });
118
208
 
@@ -141,6 +231,7 @@ async function runUpload(
141
231
  catalogPath?: string;
142
232
  cwd: string;
143
233
  env: EnvMap;
234
+ envFilePath?: string;
144
235
  homeDir?: string;
145
236
  io: CliIo;
146
237
  now: () => Date;
@@ -151,6 +242,7 @@ async function runUpload(
151
242
  const prefix = readOptionalStringFlag(parsed.flags, "prefix");
152
243
  const config = requireCoreConfig(options.env, {
153
244
  catalogPath: options.catalogPath,
245
+ envFilePath: options.envFilePath,
154
246
  homeDir: options.homeDir,
155
247
  });
156
248
 
@@ -208,6 +300,7 @@ async function runDelete(
208
300
  options: {
209
301
  catalogPath?: string;
210
302
  env: EnvMap;
303
+ envFilePath?: string;
211
304
  homeDir?: string;
212
305
  io: CliIo;
213
306
  now: () => Date;
@@ -220,6 +313,7 @@ async function runDelete(
220
313
 
221
314
  const config = requireCoreConfig(options.env, {
222
315
  catalogPath: options.catalogPath,
316
+ envFilePath: options.envFilePath,
223
317
  homeDir: options.homeDir,
224
318
  });
225
319
  const storage = options.storageFactory(config);
@@ -271,6 +365,7 @@ async function runShare(
271
365
  options: {
272
366
  catalogPath?: string;
273
367
  env: EnvMap;
368
+ envFilePath?: string;
274
369
  homeDir?: string;
275
370
  io: CliIo;
276
371
  storageFactory: (config: AppConfig) => StorageClient;
@@ -282,6 +377,7 @@ async function runShare(
282
377
 
283
378
  const config = requireCoreConfig(options.env, {
284
379
  catalogPath: options.catalogPath,
380
+ envFilePath: options.envFilePath,
285
381
  homeDir: options.homeDir,
286
382
  });
287
383
  const catalog = new Catalog(config.catalogPath);
@@ -322,6 +418,7 @@ async function runStatus(
322
418
  options: {
323
419
  catalogPath?: string;
324
420
  env: EnvMap;
421
+ envFilePath?: string;
325
422
  homeDir?: string;
326
423
  io: CliIo;
327
424
  storageFactory: (config: AppConfig) => StorageClient;
@@ -369,6 +466,7 @@ async function runStatus(
369
466
  try {
370
467
  config = requireCoreConfig(options.env, {
371
468
  catalogPath: options.catalogPath,
469
+ envFilePath: options.envFilePath,
372
470
  homeDir: options.homeDir,
373
471
  });
374
472
  } catch (error) {
@@ -505,8 +603,30 @@ function readOptionalPositiveIntegerFlag(
505
603
  return parsed;
506
604
  }
507
605
 
606
+ async function promptRequiredValue(prompt: PromptLike, label: string, defaultValue?: string): Promise<string> {
607
+ while (true) {
608
+ const answer = (await prompt.ask(label, defaultValue)).trim();
609
+ if (answer) {
610
+ return answer;
611
+ }
612
+ }
613
+ }
614
+
615
+ function createPrompter(): PromptLike {
616
+ const readline = createInterface({ input, output });
617
+ return {
618
+ ask: async (question: string, defaultValue?: string) => {
619
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
620
+ const answer = await readline.question(`${question}${suffix}: `);
621
+ return answer.trim() || defaultValue || "";
622
+ },
623
+ close: () => readline.close(),
624
+ };
625
+ }
626
+
508
627
  function printHelp(io: CliIo): void {
509
628
  io.stdout("s3-storage-cli");
629
+ io.stdout("setup [--json]");
510
630
  io.stdout("status [--json]");
511
631
  io.stdout("list|ls [prefix] [--json]");
512
632
  io.stdout("upload|up <paths...> [--public|--private] [--prefix <remote-prefix>] [--json]");
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
 
@@ -10,6 +11,7 @@ export interface AppConfig {
10
11
  bucket: string;
11
12
  catalogPath: string;
12
13
  endpoint: string;
14
+ envFilePath: string;
13
15
  publicBaseUrl?: string;
14
16
  region: string;
15
17
  secretAccessKey: string;
@@ -26,19 +28,33 @@ export interface EnvCheck {
26
28
  }
27
29
 
28
30
  const CORE_ENV_KEYS = [
29
- "S3_ENDPOINT",
30
- "S3_REGION",
31
- "S3_ACCESS_KEY_ID",
32
- "S3_SECRET_ACCESS_KEY",
33
- "S3_BUCKET",
31
+ "S3_STORAGE_CLI_ENDPOINT",
32
+ "S3_STORAGE_CLI_REGION",
33
+ "S3_STORAGE_CLI_ACCESS_KEY_ID",
34
+ "S3_STORAGE_CLI_SECRET_ACCESS_KEY",
35
+ "S3_STORAGE_CLI_BUCKET",
34
36
  ] as const;
35
37
 
36
- const READY_ENV_KEYS = [...CORE_ENV_KEYS, "S3_PUBLIC_BASE_URL"] as const;
38
+ const READY_ENV_KEYS = [...CORE_ENV_KEYS, "S3_STORAGE_CLI_PUBLIC_BASE_URL"] as const;
39
+
40
+ const OPTIONAL_SCOPED_ENV_KEYS = [
41
+ "S3_STORAGE_CLI_SESSION_TOKEN",
42
+ "S3_STORAGE_CLI_SHARE_TTL_SECONDS",
43
+ "S3_STORAGE_CLI_VIRTUAL_HOSTED_STYLE",
44
+ "S3_STORAGE_CLI_DB_PATH",
45
+ "S3_STORAGE_CLI_ENV_PATH",
46
+ ] as const;
47
+
48
+ const ALL_SCOPED_ENV_KEYS = [...READY_ENV_KEYS, ...OPTIONAL_SCOPED_ENV_KEYS] as const;
37
49
 
38
50
  export function getReadyEnvKeys(): readonly string[] {
39
51
  return READY_ENV_KEYS;
40
52
  }
41
53
 
54
+ export function getScopedEnvKeys(): readonly string[] {
55
+ return ALL_SCOPED_ENV_KEYS;
56
+ }
57
+
42
58
  export function inspectEnv(env: EnvMap): EnvCheck[] {
43
59
  return READY_ENV_KEYS.map((key) => {
44
60
  const value = env[key];
@@ -57,7 +73,7 @@ export function getMissingCoreEnvKeys(env: EnvMap): string[] {
57
73
 
58
74
  export function requireCoreConfig(
59
75
  env: EnvMap,
60
- options: { homeDir?: string; catalogPath?: string } = {},
76
+ options: { homeDir?: string; catalogPath?: string; envFilePath?: string } = {},
61
77
  ): AppConfig {
62
78
  const missing = getMissingCoreEnvKeys(env);
63
79
  if (missing.length > 0) {
@@ -65,21 +81,26 @@ export function requireCoreConfig(
65
81
  }
66
82
 
67
83
  return {
68
- accessKeyId: env.S3_ACCESS_KEY_ID!.trim(),
69
- bucket: env.S3_BUCKET!.trim(),
84
+ accessKeyId: env.S3_STORAGE_CLI_ACCESS_KEY_ID!.trim(),
85
+ bucket: env.S3_STORAGE_CLI_BUCKET!.trim(),
70
86
  catalogPath: options.catalogPath ?? resolveCatalogPath(env, options.homeDir),
71
- endpoint: env.S3_ENDPOINT!.trim(),
72
- publicBaseUrl: env.S3_PUBLIC_BASE_URL?.trim() || undefined,
73
- region: env.S3_REGION!.trim(),
74
- secretAccessKey: env.S3_SECRET_ACCESS_KEY!.trim(),
75
- sessionToken: env.S3_SESSION_TOKEN?.trim() || undefined,
76
- shareTtlSeconds: parsePositiveInteger(env.S3_SHARE_TTL_SECONDS, 3600, "S3_SHARE_TTL_SECONDS"),
77
- virtualHostedStyle: parseBoolean(env.S3_VIRTUAL_HOSTED_STYLE, false),
87
+ endpoint: env.S3_STORAGE_CLI_ENDPOINT!.trim(),
88
+ envFilePath: options.envFilePath ?? resolveConfigEnvPath(env, options.homeDir),
89
+ publicBaseUrl: env.S3_STORAGE_CLI_PUBLIC_BASE_URL?.trim() || undefined,
90
+ region: env.S3_STORAGE_CLI_REGION!.trim(),
91
+ secretAccessKey: env.S3_STORAGE_CLI_SECRET_ACCESS_KEY!.trim(),
92
+ sessionToken: env.S3_STORAGE_CLI_SESSION_TOKEN?.trim() || undefined,
93
+ shareTtlSeconds: parsePositiveInteger(
94
+ env.S3_STORAGE_CLI_SHARE_TTL_SECONDS,
95
+ 3600,
96
+ "S3_STORAGE_CLI_SHARE_TTL_SECONDS",
97
+ ),
98
+ virtualHostedStyle: parseBoolean(env.S3_STORAGE_CLI_VIRTUAL_HOSTED_STYLE, false),
78
99
  };
79
100
  }
80
101
 
81
102
  export function resolveCatalogPath(env: EnvMap, customHomeDir?: string): string {
82
- const override = env.S3_CLI_DB_PATH?.trim();
103
+ const override = env.S3_STORAGE_CLI_DB_PATH?.trim();
83
104
  if (override) {
84
105
  return override;
85
106
  }
@@ -87,14 +108,115 @@ export function resolveCatalogPath(env: EnvMap, customHomeDir?: string): string
87
108
  return join(customHomeDir ?? homedir(), ".s3-storage-cli", "catalog.sqlite");
88
109
  }
89
110
 
111
+ export function resolveConfigEnvPath(env: EnvMap, customHomeDir?: string): string {
112
+ const override = env.S3_STORAGE_CLI_ENV_PATH?.trim();
113
+ if (override) {
114
+ return override;
115
+ }
116
+
117
+ return join(customHomeDir ?? homedir(), ".s3-storage-cli", "config.env");
118
+ }
119
+
120
+ export async function loadScopedEnv(
121
+ processEnv: EnvMap,
122
+ options: { envFilePath?: string; homeDir?: string } = {},
123
+ ): Promise<EnvMap> {
124
+ const envFilePath = options.envFilePath ?? resolveConfigEnvPath(processEnv, options.homeDir);
125
+ const fileEnv = await readScopedEnvFile(envFilePath);
126
+
127
+ return {
128
+ ...fileEnv,
129
+ ...processEnv,
130
+ };
131
+ }
132
+
133
+ export async function readScopedEnvFile(envFilePath: string): Promise<EnvMap> {
134
+ try {
135
+ const content = await readFile(envFilePath, "utf8");
136
+ return parseEnvFile(content);
137
+ } catch (error) {
138
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
139
+ return {};
140
+ }
141
+
142
+ throw error;
143
+ }
144
+ }
145
+
146
+ export async function writeScopedEnvFile(envFilePath: string, values: EnvMap): Promise<void> {
147
+ const lines = [
148
+ "# s3-storage-cli scoped configuration",
149
+ ...ALL_SCOPED_ENV_KEYS.flatMap((key) => {
150
+ const value = values[key];
151
+ if (!value?.trim()) {
152
+ return [];
153
+ }
154
+
155
+ return [`${key}=${JSON.stringify(value.trim())}`];
156
+ }),
157
+ "",
158
+ ];
159
+
160
+ await writeFile(envFilePath, lines.join("\n"), "utf8");
161
+ }
162
+
163
+ export function pickScopedEnv(env: EnvMap): EnvMap {
164
+ const result: EnvMap = {};
165
+ for (const key of ALL_SCOPED_ENV_KEYS) {
166
+ const value = env[key];
167
+ if (value !== undefined) {
168
+ result[key] = value;
169
+ }
170
+ }
171
+ return result;
172
+ }
173
+
90
174
  export function ensurePublicBaseUrl(config: AppConfig): string {
91
175
  if (!config.publicBaseUrl) {
92
- throw new Error("missing env S3_PUBLIC_BASE_URL");
176
+ throw new Error("missing env S3_STORAGE_CLI_PUBLIC_BASE_URL");
93
177
  }
94
178
 
95
179
  return config.publicBaseUrl;
96
180
  }
97
181
 
182
+ function parseEnvFile(content: string): EnvMap {
183
+ const env: EnvMap = {};
184
+
185
+ for (const rawLine of content.split(/\r?\n/)) {
186
+ const line = rawLine.trim();
187
+ if (!line || line.startsWith("#")) {
188
+ continue;
189
+ }
190
+
191
+ const separatorIndex = line.indexOf("=");
192
+ if (separatorIndex <= 0) {
193
+ continue;
194
+ }
195
+
196
+ const key = line.slice(0, separatorIndex).trim();
197
+ const rawValue = line.slice(separatorIndex + 1).trim();
198
+ if (!key) {
199
+ continue;
200
+ }
201
+
202
+ env[key] = parseEnvValue(rawValue);
203
+ }
204
+
205
+ return env;
206
+ }
207
+
208
+ function parseEnvValue(value: string): string {
209
+ if (value.startsWith("\"") && value.endsWith("\"")) {
210
+ try {
211
+ return JSON.parse(value) as string;
212
+ } catch {
213
+ return value.slice(1, -1);
214
+ }
215
+ }
216
+
217
+ return value;
218
+ }
219
+
98
220
  function parsePositiveInteger(raw: string | undefined, fallback: number, key: string): number {
99
221
  if (!raw?.trim()) {
100
222
  return fallback;