@freecodecamp/universe-cli 0.1.1 → 0.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.
Files changed (3) hide show
  1. package/dist/index.cjs +35783 -40235
  2. package/dist/index.js +249 -324
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,172 +1,90 @@
1
1
  #!/usr/bin/env node
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropNames = Object.getOwnPropertyNames;
4
- var __esm = (fn, res) => function __init() {
5
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
- };
7
- var __export = (target, all) => {
8
- for (var name in all)
9
- __defProp(target, name, { get: all[name], enumerable: true });
10
- };
11
-
12
- // src/output/envelope.ts
13
- function buildEnvelope(command, success, data) {
14
- return {
15
- schemaVersion: "1",
16
- command,
17
- success,
18
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
19
- ...data
20
- };
21
- }
22
- function buildErrorEnvelope(command, code, message, issues) {
23
- const error = {
24
- code,
25
- message
26
- };
27
- if (issues !== void 0) {
28
- error.issues = issues;
29
- }
30
- return {
31
- schemaVersion: "1",
32
- command,
33
- success: false,
34
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
35
- error
36
- };
37
- }
38
- var init_envelope = __esm({
39
- "src/output/envelope.ts"() {
40
- "use strict";
41
- }
42
- });
43
-
44
- // src/output/redact.ts
45
- function maskAkia(match) {
46
- return match.slice(0, 4) + "****" + match.slice(-4);
47
- }
48
- function maskUrlCreds(match) {
49
- const atIndex = match.lastIndexOf("@");
50
- const protocolEnd = match.indexOf("://") + 3;
51
- return match.slice(0, protocolEnd) + "****:****@" + match.slice(atIndex + 1);
52
- }
53
- function redact(value) {
54
- let result = value;
55
- result = result.replace(URL_CREDS_PATTERN, maskUrlCreds);
56
- result = result.replace(AKIA_PATTERN, maskAkia);
57
- result = result.replace(
58
- CREDENTIAL_CONTEXT_PATTERN,
59
- (_match, _secret, _offset, _full) => {
60
- const eqIndex = _match.indexOf("=");
61
- const colonIndex = _match.indexOf(":");
62
- const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
63
- const prefix = _match.slice(0, sepIndex + 1);
64
- return prefix + "****";
65
- }
66
- );
67
- result = result.replace(HEX_CREDENTIAL_CONTEXT_PATTERN, (_match, _secret) => {
68
- const eqIndex = _match.indexOf("=");
69
- const colonIndex = _match.indexOf(":");
70
- const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
71
- const prefix = _match.slice(0, sepIndex + 1);
72
- return prefix + "****";
73
- });
74
- return result;
75
- }
76
- var AKIA_PATTERN, URL_CREDS_PATTERN, CREDENTIAL_CONTEXT_PATTERN, HEX_CREDENTIAL_CONTEXT_PATTERN;
77
- var init_redact = __esm({
78
- "src/output/redact.ts"() {
79
- "use strict";
80
- AKIA_PATTERN = /AKIA[A-Z0-9]{12,}/g;
81
- URL_CREDS_PATTERN = /https?:\/\/[^@\s]+@/g;
82
- CREDENTIAL_CONTEXT_PATTERN = /(?:secret|password|token|key|credential|auth)[=:]\s*([A-Za-z0-9/+=]{21,})/gi;
83
- HEX_CREDENTIAL_CONTEXT_PATTERN = /(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key)[=:]\s*([a-f0-9]{32,})/gi;
84
- }
85
- });
86
2
 
87
- // src/output/format.ts
88
- import { log } from "@clack/prompts";
89
- function outputSuccess(ctx, humanMessage, data) {
90
- if (ctx.json) {
91
- const envelope = buildEnvelope(ctx.command, true, data);
92
- process.stdout.write(JSON.stringify(envelope) + "\n");
93
- } else {
94
- log.success(humanMessage);
95
- }
96
- }
97
- function outputError(ctx, code, message, issues) {
98
- const redactedMessage = redact(message);
99
- const redactedIssues = issues?.map(redact);
100
- if (ctx.json) {
101
- const envelope = buildErrorEnvelope(
102
- ctx.command,
103
- code,
104
- redactedMessage,
105
- redactedIssues
106
- );
107
- process.stdout.write(JSON.stringify(envelope) + "\n");
108
- } else {
109
- log.error(redactedMessage);
110
- }
111
- }
112
- var init_format = __esm({
113
- "src/output/format.ts"() {
114
- "use strict";
115
- init_envelope();
116
- init_redact();
117
- }
118
- });
3
+ // src/cli.ts
4
+ import { cac } from "cac";
119
5
 
