@freecodecamp/universe-cli 0.1.1 → 0.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.
Files changed (3) hide show
  1. package/dist/index.cjs +43866 -38499
  2. package/dist/index.js +295 -326
  3. package/package.json +22 -6
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
+ }).prefault({});
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,14 +118,26 @@ 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
  }
207
125
  throw err;
208
126
  }
209
127
  const parsed = parseYaml(raw);
210
- const yamlValidated = platformSchema.parse(parsed);
128
+ const parseResult = platformSchema.safeParse(parsed);
129
+ if (!parseResult.success) {
130
+ const issues = parseResult.error.issues.map((issue) => {
131
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
132
+ return ` - ${path}: ${issue.message}`;
133
+ }).join("\n");
134
+ throw new ConfigError(
135
+ `platform.yaml is invalid:
136
+ ${issues}
137
+ See STAFF-GUIDE.md for the required format.`
138
+ );
139
+ }
140
+ const yamlValidated = parseResult.data;
211
141
  const envOverrides = readEnvOverrides();
212
142
  const flagOverrides = readFlagOverrides(options.flags);
213
143
  const merged = {
@@ -218,17 +148,82 @@ function loadConfig(options = {}) {
218
148
  ...flagOverrides
219
149
  }
220
150
  };
151
+ assertSafeOutputDir(merged.static.output_dir, cwd);
221
152
  return merged;
222
153
  }
223
- var init_loader = __esm({
224
- "src/config/loader.ts"() {
225
- "use strict";
226
- init_schema();
227
- }
228
- });
229
154
 
230
155
  // src/credentials/resolver.ts
231
156
  import { execSync } from "child_process";
