s3-ship 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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +196 -0
  3. package/dist/aws/cloudfront-client.d.ts +10 -0
  4. package/dist/aws/cloudfront-client.d.ts.map +1 -0
  5. package/dist/aws/s3-client.d.ts +11 -0
  6. package/dist/aws/s3-client.d.ts.map +1 -0
  7. package/dist/cli/init.d.ts +11 -0
  8. package/dist/cli/init.d.ts.map +1 -0
  9. package/dist/cli/run.d.ts +6 -0
  10. package/dist/cli/run.d.ts.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +1000 -0
  14. package/dist/config/define.d.ts +3 -0
  15. package/dist/config/define.d.ts.map +1 -0
  16. package/dist/config/load.d.ts +10 -0
  17. package/dist/config/load.d.ts.map +1 -0
  18. package/dist/config/merge.d.ts +28 -0
  19. package/dist/config/merge.d.ts.map +1 -0
  20. package/dist/config/schema.d.ts +348 -0
  21. package/dist/config/schema.d.ts.map +1 -0
  22. package/dist/deploy.d.ts +32 -0
  23. package/dist/deploy.d.ts.map +1 -0
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +707 -0
  27. package/dist/reporter.d.ts +8 -0
  28. package/dist/reporter.d.ts.map +1 -0
  29. package/dist/stages/delete-stale.d.ts +10 -0
  30. package/dist/stages/delete-stale.d.ts.map +1 -0
  31. package/dist/stages/diff.d.ts +34 -0
  32. package/dist/stages/diff.d.ts.map +1 -0
  33. package/dist/stages/execute.d.ts +28 -0
  34. package/dist/stages/execute.d.ts.map +1 -0
  35. package/dist/stages/invalidate.d.ts +8 -0
  36. package/dist/stages/invalidate.d.ts.map +1 -0
  37. package/dist/stages/list-remote.d.ts +13 -0
  38. package/dist/stages/list-remote.d.ts.map +1 -0
  39. package/dist/stages/redirects.d.ts +11 -0
  40. package/dist/stages/redirects.d.ts.map +1 -0
  41. package/dist/stages/scan.d.ts +12 -0
  42. package/dist/stages/scan.d.ts.map +1 -0
  43. package/dist/stages/upload.d.ts +12 -0
  44. package/dist/stages/upload.d.ts.map +1 -0
  45. package/dist/util/content-type.d.ts +2 -0
  46. package/dist/util/content-type.d.ts.map +1 -0
  47. package/dist/util/glob-match.d.ts +3 -0
  48. package/dist/util/glob-match.d.ts.map +1 -0
  49. package/dist/util/parallel-limit.d.ts +10 -0
  50. package/dist/util/parallel-limit.d.ts.map +1 -0
  51. package/dist/util/retry.d.ts +6 -0
  52. package/dist/util/retry.d.ts.map +1 -0
  53. package/package.json +66 -0