120
6
  // src/output/exit-codes.ts
7
+ var EXIT_USAGE = 10;
8
+ var EXIT_CONFIG = 11;
9
+ var EXIT_CREDENTIAL = 12;
10
+ var EXIT_STORAGE = 13;
11
+ var EXIT_OUTPUT_DIR = 14;
12
+ var EXIT_GIT = 15;
13
+ var EXIT_ALIAS = 16;
14
+ var EXIT_DEPLOY_NOT_FOUND = 17;
15
+ var EXIT_CONFIRM = 18;
16
+ var EXIT_PARTIAL = 19;
121
17
  function exitWithCode(code, message) {
122
18
  if (message !== void 0) {
123
19
  process.stderr.write(message + "\n");
124
20
  }
125
21
  process.exit(code);
126
22
  }
127
- var EXIT_USAGE, EXIT_OUTPUT_DIR, EXIT_GIT, EXIT_ALIAS, EXIT_DEPLOY_NOT_FOUND, EXIT_CONFIRM, EXIT_PARTIAL;
128
- var init_exit_codes = __esm({
129
- "src/output/exit-codes.ts"() {
130
- "use strict";
131
- EXIT_USAGE = 10;
132
- EXIT_OUTPUT_DIR = 14;
133
- EXIT_GIT = 15;
134
- EXIT_ALIAS = 16;
135
- EXIT_DEPLOY_NOT_FOUND = 17;
136
- EXIT_CONFIRM = 18;
137
- EXIT_PARTIAL = 19;
23
+
24
+ // src/errors.ts
25
+ var CliError = class extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = new.target.name;
138
29
  }
139
- });
30
+ };
31
+ var ConfigError = class extends CliError {
32
+ exitCode = EXIT_CONFIG;
33
+ };
34
+ var CredentialError = class extends CliError {
35
+ exitCode = EXIT_CREDENTIAL;
36
+ };
37
+ var StorageError = class extends CliError {
38
+ exitCode = EXIT_STORAGE;
39
+ };
40
+ var GitError = class extends CliError {
41
+ exitCode = EXIT_GIT;
42
+ };
43
+
44
+ // src/config/loader.ts
45
+ import { readFileSync } from "fs";
46
+ import { isAbsolute, relative, resolve, sep } from "path";
47
+ import { parse as parseYaml } from "yaml";
140
48
 
141
49
  // src/config/schema.ts
142
50
  import { z } from "zod";