157
+
158
+ // src/output/redact.ts
159
+ var AWS_KEY_PREFIX_PATTERN = /(?:AKIA|ASIA|AROA|AIDA|ACCA|ANPA|ABIA|AGPA)[A-Z0-9]{12,}/g;
160
+ var URL_CREDS_PATTERN = /https?:\/\/[^@\s]+@/g;
161
+ var CREDENTIAL_CONTEXT_PATTERN = /(?:access_key_id|secret_access_key|accessKeyId|secretAccessKey|secret|password|token|key|credential|auth)\s*[=:]\s*([A-Za-z0-9/+=]{21,})/gi;
162
+ var HEX_CREDENTIAL_CONTEXT_PATTERN = /(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key)\s*[=:]\s*([a-f0-9]{32,})/gi;
163
+ var JSON_CREDENTIAL_PATTERN = /"(?:secret|password|token|key|credential|auth|access_key_id|secret_access_key|accessKeyId|secretAccessKey)"\s*:\s*"[^"]+"/gi;
164
+ var BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
165
+ function maskAwsKey(match) {
166
+ return match.slice(0, 4) + "****" + match.slice(-4);
167
+ }
168
+ function maskUrlCreds(match) {
169
+ const atIndex = match.lastIndexOf("@");
170
+ const protocolEnd = match.indexOf("://") + 3;
171
+ return match.slice(0, protocolEnd) + "****:****@" + match.slice(atIndex + 1);
172
+ }
173
+ function redact(value) {
174
+ let result = value;
175
+ result = result.replace(URL_CREDS_PATTERN, maskUrlCreds);
176
+ result = result.replace(AWS_KEY_PREFIX_PATTERN, maskAwsKey);
177
+ result = result.replace(BEARER_PATTERN, "Bearer ****");
178
+ result = result.replace(JSON_CREDENTIAL_PATTERN, (match) => {
179
+ const colonIndex = match.indexOf(":");
180
+ return match.slice(0, colonIndex + 1) + '"****"';
181
+ });
182
+ result = result.replace(
183
+ CREDENTIAL_CONTEXT_PATTERN,
184
+ (_match, _secret, _offset, _full) => {
185
+ const eqIndex = _match.indexOf("=");
186
+ const colonIndex = _match.indexOf(":");
187
+ const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
188
+ const prefix = _match.slice(0, sepIndex + 1);
189
+ return prefix + "****";
190
+ }
191
+ );
192
+ result = result.replace(HEX_CREDENTIAL_CONTEXT_PATTERN, (_match, _secret) => {
193
+ const eqIndex = _match.indexOf("=");
194
+ const colonIndex = _match.indexOf(":");
195
+ const sepIndex = eqIndex >= 0 && colonIndex >= 0 ? Math.min(eqIndex, colonIndex) : eqIndex >= 0 ? eqIndex : colonIndex;
196
+ const prefix = _match.slice(0, sepIndex + 1);
197
+ return prefix + "****";
198
+ });
199
+ return result;
200
+ }
201
+
202
+ // src/credentials/resolver.ts
203
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
204
+ function validateEndpoint(endpoint) {
205
+ let url;
206
+ try {
207
+ url = new URL(endpoint);
208
+ } catch {
209
+ throw new CredentialError(`S3_ENDPOINT is not a valid URL: ${endpoint}`);
210
+ }
211
+ if (url.username !== "" || url.password !== "") {
212
+ throw new CredentialError(
213
+ "S3_ENDPOINT must not contain credentials in the URL (user:pass@host). Use S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY instead."
214
+ );
215
+ }
216
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
217
+ throw new CredentialError(
218
+ `S3_ENDPOINT must use http or https, got: ${url.protocol}`
219
+ );
220
+ }
221
+ if (url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname)) {
222
+ throw new CredentialError(
223
+ `S3_ENDPOINT must use https for non-localhost hosts. Plaintext http is only allowed for localhost/127.0.0.1. Got: ${endpoint}`
224
+ );
225
+ }
226
+ }
232
227
  function tryEnvCredentials() {
233
228
  const key = process.env.S3_ACCESS_KEY_ID;
234
229
  const secret = process.env.S3_SECRET_ACCESS_KEY;
@@ -237,6 +232,7 @@ function tryEnvCredentials() {
237
232
  const present = [key, secret, endpoint].filter(Boolean);
238
233
  if (present.length === 0) return "none";
239
234
  if (present.length < 3) return "partial";
235
+ validateEndpoint(endpoint);
240
236
  const creds = {
241
237
  accessKeyId: key,
242
238
  secretAccessKey: secret,
@@ -251,11 +247,11 @@ function fromRclone(remoteName) {
251
247
  output = execSync("rclone config dump", { stdio: "pipe" });
252
248
  } catch (err) {
253
249
  if (err instanceof Error && err.code === "ENOENT") {
254
- throw new Error(
250
+ throw new CredentialError(
255
251
  "rclone not found \u2014 install rclone or provide S3 credentials via environment variables"
256
252
  );
257
253
  }
258
- throw new Error(
254
+ throw new CredentialError(
259
255
  `Failed to run rclone config dump: ${redact(err instanceof Error ? err.message : "unknown error")}`
260
256
  );
261
257
  }
@@ -263,12 +259,14 @@ function fromRclone(remoteName) {
263
259
  try {
264
260
  parsed = JSON.parse(output.toString("utf-8"));
265
261
  } catch {
266
- throw new Error(`Failed to parse rclone config for remote ${remoteName}`);
262
+ throw new CredentialError(
263
+ `Failed to parse rclone config for remote ${remoteName}`
264
+ );
267
265
  }
268
266
  output = null;
269
267
  const remote = parsed[remoteName];
270
268
  if (!remote) {
271
- throw new Error(
269
+ throw new CredentialError(
272
270
  `Remote "${remoteName}" not found in rclone config. Available remotes: ${Object.keys(parsed).join(", ") || "(none)"}`
273
271
  );
274
272
  }
@@ -277,7 +275,7 @@ function fromRclone(remoteName) {
277
275
  if (!remote.secret_access_key) missing.push("secret_access_key");
278
276
  if (!remote.endpoint) missing.push("endpoint");
279
277
  if (missing.length > 0) {
280
- throw new Error(
278
+ throw new CredentialError(
281
279
  `Rclone remote "${remoteName}" is missing required fields: ${missing.join(", ")}`
282
280
  );
283
281
  }
@@ -292,7 +290,7 @@ function fromRclone(remoteName) {
292
290
  function resolveCredentials(options) {
293
291
  const envResult = tryEnvCredentials();
294
292
  if (envResult === "partial") {
295
- throw new Error(
293
+ throw new CredentialError(
296
294
  "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
295
  );
298
296
  }
@@ -301,12 +299,6 @@ function resolveCredentials(options) {
301
299
  }
302
300
  return fromRclone(options.remoteName);
303
301
  }
304
- var init_resolver = __esm({
305
- "src/credentials/resolver.ts"() {
306
- "use strict";
307
- init_redact();
308
- }
309
- });
310
302
 
311
303
  // src/storage/client.ts
312
304
  import { S3Client } from "@aws-sdk/client-s3";
@@ -321,11 +313,6 @@ function createS3Client(credentials) {
321
313
  forcePathStyle: true
322
314
  });
323
315
  }
324
- var init_client = __esm({
325
- "src/storage/client.ts"() {
326
- "use strict";
327
- }
328
- });
329
316
 
330
317
  // src/storage/operations.ts
331
318
  import {
@@ -336,6 +323,8 @@ import {
336
323
  DeleteObjectCommand,
337
324
  DeleteObjectsCommand
338
325
  } from "@aws-sdk/client-s3";
326
+ var MAX_RETRIES = 3;
327
+ var BASE_DELAY_MS = 500;
339
328
  function isTransientError(error) {
340
329
  if (!(error instanceof Error)) return false;
341
330
  const meta = error.$metadata;
@@ -421,14 +410,6 @@ async function listObjects(client, params) {
421
410
  } while (continuationToken);
422
411
  return allItems;
423
412
  }
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
413
 
433
414
  // src/storage/aliases.ts
434
415
  async function readAlias(client, bucket, site, aliasName) {
@@ -446,18 +427,12 @@ async function writeAlias(client, bucket, site, aliasName, deployId) {
446
427
  contentType: "text/plain"
447
428
  });
448
429
  }
449
- var init_aliases = __esm({
450
- "src/storage/aliases.ts"() {
451
- "use strict";
452
- init_operations();
453
- }
454
- });
455
430
 
456
431
  // src/deploy/id.ts
457
432
  function generateDeployId(gitHash, force = false) {
458
433
  if (gitHash === void 0) {
459
434
  if (!force) {
460
- throw new Error("git hash is required unless --force is set");
435
+ throw new GitError("git hash is required unless --force is set");
461
436
  }
462
437
  return formatId("nogit");
463
438
  }
@@ -473,11 +448,6 @@ function formatId(suffix) {
473
448
  const s = String(now.getUTCSeconds()).padStart(2, "0");
474
449
  return `${y}${mo}${d}-${h}${mi}${s}-${suffix}`;
475
450
  }
476
- var init_id = __esm({
477
- "src/deploy/id.ts"() {
478
- "use strict";
479
- }
480
- });
481
451
 
482
452
  // src/deploy/git.ts
483
453
  import { execSync as execSync2 } from "child_process";
@@ -490,50 +460,77 @@ function getGitState() {
490
460
  return { hash: null, dirty: false, error: "not a git repository" };
491
461
  }
492
462
  }
493
- var init_git = __esm({
494
- "src/deploy/git.ts"() {
495
- "use strict";
463
+
464
+ // src/deploy/preflight.ts
465
+ import { existsSync, statSync as statSync2 } from "fs";
466
+
467
+ // src/deploy/walk.ts
468
+ import { readdirSync, realpathSync, statSync } from "fs";
469
+ import { join, relative as relative2, sep as sep2 } from "path";
470
+ function walkFiles(base) {
471
+ const baseReal = realpathSync(base);
472
+ const entries = readdirSync(base, { recursive: true, withFileTypes: true });
473
+ const files = [];
474
+ for (const entry of entries) {
475
+ const full = join(entry.parentPath, entry.name);
476
+ const relPath = relative2(base, full);
477
+ let targetIsFile;
478
+ try {
479
+ targetIsFile = statSync(full).isFile();
480
+ } catch {
481
+ throw new StorageError(
482
+ `"${relPath}" could not be stat'd (dangling symlink?)`
483
+ );
484
+ }
485
+ if (!targetIsFile) continue;
486
+ let resolved;
487
+ try {
488
+ resolved = realpathSync(full);
489
+ } catch {
490
+ throw new StorageError(
491
+ `"${relPath}" could not be resolved (dangling symlink?)`
492
+ );
493
+ }
494
+ const rel = relative2(baseReal, resolved);
495
+ if (rel === ".." || rel.startsWith(`..${sep2}`)) {
496
+ throw new StorageError(
497
+ `"${relPath}" resolves outside the output directory (symlink escape). Resolved to: ${resolved}`
498
+ );
499
+ }
500
+ files.push({ relPath, absPath: full });
496
501
  }
497
- });
502
+ return files;
503
+ }
498
504
 
499
505
  // src/deploy/preflight.ts
500
- import { existsSync, statSync, readdirSync } from "fs";
501
- import { join } from "path";
502
506
  function validateOutputDir(dir) {
503
507
  if (!existsSync(dir)) {
504
508
  return { valid: false, fileCount: 0, error: "directory not found" };
505
509
  }
506
- if (!statSync(dir).isDirectory()) {
510
+ if (!statSync2(dir).isDirectory()) {
507
511
  return { valid: false, fileCount: 0, error: "not a directory" };
508
512
  }
509
- const fileCount = countFiles(dir);
513
+ let fileCount;
514
+ try {
515
+ fileCount = walkFiles(dir).length;
516
+ } catch (err) {
517
+ if (err instanceof StorageError) {
518
+ return { valid: false, fileCount: 0, error: err.message };
519
+ }
520
+ throw err;
521
+ }
510
522
  if (fileCount === 0) {
511
523
  return { valid: false, fileCount: 0, error: "directory is empty" };
512
524
  }
513
525
  return { valid: true, fileCount };
514
526
  }
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
527
 
532
528
  // src/deploy/upload.ts
533
- import { readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
534
- import { extname, basename, join as join2, relative } from "path";
529
+ import { readFileSync as readFileSync2 } from "fs";
530
+ import { extname, basename } from "path";
535
531
  import { lookup } from "mrmime";
536
532
  import pLimit from "p-limit";
533
+ var FINGERPRINT_RE = /[.-][a-zA-Z0-9_-]{8,}\./;
537
534
  function getContentType(filename) {
538
535
  const mime = lookup(filename);
539
536
  if (mime === void 0) {
@@ -554,25 +551,17 @@ function getCacheControl(filename) {
554
551
  async function uploadDirectory(client, bucket, site, deployId, outputDir, options) {
555
552
  const concurrency = options?.concurrency ?? 10;
556
553
  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
- });
554
+ const files = walkFiles(outputDir);
565
555
  const errors = [];
566
556
  let totalSize = 0;
567
557
  let fileCount = 0;
568
558
  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);
559
+ (file) => limit(async () => {
560
+ const key = `${site}/deploys/${deployId}/${file.relPath}`;
561
+ const contentType = getContentType(file.relPath);
562
+ const cacheControl = getCacheControl(file.relPath);
574
563
  try {
575
- const body = readFileSync2(fullPath);
564
+ const body = readFileSync2(file.absPath);
576
565
  totalSize += body.byteLength;
577
566
  fileCount++;
578
567
  await putObject(client, {
@@ -584,21 +573,13 @@ async function uploadDirectory(client, bucket, site, deployId, outputDir, option
584
573
  });
585
574
  } catch (err) {
586
575
  const message = err instanceof Error ? err.message : "unknown upload error";
587
- errors.push(`${filePath}: ${message}`);
576
+ errors.push(`${file.relPath}: ${message}`);
588
577
  }
589
578
  })
590
579
  );
591
580
  await Promise.all(tasks);
592
581
  return { fileCount, totalSize, errors };
593
582
  }
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
583
 
603
584
  // src/deploy/metadata.ts
604
585
  async function writeDeployMetadata(client, bucket, site, deployId, meta) {
@@ -611,21 +592,70 @@ async function writeDeployMetadata(client, bucket, site, deployId, meta) {
611
592
  contentType: "application/json"
612
593
  });
613
594
  }
614
- var init_metadata = __esm({
615
- "src/deploy/metadata.ts"() {
616
- "use strict";
617
- init_operations();
595
+
596
+ // src/output/format.ts
597
+ import { log } from "@clack/prompts";
598
+
599
+ // src/output/envelope.ts
600
+ function buildEnvelope(command, success, data) {
601
+ return {
602
+ schemaVersion: "1",
603
+ command,
604
+ success,
605
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
606
+ ...data
607
+ };
608
+ }
609
+ function buildErrorEnvelope(command, code, message, issues) {
610
+ const error = {
611
+ code,
612
+ message
613
+ };
614
+ if (issues !== void 0) {
615
+ error.issues = issues;
618
616
  }
619
- });
617
+ return {
618
+ schemaVersion: "1",
619
+ command,
620
+ success: false,
621
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
622
+ error
623
+ };
624
+ }
625
+
626
+ // src/output/format.ts
627
+ function outputSuccess(ctx, humanMessage, data) {
628
+ if (ctx.json) {
629
+ const envelope = buildEnvelope(ctx.command, true, data);
630
+ process.stdout.write(JSON.stringify(envelope) + "\n");
631
+ } else {
632
+ log.success(humanMessage);
633
+ }
634
+ }
635
+ function outputError(ctx, code, message, issues) {
636
+ const redactedMessage = redact(message);
637
+ const redactedIssues = issues?.map(redact);
638
+ if (ctx.json) {
639
+ const envelope = buildErrorEnvelope(
640
+ ctx.command,
641
+ code,
642
+ redactedMessage,
643
+ redactedIssues
644
+ );
645
+ process.stdout.write(JSON.stringify(envelope) + "\n");
646
+ } else {
647
+ log.error(redactedMessage);
648
+ }
649
+ }
620
650
 
621
651
  // src/commands/deploy.ts
622
- var deploy_exports = {};
623
- __export(deploy_exports, {
624
- deploy: () => deploy
625
- });
652
+ var MAX_COLLISION_RETRIES = 3;
653
+ var COLLISION_DELAY_MS = 1e3;
626
654
  async function resolveDeployId(client, bucket, site, gitHash, force) {
655
+ let lastDeployId = "";
627
656
  for (let attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
628
657
  const deployId = generateDeployId(gitHash, force);
658
+ lastDeployId = deployId;
629
659
  const prefix = `${site}/deploys/${deployId}/`;
630
660
  const existing = await listObjects(client, { bucket, prefix });
631
661
  if (existing.length === 0) {
@@ -635,7 +665,9 @@ async function resolveDeployId(client, bucket, site, gitHash, force) {
635
665
  await new Promise((resolve2) => setTimeout(resolve2, COLLISION_DELAY_MS));
636
666
  }
637
667
  }
638
- return generateDeployId(gitHash, force);
668
+ throw new StorageError(
669
+ `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.`
670
+ );
639
671
  }
640
672
  async function deploy(options) {
641
673
  const config = loadConfig({
@@ -738,26 +770,6 @@ async function deploy(options) {
738
770
  previewDomain
739
771
  });
740
772
  }
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
773
 
762
774
  // src/storage/deploys.ts
763
775
  async function listDeploys(client, bucket, site) {
@@ -778,18 +790,8 @@ async function deployExists(client, bucket, site, deployId) {
778
790
  const items = await listObjects(client, { bucket, prefix });
779
791
  return items.length > 0;
780
792
  }
781
- var init_deploys = __esm({
782
- "src/storage/deploys.ts"() {
783
- "use strict";
784
- init_operations();
785
- }
786
- });
787
793
 
788
794
  // src/commands/promote.ts
789
- var promote_exports = {};
790
- __export(promote_exports, {
791
- promote: () => promote
792
- });
793
795
  async function promote(options) {
794
796
  const config = loadConfig();
795
797
  const credentials = resolveCredentials({
@@ -847,24 +849,8 @@ async function promote(options) {
847
849
  productionDomain
848
850
  });
849
851
  }
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
852
 
863
853
  // src/commands/rollback.ts
864
- var rollback_exports = {};
865
- __export(rollback_exports, {
866
- rollback: () => rollback
867
- });
868
854
  import { confirm } from "@clack/prompts";
869
855
  async function rollback(options) {
870
856
  const config = loadConfig();
@@ -928,29 +914,15 @@ async function rollback(options) {
928
914
  productionDomain
929
915
  });
930
916
  }
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
917
 
944
918
  // src/cli.ts
945
- init_format();
946
- init_exit_codes();
947
- import { cac } from "cac";
948
- var version = true ? "0.1.0" : "0.0.0";
919
+ var version = true ? "0.3.0" : "0.0.0";
949
920
  function handleActionError(command, json, err) {
950
921
  const ctx = { json, command };
951
922
  const message = err instanceof Error ? err.message : "unknown error";
952
- outputError(ctx, EXIT_USAGE, message);
953
- exitWithCode(EXIT_USAGE, message);
923
+ const code = err instanceof CliError ? err.exitCode : EXIT_USAGE;
924
+ outputError(ctx, code, message);
925
+ exitWithCode(code, message);
954
926
  }
955
927
  function run(argv = process.argv) {
956
928
  const args = argv.slice(2);
@@ -959,8 +931,7 @@ function run(argv = process.argv) {
959
931
  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
932
  async (flags) => {
961
933
  try {
962
- const { deploy: deploy2 } = await Promise.resolve().then(() => (init_deploy(), deploy_exports));
963
- await deploy2({
934
+ await deploy({
964
935
  json: flags.json ?? false,
965
936
  force: flags.force ?? false,
966
937
  outputDir: flags.outputDir
@@ -973,8 +944,7 @@ function run(argv = process.argv) {
973
944
  staticCli.command("promote [deploy-id]", "Promote a deploy to production").option("--json", "Output as JSON").action(
974
945
  async (deployId, flags) => {
975
946
  try {
976
- const { promote: promote2 } = await Promise.resolve().then(() => (init_promote(), promote_exports));
977
- await promote2({ json: flags.json ?? false, deployId });
947
+ await promote({ json: flags.json ?? false, deployId });
978
948
  } catch (err) {
979
949
  handleActionError("promote", flags.json ?? false, err);
980
950
  }
@@ -982,8 +952,7 @@ function run(argv = process.argv) {
982
952
  );
983
953
  staticCli.command("rollback", "Rollback production to previous deploy").option("--json", "Output as JSON").option("--confirm", "Confirm rollback").action(async (flags) => {
984
954
  try {
985
- const { rollback: rollback2 } = await Promise.resolve().then(() => (init_rollback(), rollback_exports));
986
- await rollback2({
955
+ await rollback({
987
956
  json: flags.json ?? false,
988
957
  confirm: flags.confirm ?? false
989
958
  });