@freecodecamp/universe-cli 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1009 @@
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
+
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
+ });
119
+
120
+ // src/output/exit-codes.ts
121
+ function exitWithCode(code, message) {
122
+ if (message !== void 0) {
123
+ process.stderr.write(message + "\n");
124
+ }
125
+ process.exit(code);
126
+ }
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;
138
+ }
139
+ });
140
+
141
+ // src/config/schema.ts
142
+ 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
+ }
164
+ });
165
+
166
+ // src/config/loader.ts
167
+ import { readFileSync } from "fs";
168
+ import { resolve } from "path";
169
+ import { parse as parseYaml } from "yaml";
170
+ function readEnvOverrides() {
171
+ const overrides = {};
172
+ if (process.env.UNIVERSE_STATIC_OUTPUT_DIR) {
173
+ overrides.output_dir = process.env.UNIVERSE_STATIC_OUTPUT_DIR;
174
+ }
175
+ if (process.env.UNIVERSE_STATIC_BUCKET) {
176
+ overrides.bucket = process.env.UNIVERSE_STATIC_BUCKET;
177
+ }
178
+ if (process.env.UNIVERSE_STATIC_RCLONE_REMOTE) {
179
+ overrides.rclone_remote = process.env.UNIVERSE_STATIC_RCLONE_REMOTE;
180
+ }
181
+ if (process.env.UNIVERSE_STATIC_REGION) {
182
+ overrides.region = process.env.UNIVERSE_STATIC_REGION;
183
+ }
184
+ return overrides;
185
+ }
186
+ function readFlagOverrides(flags) {
187
+ if (!flags) return {};
188
+ const overrides = {};
189
+ if (flags.outputDir) overrides.output_dir = flags.outputDir;
190
+ if (flags.bucket) overrides.bucket = flags.bucket;
191
+ if (flags.rcloneRemote) overrides.rclone_remote = flags.rcloneRemote;
192
+ if (flags.region) overrides.region = flags.region;
193
+ return overrides;
194
+ }
195
+ function loadConfig(options = {}) {
196
+ const cwd = options.cwd ?? process.cwd();
197
+ const configPath = resolve(cwd, "platform.yaml");
198
+ let raw;
199
+ try {
200
+ raw = readFileSync(configPath, "utf-8");
201
+ } catch (err) {
202
+ if (err instanceof Error && err.code === "ENOENT") {
203
+ throw new Error(
204
+ `platform.yaml not found at ${configPath}. See STAFF-GUIDE.md for the required format.`
205
+ );
206
+ }
207
+ throw err;
208
+ }
209
+ const parsed = parseYaml(raw);
210
+ const yamlValidated = platformSchema.parse(parsed);
211
+ const envOverrides = readEnvOverrides();
212
+ const flagOverrides = readFlagOverrides(options.flags);
213
+ const merged = {
214
+ ...yamlValidated,
215
+ static: {
216
+ ...yamlValidated.static,
217
+ ...envOverrides,
218
+ ...flagOverrides
219
+ }
220
+ };
221
+ return merged;
222
+ }
223
+ var init_loader = __esm({
224
+ "src/config/loader.ts"() {
225
+ "use strict";
226
+ init_schema();
227
+ }
228
+ });
229
+
230
+ // src/credentials/resolver.ts
231
+ import { execSync } from "child_process";
232
+ function tryEnvCredentials() {
233
+ const key = process.env.S3_ACCESS_KEY_ID;
234
+ const secret = process.env.S3_SECRET_ACCESS_KEY;
235
+ const endpoint = process.env.S3_ENDPOINT;
236
+ const region = process.env.S3_REGION;
237
+ const present = [key, secret, endpoint].filter(Boolean);
238
+ if (present.length === 0) return "none";
239
+ if (present.length < 3) return "partial";
240
+ const creds = {
241
+ accessKeyId: key,
242
+ secretAccessKey: secret,
243
+ endpoint
244
+ };
245
+ if (region) creds.region = region;
246
+ return creds;
247
+ }
248
+ function fromRclone(remoteName) {
249
+ let output;
250
+ try {
251
+ output = execSync("rclone config dump", { stdio: "pipe" });
252
+ } catch (err) {
253
+ if (err instanceof Error && err.code === "ENOENT") {
254
+ throw new Error(
255
+ "rclone not found \u2014 install rclone or provide S3 credentials via environment variables"
256
+ );
257
+ }
258
+ throw new Error(
259
+ `Failed to run rclone config dump: ${redact(err instanceof Error ? err.message : "unknown error")}`
260
+ );
261
+ }
262
+ let parsed;
263
+ try {
264
+ parsed = JSON.parse(output.toString("utf-8"));
265
+ } catch {
266
+ throw new Error(`Failed to parse rclone config for remote ${remoteName}`);
267
+ }
268
+ output = null;
269
+ const remote = parsed[remoteName];
270
+ if (!remote) {
271
+ throw new Error(
272
+ `Remote "${remoteName}" not found in rclone config. Available remotes: ${Object.keys(parsed).join(", ") || "(none)"}`
273
+ );
274
+ }
275
+ const missing = [];
276
+ if (!remote.access_key_id) missing.push("access_key_id");
277
+ if (!remote.secret_access_key) missing.push("secret_access_key");
278
+ if (!remote.endpoint) missing.push("endpoint");
279
+ if (missing.length > 0) {
280
+ throw new Error(
281
+ `Rclone remote "${remoteName}" is missing required fields: ${missing.join(", ")}`
282
+ );
283
+ }
284
+ const creds = {
285
+ accessKeyId: remote.access_key_id,
286
+ secretAccessKey: remote.secret_access_key,
287
+ endpoint: remote.endpoint
288
+ };
289
+ if (remote.region) creds.region = remote.region;
290
+ return creds;
291
+ }
292
+ function resolveCredentials(options) {
293
+ const envResult = tryEnvCredentials();
294
+ if (envResult === "partial") {
295
+ throw new Error(
296
+ "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
+ );
298
+ }
299
+ if (envResult !== "none") {
300
+ return envResult;
301
+ }
302
+ return fromRclone(options.remoteName);
303
+ }
304
+ var init_resolver = __esm({
305
+ "src/credentials/resolver.ts"() {
306
+ "use strict";
307
+ init_redact();
308
+ }
309
+ });
310
+
311
+ // src/storage/client.ts
312
+ import { S3Client } from "@aws-sdk/client-s3";
313
+ function createS3Client(credentials) {
314
+ return new S3Client({
315
+ endpoint: credentials.endpoint,
316
+ region: credentials.region ?? "auto",
317
+ credentials: {
318
+ accessKeyId: credentials.accessKeyId,
319
+ secretAccessKey: credentials.secretAccessKey
320
+ },
321
+ forcePathStyle: true
322
+ });
323
+ }
324
+ var init_client = __esm({
325
+ "src/storage/client.ts"() {
326
+ "use strict";
327
+ }
328
+ });
329
+
330
+ // src/storage/operations.ts
331
+ import {
332
+ PutObjectCommand,
333
+ GetObjectCommand,
334
+ ListObjectsV2Command,
335
+ HeadObjectCommand,
336
+ DeleteObjectCommand,
337
+ DeleteObjectsCommand
338
+ } from "@aws-sdk/client-s3";
339
+ function isTransientError(error) {
340
+ if (!(error instanceof Error)) return false;
341
+ const meta = error.$metadata;
342
+ const statusCode = meta?.httpStatusCode;
343
+ if (statusCode !== void 0 && statusCode >= 500) return true;
344
+ if (statusCode === 429) return true;
345
+ const name = error.name;
346
+ if (name === "ThrottlingException" || name === "TooManyRequestsException" || name === "RequestTimeout" || name === "RequestTimeoutException")
347
+ return true;
348
+ if (name === "NetworkingError" || name === "TimeoutError" || error.code === "ECONNRESET")
349
+ return true;
350
+ return false;
351
+ }
352
+ async function withRetry(fn) {
353
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
354
+ try {
355
+ return await fn();
356
+ } catch (error) {
357
+ if (!isTransientError(error) || attempt === MAX_RETRIES) {
358
+ throw error;
359
+ }
360
+ const jitter = Math.random() * BASE_DELAY_MS;
361
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt) + jitter;
362
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
363
+ }
364
+ }
365
+ throw new Error("withRetry: unreachable");
366
+ }
367
+ async function putObject(client, params) {
368
+ await withRetry(
369
+ () => client.send(
370
+ new PutObjectCommand({
371
+ Bucket: params.bucket,
372
+ Key: params.key,
373
+ Body: params.body,
374
+ ContentType: params.contentType,
375
+ CacheControl: params.cacheControl
376
+ })
377
+ )
378
+ );
379
+ }
380
+ async function getObject(client, params) {
381
+ try {
382
+ const response = await withRetry(
383
+ () => client.send(
384
+ new GetObjectCommand({
385
+ Bucket: params.bucket,
386
+ Key: params.key
387
+ })
388
+ )
389
+ );
390
+ return await response.Body?.transformToString() ?? null;
391
+ } catch (error) {
392
+ if (error instanceof Error && error.name === "NoSuchKey") {
393
+ return null;
394
+ }
395
+ throw error;
396
+ }
397
+ }
398
+ async function listObjects(client, params) {
399
+ const allItems = [];
400
+ let continuationToken;
401
+ do {
402
+ const response = await withRetry(
403
+ () => client.send(
404
+ new ListObjectsV2Command({
405
+ Bucket: params.bucket,
406
+ Prefix: params.prefix,
407
+ ContinuationToken: continuationToken
408
+ })
409
+ )
410
+ );
411
+ if (response.Contents) {
412
+ for (const item of response.Contents) {
413
+ allItems.push({
414
+ key: item.Key ?? "",
415
+ size: item.Size ?? 0,
416
+ lastModified: item.LastModified ?? /* @__PURE__ */ new Date(0)
417
+ });
418
+ }
419
+ }
420
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
421
+ } while (continuationToken);
422
+ return allItems;
423
+ }
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
+
433
+ // src/storage/aliases.ts
434
+ async function readAlias(client, bucket, site, aliasName) {
435
+ const result = await getObject(client, {
436
+ bucket,
437
+ key: `${site}/${aliasName}`
438
+ });
439
+ return result !== null ? result.trim() : null;
440
+ }
441
+ async function writeAlias(client, bucket, site, aliasName, deployId) {
442
+ await putObject(client, {
443
+ bucket,
444
+ key: `${site}/${aliasName}`,
445
+ body: deployId,
446
+ contentType: "text/plain"
447
+ });
448
+ }
449
+ var init_aliases = __esm({
450
+ "src/storage/aliases.ts"() {
451
+ "use strict";
452
+ init_operations();
453
+ }
454
+ });
455
+
456
+ // src/deploy/id.ts
457
+ function generateDeployId(gitHash, force = false) {
458
+ if (gitHash === void 0) {
459
+ if (!force) {
460
+ throw new Error("git hash is required unless --force is set");
461
+ }
462
+ return formatId("nogit");
463
+ }
464
+ return formatId(gitHash.slice(0, 7));
465
+ }
466
+ function formatId(suffix) {
467
+ const now = /* @__PURE__ */ new Date();
468
+ const y = now.getUTCFullYear();
469
+ const mo = String(now.getUTCMonth() + 1).padStart(2, "0");
470
+ const d = String(now.getUTCDate()).padStart(2, "0");
471
+ const h = String(now.getUTCHours()).padStart(2, "0");
472
+ const mi = String(now.getUTCMinutes()).padStart(2, "0");
473
+ const s = String(now.getUTCSeconds()).padStart(2, "0");
474
+ return `${y}${mo}${d}-${h}${mi}${s}-${suffix}`;
475
+ }
476
+ var init_id = __esm({
477
+ "src/deploy/id.ts"() {
478
+ "use strict";
479
+ }
480
+ });
481
+
482
+ // src/deploy/git.ts
483
+ import { execSync as execSync2 } from "child_process";
484
+ function getGitState() {
485
+ try {
486
+ const hash = execSync2("git rev-parse HEAD", { encoding: "utf-8" }).trim();
487
+ const status = execSync2("git status --porcelain", { encoding: "utf-8" });
488
+ return { hash, dirty: status.length > 0 };
489
+ } catch {
490
+ return { hash: null, dirty: false, error: "not a git repository" };
491
+ }
492
+ }
493
+ var init_git = __esm({
494
+ "src/deploy/git.ts"() {
495
+ "use strict";
496
+ }
497
+ });
498
+
499
+ // src/deploy/preflight.ts
500
+ import { existsSync, statSync, readdirSync } from "fs";
501
+ import { join } from "path";
502
+ function validateOutputDir(dir) {
503
+ if (!existsSync(dir)) {
504
+ return { valid: false, fileCount: 0, error: "directory not found" };
505
+ }
506
+ if (!statSync(dir).isDirectory()) {
507
+ return { valid: false, fileCount: 0, error: "not a directory" };
508
+ }
509
+ const fileCount = countFiles(dir);
510
+ if (fileCount === 0) {
511
+ return { valid: false, fileCount: 0, error: "directory is empty" };
512
+ }
513
+ return { valid: true, fileCount };
514
+ }
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
+
532
+ // src/deploy/upload.ts
533
+ import { readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
534
+ import { extname, basename, join as join2, relative } from "path";
535
+ import { lookup } from "mrmime";
536
+ import pLimit from "p-limit";
537
+ function getContentType(filename) {
538
+ const mime = lookup(filename);
539
+ if (mime === void 0) {
540
+ console.warn(`Unknown content-type for extension: ${extname(filename)}`);
541
+ return "application/octet-stream";
542
+ }
543
+ return mime;
544
+ }
545
+ function getCacheControl(filename) {
546
+ if (extname(filename) === ".html") {
547
+ return "public, max-age=60, must-revalidate";
548
+ }
549
+ if (FINGERPRINT_RE.test(basename(filename))) {
550
+ return "public, max-age=31536000, immutable";
551
+ }
552
+ return "public, max-age=3600";
553
+ }
554
+ async function uploadDirectory(client, bucket, site, deployId, outputDir, options) {
555
+ const concurrency = options?.concurrency ?? 10;
556
+ 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
+ });
565
+ const errors = [];
566
+ let totalSize = 0;
567
+ let fileCount = 0;
568
+ 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);
574
+ try {
575
+ const body = readFileSync2(fullPath);
576
+ totalSize += body.byteLength;
577
+ fileCount++;
578
+ await putObject(client, {
579
+ bucket,
580
+ key,
581
+ body,
582
+ contentType,
583
+ cacheControl
584
+ });
585
+ } catch (err) {
586
+ const message = err instanceof Error ? err.message : "unknown upload error";
587
+ errors.push(`${filePath}: ${message}`);
588
+ }
589
+ })
590
+ );
591
+ await Promise.all(tasks);
592
+ return { fileCount, totalSize, errors };
593
+ }
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
+
603
+ // src/deploy/metadata.ts
604
+ async function writeDeployMetadata(client, bucket, site, deployId, meta) {
605
+ const key = `${site}/_universe/deploys/${deployId}.json`;
606
+ const body = JSON.stringify(meta);
607
+ await putObject(client, {
608
+ bucket,
609
+ key,
610
+ body,
611
+ contentType: "application/json"
612
+ });
613
+ }
614
+ var init_metadata = __esm({
615
+ "src/deploy/metadata.ts"() {
616
+ "use strict";
617
+ init_operations();
618
+ }
619
+ });
620
+
621
+ // src/commands/deploy.ts
622
+ var deploy_exports = {};
623
+ __export(deploy_exports, {
624
+ deploy: () => deploy
625
+ });
626
+ async function resolveDeployId(client, bucket, site, gitHash, force) {
627
+ for (let attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
628
+ const deployId = generateDeployId(gitHash, force);
629
+ const prefix = `${site}/deploys/${deployId}/`;
630
+ const existing = await listObjects(client, { bucket, prefix });
631
+ if (existing.length === 0) {
632
+ return deployId;
633
+ }
634
+ if (attempt < MAX_COLLISION_RETRIES - 1) {
635
+ await new Promise((resolve2) => setTimeout(resolve2, COLLISION_DELAY_MS));
636
+ }
637
+ }
638
+ return generateDeployId(gitHash, force);
639
+ }
640
+ async function deploy(options) {
641
+ const config = loadConfig({
642
+ flags: options.outputDir ? { outputDir: options.outputDir } : void 0
643
+ });
644
+ const credentials = resolveCredentials({
645
+ remoteName: config.static.rclone_remote
646
+ });
647
+ const client = createS3Client(credentials);
648
+ const bucket = config.static.bucket;
649
+ const site = config.name;
650
+ const ctx = { json: options.json, command: "deploy" };
651
+ const git = getGitState();
652
+ if (git.hash === null && !options.force) {
653
+ outputError(
654
+ ctx,
655
+ EXIT_GIT,
656
+ "Git hash not available \u2014 use --force to deploy without git info"
657
+ );
658
+ exitWithCode(
659
+ EXIT_GIT,
660
+ "Git hash not available \u2014 use --force to deploy without git info"
661
+ );
662
+ return;
663
+ }
664
+ if (git.dirty) {
665
+ console.warn(
666
+ "Warning: git working tree is dirty \u2014 uncommitted changes will not be reflected in the deploy"
667
+ );
668
+ }
669
+ const preflight = validateOutputDir(config.static.output_dir);
670
+ if (!preflight.valid) {
671
+ outputError(
672
+ ctx,
673
+ EXIT_OUTPUT_DIR,
674
+ `Output directory invalid: ${preflight.error}`
675
+ );
676
+ exitWithCode(
677
+ EXIT_OUTPUT_DIR,
678
+ `Output directory invalid: ${preflight.error}`
679
+ );
680
+ return;
681
+ }
682
+ const gitHash = git.hash ?? void 0;
683
+ const deployId = await resolveDeployId(
684
+ client,
685
+ bucket,
686
+ site,
687
+ gitHash,
688
+ options.force ?? false
689
+ );
690
+ const uploadResult = await uploadDirectory(
691
+ client,
692
+ bucket,
693
+ site,
694
+ deployId,
695
+ config.static.output_dir
696
+ );
697
+ if (uploadResult.errors.length > 0) {
698
+ outputError(
699
+ ctx,
700
+ EXIT_PARTIAL,
701
+ `Upload partially failed: ${uploadResult.errors.length} file(s) failed`,
702
+ uploadResult.errors
703
+ );
704
+ exitWithCode(
705
+ EXIT_PARTIAL,
706
+ `Upload partially failed: ${uploadResult.errors.length} file(s) failed`
707
+ );
708
+ return;
709
+ }
710
+ await writeDeployMetadata(client, bucket, site, deployId, {
711
+ deployId,
712
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
713
+ gitHash: git.hash,
714
+ gitDirty: git.dirty,
715
+ fileCount: uploadResult.fileCount,
716
+ totalSize: uploadResult.totalSize
717
+ });
718
+ await writeAlias(client, bucket, site, "preview", deployId);
719
+ const sizeKB = (uploadResult.totalSize / 1024).toFixed(1);
720
+ const previewDomain = config.domain?.preview ?? `preview.${site}`;
721
+ const humanMsg = [
722
+ `Deployed ${deployId}`,
723
+ ``,
724
+ ` Site: ${site}`,
725
+ ` Files: ${uploadResult.fileCount}`,
726
+ ` Size: ${sizeKB} KB`,
727
+ ` Alias: preview`,
728
+ ` Preview: https://${previewDomain}`,
729
+ ``,
730
+ `Next: universe static promote`
731
+ ].join("\n");
732
+ outputSuccess(ctx, humanMsg, {
733
+ deployId,
734
+ site,
735
+ fileCount: uploadResult.fileCount,
736
+ totalSize: uploadResult.totalSize,
737
+ alias: "preview",
738
+ previewDomain
739
+ });
740
+ }
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
+
762
+ // src/storage/deploys.ts
763
+ async function listDeploys(client, bucket, site) {
764
+ const prefix = `${site}/deploys/`;
765
+ const items = await listObjects(client, { bucket, prefix });
766
+ const deployIds = /* @__PURE__ */ new Set();
767
+ for (const item of items) {
768
+ const afterPrefix = item.key.slice(prefix.length);
769
+ const segment = afterPrefix.split("/")[0];
770
+ if (segment) {
771
+ deployIds.add(segment);
772
+ }
773
+ }
774
+ return [...deployIds].sort().reverse();
775
+ }
776
+ async function deployExists(client, bucket, site, deployId) {
777
+ const prefix = `${site}/deploys/${deployId}/`;
778
+ const items = await listObjects(client, { bucket, prefix });
779
+ return items.length > 0;
780
+ }
781
+ var init_deploys = __esm({
782
+ "src/storage/deploys.ts"() {
783
+ "use strict";
784
+ init_operations();
785
+ }
786
+ });
787
+
788
+ // src/commands/promote.ts
789
+ var promote_exports = {};
790
+ __export(promote_exports, {
791
+ promote: () => promote
792
+ });
793
+ async function promote(options) {
794
+ const config = loadConfig();
795
+ const credentials = resolveCredentials({
796
+ remoteName: config.static.rclone_remote
797
+ });
798
+ const client = createS3Client(credentials);
799
+ const bucket = config.static.bucket;
800
+ const site = config.name;
801
+ const ctx = { json: options.json, command: "promote" };
802
+ let deployId;
803
+ if (options.deployId) {
804
+ const exists = await deployExists(client, bucket, site, options.deployId);
805
+ if (!exists) {
806
+ outputError(
807
+ ctx,
808
+ EXIT_DEPLOY_NOT_FOUND,
809
+ `Deploy ${options.deployId} not found`
810
+ );
811
+ exitWithCode(
812
+ EXIT_DEPLOY_NOT_FOUND,
813
+ `Deploy ${options.deployId} not found`
814
+ );
815
+ return;
816
+ }
817
+ deployId = options.deployId;
818
+ } else {
819
+ const preview = await readAlias(client, bucket, site, "preview");
820
+ if (!preview) {
821
+ outputError(
822
+ ctx,
823
+ EXIT_ALIAS,
824
+ "No preview alias set \u2014 deploy first or specify a deploy ID"
825
+ );
826
+ exitWithCode(
827
+ EXIT_ALIAS,
828
+ "No preview alias set \u2014 deploy first or specify a deploy ID"
829
+ );
830
+ return;
831
+ }
832
+ deployId = preview;
833
+ }
834
+ await writeAlias(client, bucket, site, "production", deployId);
835
+ const productionDomain = config.domain?.production ?? site;
836
+ const humanMsg = [
837
+ `Promoted ${deployId} to production`,
838
+ ``,
839
+ ` Site: ${site}`,
840
+ ` Deploy: ${deployId}`,
841
+ ` Production: https://${productionDomain}`
842
+ ].join("\n");
843
+ outputSuccess(ctx, humanMsg, {
844
+ deployId,
845
+ site,
846
+ alias: "production",
847
+ productionDomain
848
+ });
849
+ }
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
+
863
+ // src/commands/rollback.ts
864
+ var rollback_exports = {};
865
+ __export(rollback_exports, {
866
+ rollback: () => rollback
867
+ });
868
+ import { confirm } from "@clack/prompts";
869
+ async function rollback(options) {
870
+ const config = loadConfig();
871
+ const credentials = resolveCredentials({
872
+ remoteName: config.static.rclone_remote
873
+ });
874
+ const client = createS3Client(credentials);
875
+ const bucket = config.static.bucket;
876
+ const site = config.name;
877
+ const ctx = { json: options.json, command: "rollback" };
878
+ const currentDeployId = await readAlias(client, bucket, site, "production");
879
+ if (!currentDeployId) {
880
+ outputError(
881
+ ctx,
882
+ EXIT_ALIAS,
883
+ "No production alias set \u2014 nothing to rollback"
884
+ );
885
+ exitWithCode(EXIT_ALIAS, "No production alias set \u2014 nothing to rollback");
886
+ return;
887
+ }
888
+ const deploys = await listDeploys(client, bucket, site);
889
+ const currentIndex = deploys.indexOf(currentDeployId);
890
+ const previousDeploy = deploys[currentIndex + 1];
891
+ if (!previousDeploy) {
892
+ outputError(ctx, EXIT_ALIAS, "no previous deploy to rollback to");
893
+ exitWithCode(EXIT_ALIAS, "no previous deploy to rollback to");
894
+ return;
895
+ }
896
+ if (options.json && !options.confirm) {
897
+ outputError(
898
+ ctx,
899
+ EXIT_CONFIRM,
900
+ "Rollback requires --confirm flag in JSON mode"
901
+ );
902
+ exitWithCode(EXIT_CONFIRM, "Rollback requires --confirm flag in JSON mode");
903
+ return;
904
+ }
905
+ if (!options.json && !options.confirm) {
906
+ const confirmed = await confirm({
907
+ message: `Rollback production from ${currentDeployId} to ${previousDeploy}?`
908
+ });
909
+ if (!confirmed || typeof confirmed === "symbol") {
910
+ return;
911
+ }
912
+ }
913
+ await writeAlias(client, bucket, site, "production", previousDeploy);
914
+ const productionDomain = config.domain?.production ?? site;
915
+ const humanMsg = [
916
+ `Rolled back production`,
917
+ ``,
918
+ ` Site: ${site}`,
919
+ ` Was: ${currentDeployId}`,
920
+ ` Now: ${previousDeploy}`,
921
+ ` Production: https://${productionDomain}`
922
+ ].join("\n");
923
+ outputSuccess(ctx, humanMsg, {
924
+ previousDeployId: currentDeployId,
925
+ rolledBackTo: previousDeploy,
926
+ site,
927
+ alias: "production",
928
+ productionDomain
929
+ });
930
+ }
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
+
944
+ // 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";
949
+ function handleActionError(command, json, err) {
950
+ const ctx = { json, command };
951
+ const message = err instanceof Error ? err.message : "unknown error";
952
+ outputError(ctx, EXIT_USAGE, message);
953
+ exitWithCode(EXIT_USAGE, message);
954
+ }
955
+ function run(argv = process.argv) {
956
+ const args = argv.slice(2);
957
+ if (args[0] === "static") {
958
+ const staticCli = cac("universe static");
959
+ 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
+ async (flags) => {
961
+ try {
962
+ const { deploy: deploy2 } = await Promise.resolve().then(() => (init_deploy(), deploy_exports));
963
+ await deploy2({
964
+ json: flags.json ?? false,
965
+ force: flags.force ?? false,
966
+ outputDir: flags.outputDir
967
+ });
968
+ } catch (err) {
969
+ handleActionError("deploy", flags.json ?? false, err);
970
+ }
971
+ }
972
+ );
973
+ staticCli.command("promote [deploy-id]", "Promote a deploy to production").option("--json", "Output as JSON").action(
974
+ async (deployId, flags) => {
975
+ try {
976
+ const { promote: promote2 } = await Promise.resolve().then(() => (init_promote(), promote_exports));
977
+ await promote2({ json: flags.json ?? false, deployId });
978
+ } catch (err) {
979
+ handleActionError("promote", flags.json ?? false, err);
980
+ }
981
+ }
982
+ );
983
+ staticCli.command("rollback", "Rollback production to previous deploy").option("--json", "Output as JSON").option("--confirm", "Confirm rollback").action(async (flags) => {
984
+ try {
985
+ const { rollback: rollback2 } = await Promise.resolve().then(() => (init_rollback(), rollback_exports));
986
+ await rollback2({
987
+ json: flags.json ?? false,
988
+ confirm: flags.confirm ?? false
989
+ });
990
+ } catch (err) {
991
+ handleActionError("rollback", flags.json ?? false, err);
992
+ }
993
+ });
994
+ staticCli.help();
995
+ staticCli.version(version);
996
+ staticCli.parse(["node", "universe-static", ...args.slice(1)]);
997
+ } else {
998
+ const cli = cac("universe");
999
+ cli.command("static [...args]", "Static site deployment commands").action(() => {
1000
+ console.log("Run: universe static --help");
1001
+ });
1002
+ cli.help();
1003
+ cli.version(version);
1004
+ cli.parse(argv);
1005
+ }
1006
+ }
1007
+
1008
+ // src/index.ts
1009
+ run();