143
- var staticSchema, domainSchema, platformSchema;
144
- var init_schema = __esm({
145
- "src/config/schema.ts"() {
146
- "use strict";
147
- staticSchema = z.object({
148
- output_dir: z.string().min(1).default("dist"),
149
- bucket: z.string().min(1).default("gxy-static-1"),
150
- rclone_remote: z.string().min(1).default("gxy-static"),
151
- region: z.string().min(1).default("auto")
152
- }).default({});
153
- domainSchema = z.object({
154
- production: z.string().min(1),
155
- preview: z.string().min(1)
156
- });
157
- platformSchema = z.object({
158
- name: z.string().min(1).regex(/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/i),
159
- stack: z.literal("static"),
160
- domain: domainSchema,
161
- static: staticSchema
162
- });
163
- }
51
+ var staticSchema = z.object({
52
+ output_dir: z.string().min(1).default("dist"),
53
+ bucket: z.string().min(1).default("gxy-static-1"),
54
+ rclone_remote: z.string().min(1).default("gxy-static"),
55
+ region: z.string().min(1).default("auto")
56
+ }).default({});
57
+ var domainSchema = z.object({
58
+ production: z.string().min(1),
59
+ preview: z.string().min(1)
60
+ });
61
+ var platformSchema = z.object({
62
+ name: z.string().min(1).regex(/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/i),
63
+ stack: z.literal("static"),
64
+ domain: domainSchema,
65
+ static: staticSchema
164
66
  });
165
67
 
166
68
  // src/config/loader.ts
167
- import { readFileSync } from "fs";
168
- import { resolve } from "path";
169
- import { parse as parseYaml } from "yaml";
69
+ function assertSafeOutputDir(outputDir, cwd) {
70
+ if (isAbsolute(outputDir)) {
71
+ throw new ConfigError(
72
+ `output_dir must be relative to the project root; absolute paths are rejected: ${outputDir}`
73
+ );
74
+ }
75
+ const resolved = resolve(cwd, outputDir);
76
+ const rel = relative(cwd, resolved);
77
+ if (rel === "..") {
78
+ throw new ConfigError(
79
+ `output_dir resolves outside the project root: ${outputDir}`
80
+ );
81
+ }
82
+ if (rel.startsWith(`..${sep}`)) {
83
+ throw new ConfigError(
84
+ `output_dir resolves outside the project root: ${outputDir}`
85
+ );
86
+ }
87
+ }
170
88
  function readEnvOverrides() {
171
89
  const overrides = {};
172
90
  if (process.env.UNIVERSE_STATIC_OUTPUT_DIR) {
@@ -200,7 +118,7 @@ function loadConfig(options = {}) {
200
118
  raw = readFileSync(configPath, "utf-8");
201
119
  } catch (err) {
202
120
  if (err instanceof Error && err.code === "ENOENT") {
203
- throw new Error(
121
+ throw new ConfigError(
204
122
  `platform.yaml not found at ${configPath}. See STAFF-GUIDE.md for the required format.`
205
123
  );
206
124
  }
@@ -218,17 +136,51 @@ function loadConfig(options = {}) {
218
136
  ...flagOverrides
219
137
  }
220
138
  };
139
+ assertSafeOutputDir(merged.static.output_dir, cwd);
221
140
  return merged;
222
141
  }
223
- var init_loader = __esm({
224
- "src/config/loader.ts"() {
225
- "use strict";
226
- init_schema();
227
- }
228
- });
229
142
 
230
143
  // src/credentials/resolver.ts
231
144
  import { execSync } from "child_process";
145
+
146
+ // src/output/redact.ts
147
+ var AKIA_PATTERN = /AKIA[A-Z0-9]{12,}/g;
148
+ var URL_CREDS_PATTERN = /https?:\/\/[^@\s]+@/g;
149
+ var CREDENTIAL_CONTEXT_PATTERN = /(?:secret|password|token|key|credential|auth)[=:]\s*([A-Za-z0-9/+=]{21,})/gi;
150
+ var HEX_CREDENTIAL_CONTEXT_PATTERN = /(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key)[=:]\s*([a-f0-9]{32,})/gi;
151
+ function maskAkia(match) {
152
+ return match.slice(0, 4) + "****" + match.slice(-4);
153
+ }
154
+ function maskUrlCreds(match) {
155
+ const atIndex = match.lastIndexOf("@");
156
+ const protocolEnd = match.indexOf("://") + 3;
157
+ return match.slice(0, protocolEnd) + "****:****@" + match.slice(atIndex + 1);
158
+ }
159
+ function redact(value) {
160
+ let result = value;
161
+ result = result.replace(URL_CREDS_PATTERN, maskUrlCreds);
162
+ result = result.replace(AKIA_PATTERN, maskAkia);
163
+ result = result.replace(
164
+ CREDENTIAL_CONTEXT_PATTERN,
165
+ (_match, _secret, _offset, _full) => {
166
+ const eqIndex = _match.indexOf("=");
167
+ const colonIndex = _match.indexOf(":");
168
+ const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
169
+ const prefix = _match.slice(0, sepIndex + 1);
170
+ return prefix + "****";
171
+ }
172
+ );
173
+ result = result.replace(HEX_CREDENTIAL_CONTEXT_PATTERN, (_match, _secret) => {
174
+ const eqIndex = _match.indexOf("=");
175
+ const colonIndex = _match.indexOf(":");
176
+ const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
177
+ const prefix = _match.slice(0, sepIndex + 1);
178
+ return prefix + "****";
179
+ });
180
+ return result;
181
+ }
182
+
183
+ // src/credentials/resolver.ts
232
184
  function tryEnvCredentials() {
233
185
  const key = process.env.S3_ACCESS_KEY_ID;
234
186
  const secret = process.env.S3_SECRET_ACCESS_KEY;
@@ -251,11 +203,11 @@ function fromRclone(remoteName) {
251
203
  output = execSync("rclone config dump", { stdio: "pipe" });
252
204
  } catch (err) {
253
205
  if (err instanceof Error && err.code === "ENOENT") {
254
- throw new Error(
206
+ throw new CredentialError(
255
207
  "rclone not found \u2014 install rclone or provide S3 credentials via environment variables"
256
208
  );
257
209
  }
258
- throw new Error(
210
+ throw new CredentialError(
259
211
  `Failed to run rclone config dump: ${redact(err instanceof Error ? err.message : "unknown error")}`
260
212
  );
261
213
  }
@@ -263,12 +215,14 @@ function fromRclone(remoteName) {
263
215
  try {
264
216
  parsed = JSON.parse(output.toString("utf-8"));
265
217
  } catch {
266
- throw new Error(`Failed to parse rclone config for remote ${remoteName}`);
218
+ throw new CredentialError(
219
+ `Failed to parse rclone config for remote ${remoteName}`
220
+ );
267
221
  }
268
222
  output = null;
269
223
  const remote = parsed[remoteName];
270
224
  if (!remote) {
271
- throw new Error(
225
+ throw new CredentialError(
272
226
  `Remote "${remoteName}" not found in rclone config. Available remotes: ${Object.keys(parsed).join(", ") || "(none)"}`
273
227
  );
274
228
  }
@@ -277,7 +231,7 @@ function fromRclone(remoteName) {
277
231
  if (!remote.secret_access_key) missing.push("secret_access_key");
278
232
  if (!remote.endpoint) missing.push("endpoint");
279
233
  if (missing.length > 0) {
280
- throw new Error(
234
+ throw new CredentialError(
281
235
  `Rclone remote "${remoteName}" is missing required fields: ${missing.join(", ")}`
282
236
  );
283
237
  }
@@ -292,7 +246,7 @@ function fromRclone(remoteName) {
292
246
  function resolveCredentials(options) {
293
247
  const envResult = tryEnvCredentials();
294
248
  if (envResult === "partial") {
295
- throw new Error(
249
+ throw new CredentialError(
296
250
  "Partial S3 credentials in environment. Set all three: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_ENDPOINT \u2014 or remove all to use rclone fallback."
297
251
  );
298
252
  }
@@ -301,12 +255,6 @@ function resolveCredentials(options) {
301
255
  }
302
256
  return fromRclone(options.remoteName);
303
257
  }
304
- var init_resolver = __esm({
305
- "src/credentials/resolver.ts"() {
306
- "use strict";
307
- init_redact();
308
- }
309
- });
310
258
 
311
259
  // src/storage/client.ts
312
260
  import { S3Client } from "@aws-sdk/client-s3";
@@ -321,11 +269,6 @@ function createS3Client(credentials) {
321
269
  forcePathStyle: true
322
270
  });
323
271
  }
324
- var init_client = __esm({
325
- "src/storage/client.ts"() {
326
- "use strict";
327
- }
328
- });
329
272
 
330
273
  // src/storage/operations.ts
331
274
  import {
@@ -336,6 +279,8 @@ import {
336
279
  DeleteObjectCommand,
337
280
  DeleteObjectsCommand
338
281
  } from "@aws-sdk/client-s3";
282
+ var MAX_RETRIES = 3;
283
+ var BASE_DELAY_MS = 500;
339
284
  function isTransientError(error) {
340
285
  if (!(error instanceof Error)) return false;
341
286
  const meta = error.$metadata;
@@ -421,14 +366,6 @@ async function listObjects(client, params) {
421
366
  } while (continuationToken);
422
367
  return allItems;
423
368
  }
424
- var MAX_RETRIES, BASE_DELAY_MS;
425
- var init_operations = __esm({
426
- "src/storage/operations.ts"() {
427
- "use strict";
428
- MAX_RETRIES = 3;
429
- BASE_DELAY_MS = 500;
430
- }
431
- });
432
369
 
433
370
  // src/storage/aliases.ts
434
371
  async function readAlias(client, bucket, site, aliasName) {
@@ -446,18 +383,12 @@ async function writeAlias(client, bucket, site, aliasName, deployId) {
446
383
  contentType: "text/plain"
447
384
  });
448
385
  }
449
- var init_aliases = __esm({
450
- "src/storage/aliases.ts"() {
451
- "use strict";
452
- init_operations();
453
- }
454
- });
455
386
 
456
387
  // src/deploy/id.ts
457
388
  function generateDeployId(gitHash, force = false) {
458
389
  if (gitHash === void 0) {
459
390
  if (!force) {
460
- throw new Error("git hash is required unless --force is set");
391
+ throw new GitError("git hash is required unless --force is set");
461
392
  }
462
393
  return formatId("nogit");
463
394
  }
@@ -473,11 +404,6 @@ function formatId(suffix) {
473
404
  const s = String(now.getUTCSeconds()).padStart(2, "0");
474
405
  return `${y}${mo}${d}-${h}${mi}${s}-${suffix}`;
475
406
  }
476
- var init_id = __esm({
477
- "src/deploy/id.ts"() {
478
- "use strict";
479
- }
480
- });
481
407
 
482
408
  // src/deploy/git.ts
483
409
  import { execSync as execSync2 } from "child_process";
@@ -490,50 +416,77 @@ function getGitState() {
490
416
  return { hash: null, dirty: false, error: "not a git repository" };
491
417
  }
492
418
  }
493
- var init_git = __esm({
494
- "src/deploy/git.ts"() {
495
- "use strict";
419
+
420
+ // src/deploy/preflight.ts
421
+ import { existsSync, statSync as statSync2 } from "fs";
422
+
423
+ // src/deploy/walk.ts
424
+ import { readdirSync, realpathSync, statSync } from "fs";
425
+ import { join, relative as relative2, sep as sep2 } from "path";
426
+ function walkFiles(base) {
427
+ const baseReal = realpathSync(base);
428
+ const entries = readdirSync(base, { recursive: true, withFileTypes: true });
429
+ const files = [];
430
+ for (const entry of entries) {
431
+ const full = join(entry.parentPath, entry.name);
432
+ const relPath = relative2(base, full);
433
+ let targetIsFile;
434
+ try {
435
+ targetIsFile = statSync(full).isFile();
436
+ } catch {
437
+ throw new StorageError(
438
+ `"${relPath}" could not be stat'd (dangling symlink?)`
439
+ );
440
+ }
441
+ if (!targetIsFile) continue;
442
+ let resolved;
443
+ try {
444
+ resolved = realpathSync(full);
445
+ } catch {
446
+ throw new StorageError(
447
+ `"${relPath}" could not be resolved (dangling symlink?)`
448
+ );
449
+ }
450
+ const rel = relative2(baseReal, resolved);
451
+ if (rel === ".." || rel.startsWith(`..${sep2}`)) {
452
+ throw new StorageError(
453
+ `"${relPath}" resolves outside the output directory (symlink escape). Resolved to: ${resolved}`
454
+ );
455
+ }
456
+ files.push({ relPath, absPath: full });
496
457
  }
497
- });
458
+ return files;
459
+ }
498
460
 
499
461
  // src/deploy/preflight.ts
500
- import { existsSync, statSync, readdirSync } from "fs";
501
- import { join } from "path";
502
462
  function validateOutputDir(dir) {
503
463
  if (!existsSync(dir)) {
504
464
  return { valid: false, fileCount: 0, error: "directory not found" };
505
465
  }
506
- if (!statSync(dir).isDirectory()) {
466
+ if (!statSync2(dir).isDirectory()) {
507
467
  return { valid: false, fileCount: 0, error: "not a directory" };
508
468
  }
509
- const fileCount = countFiles(dir);
469
+ let fileCount;
470
+ try {
471
+ fileCount = walkFiles(dir).length;
472
+ } catch (err) {
473
+ if (err instanceof StorageError) {
474
+ return { valid: false, fileCount: 0, error: err.message };
475
+ }
476
+ throw err;
477
+ }
510
478
  if (fileCount === 0) {
511
479
  return { valid: false, fileCount: 0, error: "directory is empty" };
512
480
  }
513
481
  return { valid: true, fileCount };
514
482
  }
515
- function countFiles(dir) {
516
- let count = 0;
517
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
518
- if (entry.isFile()) {
519
- count++;
520
- } else if (entry.isDirectory()) {
521
- count += countFiles(join(dir, entry.name));
522
- }
523
- }
524
- return count;
525
- }
526
- var init_preflight = __esm({
527
- "src/deploy/preflight.ts"() {
528
- "use strict";
529
- }
530
- });
531
483
 
532
484
  // src/deploy/upload.ts
533
- import { readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
534
- import { extname, basename, join as join2, relative } from "path";
485
+ import { readFileSync as readFileSync2 } from "fs";
486
+ import { extname, basename } from "path";
535
487
  import { lookup } from "mrmime";
536
488
  import pLimit from "p-limit";
489
+ var FINGERPRINT_RE = /[.-][a-zA-Z0-9_-]{8,}\./;
537
490
  function getContentType(filename) {
538
491
  const mime = lookup(filename);
539
492
  if (mime === void 0) {
@@ -554,25 +507,17 @@ function getCacheControl(filename) {
554
507
  async function uploadDirectory(client, bucket, site, deployId, outputDir, options) {
555
508
  const concurrency = options?.concurrency ?? 10;
556
509
  const limit = pLimit(concurrency);
557
- const entries = readdirSync2(outputDir, {
558
- recursive: true,
559
- withFileTypes: true
560
- });
561
- const files = entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink()).map((entry) => {
562
- const full = join2(entry.parentPath ?? entry.path, entry.name);
563
- return relative(outputDir, full);
564
- });
510
+ const files = walkFiles(outputDir);
565
511
  const errors = [];
566
512
  let totalSize = 0;
567
513
  let fileCount = 0;
568
514
  const tasks = files.map(
569
- (filePath) => limit(async () => {
570
- const fullPath = `${outputDir}/${filePath}`;
571
- const key = `${site}/deploys/${deployId}/${filePath}`;
572
- const contentType = getContentType(filePath);
573
- const cacheControl = getCacheControl(filePath);
515
+ (file) => limit(async () => {
516
+ const key = `${site}/deploys/${deployId}/${file.relPath}`;
517
+ const contentType = getContentType(file.relPath);
518
+ const cacheControl = getCacheControl(file.relPath);
574
519
  try {
575
- const body = readFileSync2(fullPath);
520
+ const body = readFileSync2(file.absPath);
576
521
  totalSize += body.byteLength;
577
522
  fileCount++;
578
523
  await putObject(client, {
@@ -584,21 +529,13 @@ async function uploadDirectory(client, bucket, site, deployId, outputDir, option
584
529
  });
585
530
  } catch (err) {
586
531
  const message = err instanceof Error ? err.message : "unknown upload error";
587
- errors.push(`${filePath}: ${message}`);
532
+ errors.push(`${file.relPath}: ${message}`);
588
533
  }
589
534
  })
590
535
  );
591
536
  await Promise.all(tasks);
592
537
  return { fileCount, totalSize, errors };
593
538
  }
594
- var FINGERPRINT_RE;
595
- var init_upload = __esm({
596
- "src/deploy/upload.ts"() {
597
- "use strict";
598
- init_operations();
599
- FINGERPRINT_RE = /[.-][a-zA-Z0-9_-]{8,}\./;
600
- }
601
- });
602
539
 
603
540
  // src/deploy/metadata.ts
604
541
  async function writeDeployMetadata(client, bucket, site, deployId, meta) {
@@ -611,21 +548,70 @@ async function writeDeployMetadata(client, bucket, site, deployId, meta) {
611
548
  contentType: "application/json"
612
549
  });
613
550
  }
614
- var init_metadata = __esm({
615
- "src/deploy/metadata.ts"() {
616
- "use strict";
617
- init_operations();
551
+
552
+ // src/output/format.ts
553
+ import { log } from "@clack/prompts";
554
+
555
+ // src/output/envelope.ts
556
+ function buildEnvelope(command, success, data) {
557
+ return {
558
+ schemaVersion: "1",
559
+ command,
560
+ success,
561
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
562
+ ...data
563
+ };
564
+ }
565
+ function buildErrorEnvelope(command, code, message, issues) {
566
+ const error = {
567
+ code,
568
+ message
569
+ };
570
+ if (issues !== void 0) {
571
+ error.issues = issues;
618
572
  }
619
- });
573
+ return {
574
+ schemaVersion: "1",
575
+ command,
576
+ success: false,
577
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
578
+ error
579
+ };
580
+ }
581
+
582
+ // src/output/format.ts
583
+ function outputSuccess(ctx, humanMessage, data) {
584
+ if (ctx.json) {
585
+ const envelope = buildEnvelope(ctx.command, true, data);
586
+ process.stdout.write(JSON.stringify(envelope) + "\n");
587
+ } else {
588
+ log.success(humanMessage);
589
+ }
590
+ }
591
+ function outputError(ctx, code, message, issues) {
592
+ const redactedMessage = redact(message);
593
+ const redactedIssues = issues?.map(redact);
594
+ if (ctx.json) {
595
+ const envelope = buildErrorEnvelope(
596
+ ctx.command,
597
+ code,
598
+ redactedMessage,
599
+ redactedIssues
600
+ );
601
+ process.stdout.write(JSON.stringify(envelope) + "\n");
602
+ } else {
603
+ log.error(redactedMessage);
604
+ }
605
+ }
620
606
 
621
607
  // src/commands/deploy.ts
622
- var deploy_exports = {};
623
- __export(deploy_exports, {
624
- deploy: () => deploy
625
- });
608
+ var MAX_COLLISION_RETRIES = 3;
609
+ var COLLISION_DELAY_MS = 1e3;
626
610
  async function resolveDeployId(client, bucket, site, gitHash, force) {
611
+ let lastDeployId = "";
627
612
  for (let attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
628
613
  const deployId = generateDeployId(gitHash, force);
614
+ lastDeployId = deployId;
629
615
  const prefix = `${site}/deploys/${deployId}/`;
630
616
  const existing = await listObjects(client, { bucket, prefix });
631
617
  if (existing.length === 0) {
@@ -635,7 +621,9 @@ async function resolveDeployId(client, bucket, site, gitHash, force) {
635
621
  await new Promise((resolve2) => setTimeout(resolve2, COLLISION_DELAY_MS));
636
622
  }
637
623
  }
638
- return generateDeployId(gitHash, force);
624
+ throw new StorageError(
625
+ `Deploy ID collision could not be resolved after ${MAX_COLLISION_RETRIES} attempts (last attempted: ${lastDeployId}). Commit a new change and retry, or wait for the timestamp to advance.`
626
+ );
639
627
  }
640
628
  async function deploy(options) {
641
629
  const config = loadConfig({
@@ -738,26 +726,6 @@ async function deploy(options) {
738
726
  previewDomain
739
727
  });
740
728
  }
741
- var MAX_COLLISION_RETRIES, COLLISION_DELAY_MS;
742
- var init_deploy = __esm({
743
- "src/commands/deploy.ts"() {
744
- "use strict";
745
- init_loader();
746
- init_resolver();
747
- init_client();
748
- init_aliases();
749
- init_operations();
750
- init_id();
751
- init_git();
752
- init_preflight();
753
- init_upload();
754
- init_metadata();
755
- init_format();
756
- init_exit_codes();
757
- MAX_COLLISION_RETRIES = 3;
758
- COLLISION_DELAY_MS = 1e3;
759
- }
760
- });
761
729
 
762
730
  // src/storage/deploys.ts
763
731
  async function listDeploys(client, bucket, site) {
@@ -778,18 +746,8 @@ async function deployExists(client, bucket, site, deployId) {
778
746
  const items = await listObjects(client, { bucket, prefix });
779
747
  return items.length > 0;
780
748
  }
781
- var init_deploys = __esm({
782
- "src/storage/deploys.ts"() {
783
- "use strict";
784
- init_operations();
785
- }
786
- });
787
749
 
788
750
  // src/commands/promote.ts
789
- var promote_exports = {};
790
- __export(promote_exports, {
791
- promote: () => promote
792
- });
793
751
  async function promote(options) {
794
752
  const config = loadConfig();
795
753
  const credentials = resolveCredentials({
@@ -847,24 +805,8 @@ async function promote(options) {
847
805
  productionDomain
848
806
  });
849
807
  }
850
- var init_promote = __esm({
851
- "src/commands/promote.ts"() {
852
- "use strict";
853
- init_loader();
854
- init_resolver();
855
- init_client();
856
- init_aliases();
857
- init_deploys();
858
- init_format();
859
- init_exit_codes();
860
- }
861
- });
862
808
 
863
809
  // src/commands/rollback.ts
864
- var rollback_exports = {};
865
- __export(rollback_exports, {
866
- rollback: () => rollback
867
- });
868
810
  import { confirm } from "@clack/prompts";
869
811
  async function rollback(options) {
870
812
  const config = loadConfig();
@@ -928,29 +870,15 @@ async function rollback(options) {
928
870
  productionDomain
929
871
  });
930
872
  }
931
- var init_rollback = __esm({
932
- "src/commands/rollback.ts"() {
933
- "use strict";
934
- init_loader();
935
- init_resolver();
936
- init_client();
937
- init_aliases();
938
- init_deploys();
939
- init_format();
940
- init_exit_codes();
941
- }
942
- });
943
873
 
944
874
  // src/cli.ts
945
- init_format();
946
- init_exit_codes();
947
- import { cac } from "cac";
948
875
  var version = true ? "0.1.0" : "0.0.0";
949
876
  function handleActionError(command, json, err) {
950
877
  const ctx = { json, command };
951
878
  const message = err instanceof Error ? err.message : "unknown error";
952
- outputError(ctx, EXIT_USAGE, message);
953
- exitWithCode(EXIT_USAGE, message);
879
+ const code = err instanceof CliError ? err.exitCode : EXIT_USAGE;
880
+ outputError(ctx, code, message);
881
+ exitWithCode(code, message);
954
882
  }
955
883
  function run(argv = process.argv) {
956
884
  const args = argv.slice(2);
@@ -959,8 +887,7 @@ function run(argv = process.argv) {
959
887
  staticCli.command("deploy", "Deploy static site to S3").option("--json", "Output as JSON").option("--force", "Force deploy without git hash").option("--output-dir <dir>", "Build output directory").action(
960
888
  async (flags) => {
961
889
  try {
962
- const { deploy: deploy2 } = await Promise.resolve().then(() => (init_deploy(), deploy_exports));
963
- await deploy2({
890
+ await deploy({
964
891
  json: flags.json ?? false,
965
892
  force: flags.force ?? false,
966
893
  outputDir: flags.outputDir
@@ -973,8 +900,7 @@ function run(argv = process.argv) {
973
900
  staticCli.command("promote [deploy-id]", "Promote a deploy to production").option("--json", "Output as JSON").action(
974
901
  async (deployId, flags) => {
975
902
  try {
976
- const { promote: promote2 } = await Promise.resolve().then(() => (init_promote(), promote_exports));
977
- await promote2({ json: flags.json ?? false, deployId });
903
+ await promote({ json: flags.json ?? false, deployId });
978
904
  } catch (err) {
979
905
  handleActionError("promote", flags.json ?? false, err);
980
906
  }
@@ -982,8 +908,7 @@ function run(argv = process.argv) {
982
908
  );
983
909
  staticCli.command("rollback", "Rollback production to previous deploy").option("--json", "Output as JSON").option("--confirm", "Confirm rollback").action(async (flags) => {
984
910
  try {
985
- const { rollback: rollback2 } = await Promise.resolve().then(() => (init_rollback(), rollback_exports));
986
- await rollback2({
911
+ await rollback({
987
912
  json: flags.json ?? false,
988
913
  confirm: flags.confirm ?? false
989
914
  });