package/dist/index.js ADDED
@@ -0,0 +1,707 @@
1
+ // src/config/define.ts
2
+ function defineConfig(config) {
3
+ return config;
4
+ }
5
+ // src/aws/cloudfront-client.ts
6
+ import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
7
+ import { fromIni } from "@aws-sdk/credential-providers";
8
+ function createCloudFrontClient(options) {
9
+ return new CloudFrontClient({
10
+ region: options.region ?? "us-east-1",
11
+ credentials: options.profile ? fromIni({ profile: options.profile }) : undefined
12
+ });
13
+ }
14
+
15
+ // src/aws/s3-client.ts
16
+ import { S3Client } from "@aws-sdk/client-s3";
17
+ import { fromIni as fromIni2 } from "@aws-sdk/credential-providers";
18
+ function createS3Client(options) {
19
+ return new S3Client({
20
+ region: options.region,
21
+ credentials: options.profile ? fromIni2({ profile: options.profile }) : undefined
22
+ });
23
+ }
24
+ function normalizeTargetPrefix(target) {
25
+ if (!target)
26
+ return "";
27
+ return target.endsWith("/") ? target : `${target}/`;
28
+ }
29
+
30
+ // src/config/load.ts
31
+ import { existsSync } from "node:fs";
32
+ import { readFile } from "node:fs/promises";
33
+ import { extname, isAbsolute, resolve } from "node:path";
34
+ import { pathToFileURL } from "node:url";
35
+ import YAML from "yaml";
36
+ var SEARCH_ORDER = [
37
+ "s3-ship.config.ts",
38
+ "s3-ship.config.js",
39
+ "s3-ship.config.mjs",
40
+ "s3-ship.config.cjs",
41
+ "s3-ship.config.json",
42
+ "s3-ship.config.yml",
43
+ "s3-ship.config.yaml"
44
+ ];
45
+ async function loadConfigFile(options) {
46
+ const sourcePath = options.configPath ? resolveExplicitPath(options.configPath, options.cwd) : findConfigInCwd(options.cwd);
47
+ if (!sourcePath) {
48
+ throw new Error(`[config] No s3-ship config file found in ${options.cwd}. Expected one of: ${SEARCH_ORDER.join(", ")}`);
49
+ }
50
+ const config = await readByExtension(sourcePath);
51
+ return { config, sourcePath };
52
+ }
53
+ function resolveExplicitPath(configPath, cwd) {
54
+ const abs = isAbsolute(configPath) ? configPath : resolve(cwd, configPath);
55
+ if (!existsSync(abs)) {
56
+ throw new Error(`[config] Config file not found: ${abs}`);
57
+ }
58
+ return abs;
59
+ }
60
+ function findConfigInCwd(cwd) {
61
+ for (const name of SEARCH_ORDER) {
62
+ const candidate = resolve(cwd, name);
63
+ if (existsSync(candidate)) {
64
+ return candidate;
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ async function readByExtension(path) {
70
+ const ext = extname(path).toLowerCase();
71
+ switch (ext) {
72
+ case ".json":
73
+ return parseJson(path);
74
+ case ".yml":
75
+ case ".yaml":
76
+ return parseYaml(path);
77
+ case ".ts":
78
+ case ".js":
79
+ case ".mjs":
80
+ case ".cjs":
81
+ return importModule(path);
82
+ default:
83
+ throw new Error(`[config] Unsupported config file extension: ${ext}`);
84
+ }
85
+ }
86
+ async function parseJson(path) {
87
+ const raw = await readFile(path, "utf8");
88
+ try {
89
+ return JSON.parse(raw);
90
+ } catch (err) {
91
+ throw new Error(`[config] Failed to parse JSON config at ${path}: ${err.message}`);
92
+ }
93
+ }
94
+ async function parseYaml(path) {
95
+ const raw = await readFile(path, "utf8");
96
+ try {
97
+ const data = YAML.parse(raw);
98
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
99
+ throw new Error("YAML root must be a mapping");
100
+ }
101
+ return data;
102
+ } catch (err) {
103
+ throw new Error(`[config] Failed to parse YAML config at ${path}: ${err.message}`);
104
+ }
105
+ }
106
+ async function importModule(path) {
107
+ const url = `${pathToFileURL(path).href}?t=${Date.now()}`;
108
+ const mod = await import(url);
109
+ if (mod.default == null) {
110
+ throw new Error(`[config] ${path} has no default export. Add: export default { ... } or module.exports = { ... }`);
111
+ }
112
+ if (typeof mod.default !== "object" || Array.isArray(mod.default)) {
113
+ throw new Error(`[config] ${path} default export must be a config object.`);
114
+ }
115
+ return mod.default;
116
+ }
117
+
118
+ // src/config/merge.ts
119
+ var DEFAULTS = {
120
+ source: "dist",
121
+ target: "",
122
+ syncDelete: false,
123
+ invalidationPaths: ["/*"]
124
+ };
125
+ function pickEnvironment(config, envName) {
126
+ if (!envName)
127
+ return;
128
+ const env = config.environments?.[envName];
129
+ if (!env) {
130
+ const available = Object.keys(config.environments ?? {}).join(", ") || "(none)";
131
+ throw new Error(`[config] environment "${envName}" not found. Available environments: ${available}`);
132
+ }
133
+ return env;
134
+ }
135
+ function resolveConfig(config, overrides) {
136
+ const env = pickEnvironment(config, overrides.env);
137
+ const envVars = overrides.envVars ?? {};
138
+ const layered = {
139
+ ...config,
140
+ ...env ?? {}
141
+ };
142
+ if (env?.cloudfront || config.cloudfront) {
143
+ layered.cloudfront = {
144
+ ...config.cloudfront ?? {},
145
+ ...env?.cloudfront ?? {}
146
+ };
147
+ }
148
+ const bucket = overrides.bucket ?? layered.bucket;
149
+ if (!bucket) {
150
+ throw new Error("[config] bucket is required (top-level, environment, or --bucket flag)");
151
+ }
152
+ const region = layered.region ?? envVars.AWS_REGION;
153
+ const profile = overrides.profile ?? layered.profile ?? envVars.AWS_PROFILE;
154
+ const cloudfront = layered.cloudfront ? {
155
+ distributionId: layered.cloudfront.distributionId,
156
+ invalidationPaths: layered.cloudfront.invalidationPaths ?? DEFAULTS.invalidationPaths
157
+ } : undefined;
158
+ const syncDelete = overrides.syncDelete ?? layered.syncDelete ?? DEFAULTS.syncDelete;
159
+ return {
160
+ source: overrides.source ?? layered.source ?? DEFAULTS.source,
161
+ target: overrides.target ?? layered.target ?? DEFAULTS.target,
162
+ bucket,
163
+ region,
164
+ profile,
165
+ cloudfront,
166
+ syncDelete,
167
+ ignore: layered.ignore ?? [],
168
+ redirects: layered.redirects ?? [],
169
+ cacheControl: layered.cacheControl ?? [],
170
+ environment: overrides.env
171
+ };
172
+ }
173
+
174
+ // src/config/schema.ts
175
+ import { z } from "zod";
176
+ var redirectRuleSchema = z.object({
177
+ from: z.string().min(1, "redirect.from is required"),
178
+ to: z.string().min(1, "redirect.to is required"),
179
+ statusCode: z.union([z.literal(301), z.literal(302)]).optional()
180
+ });
181
+ var cacheRuleSchema = z.object({
182
+ match: z.string().min(1),
183
+ cacheControl: z.string().min(1)
184
+ });
185
+ var cloudfrontConfigSchema = z.object({
186
+ distributionId: z.string().min(1, "cloudfront.distributionId is required"),
187
+ invalidationPaths: z.array(z.string().min(1)).optional()
188
+ });
189
+ var baseConfigShape = {
190
+ source: z.string().min(1).optional(),
191
+ target: z.string().optional(),
192
+ bucket: z.string().min(1).optional(),
193
+ region: z.string().min(1).optional(),
194
+ profile: z.string().min(1).optional(),
195
+ cloudfront: cloudfrontConfigSchema.optional(),
196
+ syncDelete: z.boolean().optional(),
197
+ ignore: z.array(z.string()).optional(),
198
+ redirects: z.array(redirectRuleSchema).optional(),
199
+ cacheControl: z.array(cacheRuleSchema).optional()
200
+ };
201
+ var environmentConfigSchema = z.object(baseConfigShape).strict();
202
+ var configSchema = z.object({
203
+ ...baseConfigShape,
204
+ environments: z.record(z.string().min(1), environmentConfigSchema).optional()
205
+ }).strict();
206
+ function zodIssuesToErrors(issues) {
207
+ return issues.map((issue) => ({
208
+ path: issue.path.join("."),
209
+ message: issue.message
210
+ }));
211
+ }
212
+ function validateConfig(input) {
213
+ const parsed = configSchema.safeParse(input);
214
+ if (!parsed.success) {
215
+ return { ok: false, errors: zodIssuesToErrors(parsed.error.issues) };
216
+ }
217
+ const value = parsed.data;
218
+ const errors = [];
219
+ const envs = value.environments ?? {};
220
+ const envEntries = Object.entries(envs);
221
+ const topHasBucket = typeof value.bucket === "string" && value.bucket.length > 0;
222
+ if (!topHasBucket) {
223
+ if (envEntries.length === 0) {
224
+ errors.push({
225
+ path: "bucket",
226
+ message: "bucket is required at the top level or inside every environment"
227
+ });
228
+ } else {
229
+ for (const [name, env] of envEntries) {
230
+ const envHasBucket = typeof env.bucket === "string" && env.bucket.length > 0;
231
+ if (!envHasBucket) {
232
+ errors.push({
233
+ path: `environments.${name}.bucket`,
234
+ message: `bucket is required in environment "${name}" because no top-level bucket is set`
235
+ });
236
+ }
237
+ }
238
+ }
239
+ }
240
+ if (errors.length > 0) {
241
+ return { ok: false, errors };
242
+ }
243
+ return { ok: true, value };
244
+ }
245
+
246
+ // src/stages/diff.ts
247
+ function prefixed(prefix, key) {
248
+ return prefix ? `${prefix}${key}` : key;
249
+ }
250
+ function sortByKey(items) {
251
+ return [...items].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
252
+ }
253
+ function computeDiff(input) {
254
+ const prefix = normalizeTargetPrefix(input.target);
255
+ const plannedRedirects = input.redirects.map((r) => ({
256
+ ...r,
257
+ fullKey: prefixed(prefix, r.from)
258
+ }));
259
+ const redirectKeys = new Set(plannedRedirects.map((r) => r.fullKey));
260
+ const localFull = input.localFiles.map((f) => ({ ...f, key: prefixed(prefix, f.key) })).filter((f) => !redirectKeys.has(f.key));
261
+ const localKeys = new Set(localFull.map((f) => f.key));
262
+ const remoteByKey = new Map(input.remoteObjects.map((r) => [r.key, r]));
263
+ const toUpload = [];
264
+ const toUpdate = [];
265
+ const toSkip = [];
266
+ for (const file of localFull) {
267
+ const remote = remoteByKey.get(file.key);
268
+ if (!remote) {
269
+ toUpload.push(file);
270
+ continue;
271
+ }
272
+ if (remote.etag.includes("-")) {
273
+ toUpdate.push(file);
274
+ continue;
275
+ }
276
+ if (remote.etag === file.hash) {
277
+ toSkip.push(file);
278
+ } else {
279
+ toUpdate.push(file);
280
+ }
281
+ }
282
+ let toDelete = [];
283
+ if (input.syncDelete) {
284
+ for (const remote of input.remoteObjects) {
285
+ if (localKeys.has(remote.key))
286
+ continue;
287
+ if (redirectKeys.has(remote.key))
288
+ continue;
289
+ toDelete.push(remote.key);
290
+ }
291
+ toDelete = toDelete.sort();
292
+ }
293
+ return {
294
+ bucket: input.bucket,
295
+ target: input.target,
296
+ toUpload: sortByKey(toUpload),
297
+ toUpdate: sortByKey(toUpdate),
298
+ toSkip: sortByKey(toSkip),
299
+ toDelete,
300
+ redirects: plannedRedirects,
301
+ cloudfront: input.cloudfront
302
+ };
303
+ }
304
+
305
+ // src/util/parallel-limit.ts
306
+ async function parallelLimit(items, limit, worker) {
307
+ const results = new Array(items.length);
308
+ let cursor = 0;
309
+ async function next() {
310
+ while (true) {
311
+ const index = cursor++;
312
+ if (index >= items.length)
313
+ return;
314
+ const item = items[index];
315
+ try {
316
+ const value = await worker(item, index);
317
+ results[index] = { ok: true, value, item };
318
+ } catch (error) {
319
+ results[index] = { ok: false, error, item };
320
+ }
321
+ }
322
+ }
323
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => next());
324
+ await Promise.all(workers);
325
+ return results;
326
+ }
327
+
328
+ // src/stages/delete-stale.ts
329
+ import { DeleteObjectsCommand } from "@aws-sdk/client-s3";
330
+
331
+ // src/util/retry.ts
332
+ async function withRetry(fn, options) {
333
+ let lastErr;
334
+ for (let i = 0;i < options.attempts; i++) {
335
+ try {
336
+ return await fn();
337
+ } catch (err) {
338
+ lastErr = err;
339
+ if (i < options.attempts - 1) {
340
+ await new Promise((resolve2) => setTimeout(resolve2, options.baseMs * 4 ** i));
341
+ }
342
+ }
343
+ }
344
+ throw lastErr;
345
+ }
346
+
347
+ // src/stages/delete-stale.ts
348
+ var MAX_KEYS_PER_BATCH = 1000;
349
+ async function deleteStale(input) {
350
+ if (input.keys.length === 0)
351
+ return;
352
+ for (let i = 0;i < input.keys.length; i += MAX_KEYS_PER_BATCH) {
353
+ const batch = input.keys.slice(i, i + MAX_KEYS_PER_BATCH);
354
+ const command = new DeleteObjectsCommand({
355
+ Bucket: input.bucket,
356
+ Delete: {
357
+ Objects: batch.map((Key) => ({ Key })),
358
+ Quiet: true
359
+ }
360
+ });
361
+ await withRetry(() => input.client.send(command), input.retry);
362
+ }
363
+ }
364
+
365
+ // src/stages/invalidate.ts
366
+ import {
367
+ CreateInvalidationCommand
368
+ } from "@aws-sdk/client-cloudfront";
369
+ async function invalidateCloudFront(input) {
370
+ const callerReference = `s3-ship-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
371
+ const command = new CreateInvalidationCommand({
372
+ DistributionId: input.distributionId,
373
+ InvalidationBatch: {
374
+ CallerReference: callerReference,
375
+ Paths: {
376
+ Quantity: input.paths.length,
377
+ Items: input.paths
378
+ }
379
+ }
380
+ });
381
+ const response = await input.client.send(command);
382
+ return response.Invalidation?.Id;
383
+ }
384
+
385
+ // src/stages/redirects.ts
386
+ import { PutObjectCommand } from "@aws-sdk/client-s3";
387
+ async function putRedirect(input) {
388
+ const command = new PutObjectCommand({
389
+ Bucket: input.bucket,
390
+ Key: input.redirect.fullKey,
391
+ Body: "",
392
+ ContentType: "text/html; charset=utf-8",
393
+ WebsiteRedirectLocation: input.redirect.to,
394
+ ChecksumAlgorithm: "CRC32"
395
+ });
396
+ await withRetry(() => input.client.send(command), input.retry);
397
+ }
398
+
399
+ // src/stages/upload.ts
400
+ import { readFile as readFile2 } from "node:fs/promises";
401
+ import { PutObjectCommand as PutObjectCommand2 } from "@aws-sdk/client-s3";
402
+
403
+ // src/util/content-type.ts
404
+ import { extname as extname2 } from "node:path";
405
+ var MAP = {
406
+ ".html": "text/html; charset=utf-8",
407
+ ".htm": "text/html; charset=utf-8",
408
+ ".css": "text/css; charset=utf-8",
409
+ ".js": "application/javascript; charset=utf-8",
410
+ ".mjs": "application/javascript; charset=utf-8",
411
+ ".json": "application/json; charset=utf-8",
412
+ ".map": "application/json; charset=utf-8",
413
+ ".xml": "application/xml; charset=utf-8",
414
+ ".txt": "text/plain; charset=utf-8",
415
+ ".md": "text/markdown; charset=utf-8",
416
+ ".svg": "image/svg+xml",
417
+ ".png": "image/png",
418
+ ".jpg": "image/jpeg",
419
+ ".jpeg": "image/jpeg",
420
+ ".gif": "image/gif",
421
+ ".webp": "image/webp",
422
+ ".avif": "image/avif",
423
+ ".ico": "image/x-icon",
424
+ ".bmp": "image/bmp",
425
+ ".woff": "font/woff",
426
+ ".woff2": "font/woff2",
427
+ ".ttf": "font/ttf",
428
+ ".otf": "font/otf",
429
+ ".eot": "application/vnd.ms-fontobject",
430
+ ".pdf": "application/pdf",
431
+ ".mp4": "video/mp4",
432
+ ".webm": "video/webm",
433
+ ".mp3": "audio/mpeg",
434
+ ".wav": "audio/wav",
435
+ ".ogg": "audio/ogg",
436
+ ".wasm": "application/wasm",
437
+ ".zip": "application/zip",
438
+ ".gz": "application/gzip"
439
+ };
440
+ function inferContentType(filename) {
441
+ const ext = extname2(filename).toLowerCase();
442
+ return MAP[ext] ?? "application/octet-stream";
443
+ }
444
+
445
+ // src/stages/upload.ts
446
+ async function uploadFile(input) {
447
+ const body = await readFile2(input.file.localPath);
448
+ const contentType = inferContentType(input.file.localPath);
449
+ const cacheControl = input.cacheControlFor(input.file.key);
450
+ const command = new PutObjectCommand2({
451
+ Bucket: input.bucket,
452
+ Key: input.file.key,
453
+ Body: body,
454
+ ContentType: contentType,
455
+ CacheControl: cacheControl,
456
+ ChecksumAlgorithm: "CRC32"
457
+ });
458
+ await withRetry(() => input.client.send(command), input.retry);
459
+ }
460
+
461
+ // src/stages/execute.ts
462
+ async function runUploads(files, input) {
463
+ const results = await parallelLimit(files, input.concurrency, (file) => uploadFile({
464
+ client: input.s3Client,
465
+ bucket: input.plan.bucket,
466
+ file,
467
+ cacheControlFor: input.cacheControlFor,
468
+ retry: input.retry
469
+ }));
470
+ const ok = results.filter((r) => r.ok).length;
471
+ const failures = results.filter((r) => !r.ok).map((r) => ({ key: r.item.key, error: r.error }));
472
+ return { ok, failures };
473
+ }
474
+ async function executePlan(input) {
475
+ const failures = [];
476
+ const uploadResult = await runUploads(input.plan.toUpload, input);
477
+ failures.push(...uploadResult.failures);
478
+ const updateResult = await runUploads(input.plan.toUpdate, input);
479
+ failures.push(...updateResult.failures);
480
+ const redirectResults = await parallelLimit(input.plan.redirects, input.concurrency, (redirect) => putRedirect({
481
+ client: input.s3Client,
482
+ bucket: input.plan.bucket,
483
+ redirect,
484
+ retry: input.retry
485
+ }));
486
+ const redirected = redirectResults.filter((r) => r.ok).length;
487
+ for (const r of redirectResults) {
488
+ if (!r.ok) {
489
+ failures.push({ key: r.item.fullKey, error: r.error });
490
+ }
491
+ }
492
+ let deleted = 0;
493
+ if (input.plan.toDelete.length > 0) {
494
+ try {
495
+ await deleteStale({
496
+ client: input.s3Client,
497
+ bucket: input.plan.bucket,
498
+ keys: input.plan.toDelete,
499
+ retry: input.retry
500
+ });
501
+ deleted = input.plan.toDelete.length;
502
+ } catch (error) {
503
+ failures.push({ key: "<delete-batch>", error });
504
+ }
505
+ }
506
+ let invalidationId;
507
+ if (!input.skipInvalidate && input.plan.cloudfront && input.cfClient) {
508
+ try {
509
+ invalidationId = await invalidateCloudFront({
510
+ client: input.cfClient,
511
+ distributionId: input.plan.cloudfront.distributionId,
512
+ paths: input.plan.cloudfront.invalidationPaths
513
+ });
514
+ } catch (error) {
515
+ failures.push({ key: "<cloudfront-invalidation>", error });
516
+ }
517
+ }
518
+ return {
519
+ uploaded: uploadResult.ok,
520
+ updated: updateResult.ok,
521
+ skipped: input.plan.toSkip.length,
522
+ deleted,
523
+ redirected,
524
+ invalidationId,
525
+ failures
526
+ };
527
+ }
528
+
529
+ // src/stages/list-remote.ts
530
+ import { ListObjectsV2Command } from "@aws-sdk/client-s3";
531
+ async function listRemoteFiles(input) {
532
+ const prefix = normalizeTargetPrefix(input.target);
533
+ const results = [];
534
+ let continuationToken;
535
+ do {
536
+ const command = new ListObjectsV2Command({
537
+ Bucket: input.bucket,
538
+ Prefix: prefix || undefined,
539
+ ContinuationToken: continuationToken
540
+ });
541
+ const response = await input.client.send(command);
542
+ for (const item of response.Contents ?? []) {
543
+ if (!item.Key)
544
+ continue;
545
+ results.push({
546
+ key: item.Key,
547
+ size: item.Size ?? 0,
548
+ etag: stripQuotes(item.ETag ?? "")
549
+ });
550
+ }
551
+ continuationToken = response.NextContinuationToken;
552
+ } while (continuationToken);
553
+ return results;
554
+ }
555
+ function stripQuotes(etag) {
556
+ return etag.replace(/^"+|"+$/g, "");
557
+ }
558
+
559
+ // src/stages/scan.ts
560
+ import { createHash } from "node:crypto";
561
+ import { existsSync as existsSync2, statSync } from "node:fs";
562
+ import { readFile as readFile3 } from "node:fs/promises";
563
+ import { sep } from "node:path";
564
+ import { glob } from "tinyglobby";
565
+ var ALWAYS_IGNORE = ["**/.DS_Store", "**/Thumbs.db", "**/.git/**", "**/*.swp"];
566
+ async function scanLocalFiles(input) {
567
+ if (!existsSync2(input.source) || !statSync(input.source).isDirectory()) {
568
+ throw new Error(`[scan] source directory does not exist: ${input.source}`);
569
+ }
570
+ const ignore = [...ALWAYS_IGNORE, ...input.ignore];
571
+ const paths = await glob(["**/*"], {
572
+ cwd: input.source,
573
+ dot: true,
574
+ onlyFiles: true,
575
+ ignore,
576
+ absolute: true
577
+ });
578
+ const files = await Promise.all(paths.map(async (absPath) => {
579
+ const buf = await readFile3(absPath);
580
+ const hash = createHash("md5").update(buf).digest("hex");
581
+ const rel = absPath.slice(input.source.length).replace(/^[\\/]/, "");
582
+ const key = rel.split(sep).join("/");
583
+ return { key, localPath: absPath, size: buf.byteLength, hash };
584
+ }));
585
+ files.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
586
+ return files;
587
+ }
588
+
589
+ // src/util/glob-match.ts
590
+ function escapeRegex(s) {
591
+ return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
592
+ }
593
+ function globToRegex(pattern) {
594
+ let out = "";
595
+ let i = 0;
596
+ while (i < pattern.length) {
597
+ const c = pattern[i];
598
+ if (c === "*") {
599
+ if (pattern[i + 1] === "*") {
600
+ out += ".*";
601
+ i += 2;
602
+ if (pattern[i] === "/")
603
+ i++;
604
+ } else {
605
+ out += "[^/]*";
606
+ i++;
607
+ }
608
+ } else if (c === "?") {
609
+ out += "[^/]";
610
+ i++;
611
+ } else {
612
+ out += escapeRegex(c);
613
+ i++;
614
+ }
615
+ }
616
+ return new RegExp(`^${out}$`);
617
+ }
618
+ function matchGlob(key, pattern) {
619
+ return globToRegex(pattern).test(key);
620
+ }
621
+
622
+ // src/deploy.ts
623
+ var DEFAULT_RETRY = { attempts: 3, baseMs: 200 };
624
+ var DEFAULT_CONCURRENCY = 10;
625
+ async function deploy(options) {
626
+ const loaded = await loadConfigFile({ cwd: options.cwd, configPath: options.configPath });
627
+ const validation = validateConfig(loaded.config);
628
+ if (!validation.ok) {
629
+ const summary = validation.errors.map((e) => ` - [${e.path || "<root>"}] ${e.message}`).join(`
630
+ `);
631
+ throw new Error(`[config] Invalid config (${loaded.sourcePath}):
632
+ ${summary}`);
633
+ }
634
+ const resolvedConfig = resolveConfig(validation.value, {
635
+ env: options.env,
636
+ profile: options.profile,
637
+ bucket: options.bucket,
638
+ target: options.target,
639
+ source: options.source,
640
+ syncDelete: options.syncDelete,
641
+ envVars: options.envVars
642
+ });
643
+ const s3Factory = options.s3ClientFactory ?? createS3Client;
644
+ const cfFactory = options.cfClientFactory ?? createCloudFrontClient;
645
+ const s3Client = s3Factory({
646
+ region: resolvedConfig.region,
647
+ profile: resolvedConfig.profile
648
+ });
649
+ const cfClient = resolvedConfig.cloudfront ? cfFactory({
650
+ region: resolvedConfig.region,
651
+ profile: resolvedConfig.profile
652
+ }) : undefined;
653
+ const sourcePath = resolveSourcePath(options.cwd, resolvedConfig.source);
654
+ const [localFiles, remoteObjects] = await Promise.all([
655
+ scanLocalFiles({ source: sourcePath, ignore: resolvedConfig.ignore }),
656
+ listRemoteFiles({
657
+ client: s3Client,
658
+ bucket: resolvedConfig.bucket,
659
+ target: resolvedConfig.target
660
+ })
661
+ ]);
662
+ const plan = computeDiff({
663
+ bucket: resolvedConfig.bucket,
664
+ target: resolvedConfig.target,
665
+ localFiles,
666
+ remoteObjects,
667
+ redirects: resolvedConfig.redirects,
668
+ syncDelete: resolvedConfig.syncDelete,
669
+ cloudfront: resolvedConfig.cloudfront
670
+ });
671
+ if (options.dryRun) {
672
+ return { resolvedConfig, plan, dryRun: true, configPath: loaded.sourcePath };
673
+ }
674
+ const cacheControlFor = buildCacheControlMatcher(resolvedConfig.cacheControl);
675
+ const report = await executePlan({
676
+ plan,
677
+ s3Client,
678
+ cfClient,
679
+ cacheControlFor,
680
+ retry: DEFAULT_RETRY,
681
+ concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
682
+ skipInvalidate: options.skipInvalidate
683
+ });
684
+ return { resolvedConfig, plan, report, dryRun: false, configPath: loaded.sourcePath };
685
+ }
686
+ function resolveSourcePath(cwd, source) {
687
+ if (source.startsWith("/"))
688
+ return source;
689
+ return `${cwd}/${source}`;
690
+ }
691
+ function buildCacheControlMatcher(rules) {
692
+ if (rules.length === 0)
693
+ return () => {
694
+ return;
695
+ };
696
+ return (key) => {
697
+ for (const rule of rules) {
698
+ if (matchGlob(key, rule.match))
699
+ return rule.cacheControl;
700
+ }
701
+ return;
702
+ };
703
+ }
704
+ export {
705
+ deploy,
706
+ defineConfig
707
+ };
@@ -0,0 +1,8 @@
1
+ import type { UploadPlan } from './stages/diff.js';
2
+ import type { ExecutionReport } from './stages/execute.js';
3
+ export interface ReportOptions {
4
+ verbose?: boolean;
5
+ }
6
+ export declare function formatPlan(plan: UploadPlan, options?: ReportOptions): string;
7
+ export declare function formatReport(report: ExecutionReport, options?: ReportOptions): string;
8
+ //# sourceMappingURL=reporter.d.ts.map