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/cli.js ADDED
@@ -0,0 +1,1000 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/run.ts
4
+ import { readFileSync } from "node:fs";
5
+ import { dirname, join as join2 } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import cac from "cac";
8
+
9
+ // src/config/load.ts
10
+ import { existsSync } from "node:fs";
11
+ import { readFile } from "node:fs/promises";
12
+ import { extname, isAbsolute, resolve } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import YAML from "yaml";
15
+ var SEARCH_ORDER = [
16
+ "s3-ship.config.ts",
17
+ "s3-ship.config.js",
18
+ "s3-ship.config.mjs",
19
+ "s3-ship.config.cjs",
20
+ "s3-ship.config.json",
21
+ "s3-ship.config.yml",
22
+ "s3-ship.config.yaml"
23
+ ];
24
+ async function loadConfigFile(options) {
25
+ const sourcePath = options.configPath ? resolveExplicitPath(options.configPath, options.cwd) : findConfigInCwd(options.cwd);
26
+ if (!sourcePath) {
27
+ throw new Error(`[config] No s3-ship config file found in ${options.cwd}. Expected one of: ${SEARCH_ORDER.join(", ")}`);
28
+ }
29
+ const config = await readByExtension(sourcePath);
30
+ return { config, sourcePath };
31
+ }
32
+ function resolveExplicitPath(configPath, cwd) {
33
+ const abs = isAbsolute(configPath) ? configPath : resolve(cwd, configPath);
34
+ if (!existsSync(abs)) {
35
+ throw new Error(`[config] Config file not found: ${abs}`);
36
+ }
37
+ return abs;
38
+ }
39
+ function findConfigInCwd(cwd) {
40
+ for (const name of SEARCH_ORDER) {
41
+ const candidate = resolve(cwd, name);
42
+ if (existsSync(candidate)) {
43
+ return candidate;
44
+ }
45
+ }
46
+ return;
47
+ }
48
+ async function readByExtension(path) {
49
+ const ext = extname(path).toLowerCase();
50
+ switch (ext) {
51
+ case ".json":
52
+ return parseJson(path);
53
+ case ".yml":
54
+ case ".yaml":
55
+ return parseYaml(path);
56
+ case ".ts":
57
+ case ".js":
58
+ case ".mjs":
59
+ case ".cjs":
60
+ return importModule(path);
61
+ default:
62
+ throw new Error(`[config] Unsupported config file extension: ${ext}`);
63
+ }
64
+ }
65
+ async function parseJson(path) {
66
+ const raw = await readFile(path, "utf8");
67
+ try {
68
+ return JSON.parse(raw);
69
+ } catch (err) {
70
+ throw new Error(`[config] Failed to parse JSON config at ${path}: ${err.message}`);
71
+ }
72
+ }
73
+ async function parseYaml(path) {
74
+ const raw = await readFile(path, "utf8");
75
+ try {
76
+ const data = YAML.parse(raw);
77
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
78
+ throw new Error("YAML root must be a mapping");
79
+ }
80
+ return data;
81
+ } catch (err) {
82
+ throw new Error(`[config] Failed to parse YAML config at ${path}: ${err.message}`);
83
+ }
84
+ }
85
+ async function importModule(path) {
86
+ const url = `${pathToFileURL(path).href}?t=${Date.now()}`;
87
+ const mod = await import(url);
88
+ if (mod.default == null) {
89
+ throw new Error(`[config] ${path} has no default export. Add: export default { ... } or module.exports = { ... }`);
90
+ }
91
+ if (typeof mod.default !== "object" || Array.isArray(mod.default)) {
92
+ throw new Error(`[config] ${path} default export must be a config object.`);
93
+ }
94
+ return mod.default;
95
+ }
96
+
97
+ // src/config/schema.ts
98
+ import { z } from "zod";
99
+ var redirectRuleSchema = z.object({
100
+ from: z.string().min(1, "redirect.from is required"),
101
+ to: z.string().min(1, "redirect.to is required"),
102
+ statusCode: z.union([z.literal(301), z.literal(302)]).optional()
103
+ });
104
+ var cacheRuleSchema = z.object({
105
+ match: z.string().min(1),
106
+ cacheControl: z.string().min(1)
107
+ });
108
+ var cloudfrontConfigSchema = z.object({
109
+ distributionId: z.string().min(1, "cloudfront.distributionId is required"),
110
+ invalidationPaths: z.array(z.string().min(1)).optional()
111
+ });
112
+ var baseConfigShape = {
113
+ source: z.string().min(1).optional(),
114
+ target: z.string().optional(),
115
+ bucket: z.string().min(1).optional(),
116
+ region: z.string().min(1).optional(),
117
+ profile: z.string().min(1).optional(),
118
+ cloudfront: cloudfrontConfigSchema.optional(),
119
+ syncDelete: z.boolean().optional(),
120
+ ignore: z.array(z.string()).optional(),
121
+ redirects: z.array(redirectRuleSchema).optional(),
122
+ cacheControl: z.array(cacheRuleSchema).optional()
123
+ };
124
+ var environmentConfigSchema = z.object(baseConfigShape).strict();
125
+ var configSchema = z.object({
126
+ ...baseConfigShape,
127
+ environments: z.record(z.string().min(1), environmentConfigSchema).optional()
128
+ }).strict();
129
+ function zodIssuesToErrors(issues) {
130
+ return issues.map((issue) => ({
131
+ path: issue.path.join("."),
132
+ message: issue.message
133
+ }));
134
+ }
135
+ function validateConfig(input) {
136
+ const parsed = configSchema.safeParse(input);
137
+ if (!parsed.success) {
138
+ return { ok: false, errors: zodIssuesToErrors(parsed.error.issues) };
139
+ }
140
+ const value = parsed.data;
141
+ const errors = [];
142
+ const envs = value.environments ?? {};
143
+ const envEntries = Object.entries(envs);
144
+ const topHasBucket = typeof value.bucket === "string" && value.bucket.length > 0;
145
+ if (!topHasBucket) {
146
+ if (envEntries.length === 0) {
147
+ errors.push({
148
+ path: "bucket",
149
+ message: "bucket is required at the top level or inside every environment"
150
+ });
151
+ } else {
152
+ for (const [name, env] of envEntries) {
153
+ const envHasBucket = typeof env.bucket === "string" && env.bucket.length > 0;
154
+ if (!envHasBucket) {
155
+ errors.push({
156
+ path: `environments.${name}.bucket`,
157
+ message: `bucket is required in environment "${name}" because no top-level bucket is set`
158
+ });
159
+ }
160
+ }
161
+ }
162
+ }
163
+ if (errors.length > 0) {
164
+ return { ok: false, errors };
165
+ }
166
+ return { ok: true, value };
167
+ }
168
+
169
+ // src/aws/cloudfront-client.ts
170
+ import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
171
+ import { fromIni } from "@aws-sdk/credential-providers";
172
+ function createCloudFrontClient(options) {
173
+ return new CloudFrontClient({
174
+ region: options.region ?? "us-east-1",
175
+ credentials: options.profile ? fromIni({ profile: options.profile }) : undefined
176
+ });
177
+ }
178
+
179
+ // src/aws/s3-client.ts
180
+ import { S3Client } from "@aws-sdk/client-s3";
181
+ import { fromIni as fromIni2 } from "@aws-sdk/credential-providers";
182
+ function createS3Client(options) {
183
+ return new S3Client({
184
+ region: options.region,
185
+ credentials: options.profile ? fromIni2({ profile: options.profile }) : undefined
186
+ });
187
+ }
188
+ function normalizeTargetPrefix(target) {
189
+ if (!target)
190
+ return "";
191
+ return target.endsWith("/") ? target : `${target}/`;
192
+ }
193
+
194
+ // src/config/merge.ts
195
+ var DEFAULTS = {
196
+ source: "dist",
197
+ target: "",
198
+ syncDelete: false,
199
+ invalidationPaths: ["/*"]
200
+ };
201
+ function pickEnvironment(config, envName) {
202
+ if (!envName)
203
+ return;
204
+ const env = config.environments?.[envName];
205
+ if (!env) {
206
+ const available = Object.keys(config.environments ?? {}).join(", ") || "(none)";
207
+ throw new Error(`[config] environment "${envName}" not found. Available environments: ${available}`);
208
+ }
209
+ return env;
210
+ }
211
+ function resolveConfig(config, overrides) {
212
+ const env = pickEnvironment(config, overrides.env);
213
+ const envVars = overrides.envVars ?? {};
214
+ const layered = {
215
+ ...config,
216
+ ...env ?? {}
217
+ };
218
+ if (env?.cloudfront || config.cloudfront) {
219
+ layered.cloudfront = {
220
+ ...config.cloudfront ?? {},
221
+ ...env?.cloudfront ?? {}
222
+ };
223
+ }
224
+ const bucket = overrides.bucket ?? layered.bucket;
225
+ if (!bucket) {
226
+ throw new Error("[config] bucket is required (top-level, environment, or --bucket flag)");
227
+ }
228
+ const region = layered.region ?? envVars.AWS_REGION;
229
+ const profile = overrides.profile ?? layered.profile ?? envVars.AWS_PROFILE;
230
+ const cloudfront = layered.cloudfront ? {
231
+ distributionId: layered.cloudfront.distributionId,
232
+ invalidationPaths: layered.cloudfront.invalidationPaths ?? DEFAULTS.invalidationPaths
233
+ } : undefined;
234
+ const syncDelete = overrides.syncDelete ?? layered.syncDelete ?? DEFAULTS.syncDelete;
235
+ return {
236
+ source: overrides.source ?? layered.source ?? DEFAULTS.source,
237
+ target: overrides.target ?? layered.target ?? DEFAULTS.target,
238
+ bucket,
239
+ region,
240
+ profile,
241
+ cloudfront,
242
+ syncDelete,
243
+ ignore: layered.ignore ?? [],
244
+ redirects: layered.redirects ?? [],
245
+ cacheControl: layered.cacheControl ?? [],
246
+ environment: overrides.env
247
+ };
248
+ }
249
+
250
+ // src/stages/diff.ts
251
+ function prefixed(prefix, key) {
252
+ return prefix ? `${prefix}${key}` : key;
253
+ }
254
+ function sortByKey(items) {
255
+ return [...items].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
256
+ }
257
+ function computeDiff(input) {
258
+ const prefix = normalizeTargetPrefix(input.target);
259
+ const plannedRedirects = input.redirects.map((r) => ({
260
+ ...r,
261
+ fullKey: prefixed(prefix, r.from)
262
+ }));
263
+ const redirectKeys = new Set(plannedRedirects.map((r) => r.fullKey));
264
+ const localFull = input.localFiles.map((f) => ({ ...f, key: prefixed(prefix, f.key) })).filter((f) => !redirectKeys.has(f.key));
265
+ const localKeys = new Set(localFull.map((f) => f.key));
266
+ const remoteByKey = new Map(input.remoteObjects.map((r) => [r.key, r]));
267
+ const toUpload = [];
268
+ const toUpdate = [];
269
+ const toSkip = [];
270
+ for (const file of localFull) {
271
+ const remote = remoteByKey.get(file.key);
272
+ if (!remote) {
273
+ toUpload.push(file);
274
+ continue;
275
+ }
276
+ if (remote.etag.includes("-")) {
277
+ toUpdate.push(file);
278
+ continue;
279
+ }
280
+ if (remote.etag === file.hash) {
281
+ toSkip.push(file);
282
+ } else {
283
+ toUpdate.push(file);
284
+ }
285
+ }
286
+ let toDelete = [];
287
+ if (input.syncDelete) {
288
+ for (const remote of input.remoteObjects) {
289
+ if (localKeys.has(remote.key))
290
+ continue;
291
+ if (redirectKeys.has(remote.key))
292
+ continue;
293
+ toDelete.push(remote.key);
294
+ }
295
+ toDelete = toDelete.sort();
296
+ }
297
+ return {
298
+ bucket: input.bucket,
299
+ target: input.target,
300
+ toUpload: sortByKey(toUpload),
301
+ toUpdate: sortByKey(toUpdate),
302
+ toSkip: sortByKey(toSkip),
303
+ toDelete,
304
+ redirects: plannedRedirects,
305
+ cloudfront: input.cloudfront
306
+ };
307
+ }
308
+
309
+ // src/util/parallel-limit.ts
310
+ async function parallelLimit(items, limit, worker) {
311
+ const results = new Array(items.length);
312
+ let cursor = 0;
313
+ async function next() {
314
+ while (true) {
315
+ const index = cursor++;
316
+ if (index >= items.length)
317
+ return;
318
+ const item = items[index];
319
+ try {
320
+ const value = await worker(item, index);
321
+ results[index] = { ok: true, value, item };
322
+ } catch (error) {
323
+ results[index] = { ok: false, error, item };
324
+ }
325
+ }
326
+ }
327
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => next());
328
+ await Promise.all(workers);
329
+ return results;
330
+ }
331
+
332
+ // src/stages/delete-stale.ts
333
+ import { DeleteObjectsCommand } from "@aws-sdk/client-s3";
334
+
335
+ // src/util/retry.ts
336
+ async function withRetry(fn, options) {
337
+ let lastErr;
338
+ for (let i = 0;i < options.attempts; i++) {
339
+ try {
340
+ return await fn();
341
+ } catch (err) {
342
+ lastErr = err;
343
+ if (i < options.attempts - 1) {
344
+ await new Promise((resolve2) => setTimeout(resolve2, options.baseMs * 4 ** i));
345
+ }
346
+ }
347
+ }
348
+ throw lastErr;
349
+ }
350
+
351
+ // src/stages/delete-stale.ts
352
+ var MAX_KEYS_PER_BATCH = 1000;
353
+ async function deleteStale(input) {
354
+ if (input.keys.length === 0)
355
+ return;
356
+ for (let i = 0;i < input.keys.length; i += MAX_KEYS_PER_BATCH) {
357
+ const batch = input.keys.slice(i, i + MAX_KEYS_PER_BATCH);
358
+ const command = new DeleteObjectsCommand({
359
+ Bucket: input.bucket,
360
+ Delete: {
361
+ Objects: batch.map((Key) => ({ Key })),
362
+ Quiet: true
363
+ }
364
+ });
365
+ await withRetry(() => input.client.send(command), input.retry);
366
+ }
367
+ }
368
+
369
+ // src/stages/invalidate.ts
370
+ import {
371
+ CreateInvalidationCommand
372
+ } from "@aws-sdk/client-cloudfront";
373
+ async function invalidateCloudFront(input) {
374
+ const callerReference = `s3-ship-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
375
+ const command = new CreateInvalidationCommand({
376
+ DistributionId: input.distributionId,
377
+ InvalidationBatch: {
378
+ CallerReference: callerReference,
379
+ Paths: {
380
+ Quantity: input.paths.length,
381
+ Items: input.paths
382
+ }
383
+ }
384
+ });
385
+ const response = await input.client.send(command);
386
+ return response.Invalidation?.Id;
387
+ }
388
+
389
+ // src/stages/redirects.ts
390
+ import { PutObjectCommand } from "@aws-sdk/client-s3";
391
+ async function putRedirect(input) {
392
+ const command = new PutObjectCommand({
393
+ Bucket: input.bucket,
394
+ Key: input.redirect.fullKey,
395
+ Body: "",
396
+ ContentType: "text/html; charset=utf-8",
397
+ WebsiteRedirectLocation: input.redirect.to,
398
+ ChecksumAlgorithm: "CRC32"
399
+ });
400
+ await withRetry(() => input.client.send(command), input.retry);
401
+ }
402
+
403
+ // src/stages/upload.ts
404
+ import { readFile as readFile2 } from "node:fs/promises";
405
+ import { PutObjectCommand as PutObjectCommand2 } from "@aws-sdk/client-s3";
406
+
407
+ // src/util/content-type.ts
408
+ import { extname as extname2 } from "node:path";
409
+ var MAP = {
410
+ ".html": "text/html; charset=utf-8",
411
+ ".htm": "text/html; charset=utf-8",
412
+ ".css": "text/css; charset=utf-8",
413
+ ".js": "application/javascript; charset=utf-8",
414
+ ".mjs": "application/javascript; charset=utf-8",
415
+ ".json": "application/json; charset=utf-8",
416
+ ".map": "application/json; charset=utf-8",
417
+ ".xml": "application/xml; charset=utf-8",
418
+ ".txt": "text/plain; charset=utf-8",
419
+ ".md": "text/markdown; charset=utf-8",
420
+ ".svg": "image/svg+xml",
421
+ ".png": "image/png",
422
+ ".jpg": "image/jpeg",
423
+ ".jpeg": "image/jpeg",
424
+ ".gif": "image/gif",
425
+ ".webp": "image/webp",
426
+ ".avif": "image/avif",
427
+ ".ico": "image/x-icon",
428
+ ".bmp": "image/bmp",
429
+ ".woff": "font/woff",
430
+ ".woff2": "font/woff2",
431
+ ".ttf": "font/ttf",
432
+ ".otf": "font/otf",
433
+ ".eot": "application/vnd.ms-fontobject",
434
+ ".pdf": "application/pdf",
435
+ ".mp4": "video/mp4",
436
+ ".webm": "video/webm",
437
+ ".mp3": "audio/mpeg",
438
+ ".wav": "audio/wav",
439
+ ".ogg": "audio/ogg",
440
+ ".wasm": "application/wasm",
441
+ ".zip": "application/zip",
442
+ ".gz": "application/gzip"
443
+ };
444
+ function inferContentType(filename) {
445
+ const ext = extname2(filename).toLowerCase();
446
+ return MAP[ext] ?? "application/octet-stream";
447
+ }
448
+
449
+ // src/stages/upload.ts
450
+ async function uploadFile(input) {
451
+ const body = await readFile2(input.file.localPath);
452
+ const contentType = inferContentType(input.file.localPath);
453
+ const cacheControl = input.cacheControlFor(input.file.key);
454
+ const command = new PutObjectCommand2({
455
+ Bucket: input.bucket,
456
+ Key: input.file.key,
457
+ Body: body,
458
+ ContentType: contentType,
459
+ CacheControl: cacheControl,
460
+ ChecksumAlgorithm: "CRC32"
461
+ });
462
+ await withRetry(() => input.client.send(command), input.retry);
463
+ }
464
+
465
+ // src/stages/execute.ts
466
+ async function runUploads(files, input) {
467
+ const results = await parallelLimit(files, input.concurrency, (file) => uploadFile({
468
+ client: input.s3Client,
469
+ bucket: input.plan.bucket,
470
+ file,
471
+ cacheControlFor: input.cacheControlFor,
472
+ retry: input.retry
473
+ }));
474
+ const ok = results.filter((r) => r.ok).length;
475
+ const failures = results.filter((r) => !r.ok).map((r) => ({ key: r.item.key, error: r.error }));
476
+ return { ok, failures };
477
+ }
478
+ async function executePlan(input) {
479
+ const failures = [];
480
+ const uploadResult = await runUploads(input.plan.toUpload, input);
481
+ failures.push(...uploadResult.failures);
482
+ const updateResult = await runUploads(input.plan.toUpdate, input);
483
+ failures.push(...updateResult.failures);
484
+ const redirectResults = await parallelLimit(input.plan.redirects, input.concurrency, (redirect) => putRedirect({
485
+ client: input.s3Client,
486
+ bucket: input.plan.bucket,
487
+ redirect,
488
+ retry: input.retry
489
+ }));
490
+ const redirected = redirectResults.filter((r) => r.ok).length;
491
+ for (const r of redirectResults) {
492
+ if (!r.ok) {
493
+ failures.push({ key: r.item.fullKey, error: r.error });
494
+ }
495
+ }
496
+ let deleted = 0;
497
+ if (input.plan.toDelete.length > 0) {
498
+ try {
499
+ await deleteStale({
500
+ client: input.s3Client,
501
+ bucket: input.plan.bucket,
502
+ keys: input.plan.toDelete,
503
+ retry: input.retry
504
+ });
505
+ deleted = input.plan.toDelete.length;
506
+ } catch (error) {
507
+ failures.push({ key: "<delete-batch>", error });
508
+ }
509
+ }
510
+ let invalidationId;
511
+ if (!input.skipInvalidate && input.plan.cloudfront && input.cfClient) {
512
+ try {
513
+ invalidationId = await invalidateCloudFront({
514
+ client: input.cfClient,
515
+ distributionId: input.plan.cloudfront.distributionId,
516
+ paths: input.plan.cloudfront.invalidationPaths
517
+ });
518
+ } catch (error) {
519
+ failures.push({ key: "<cloudfront-invalidation>", error });
520
+ }
521
+ }
522
+ return {
523
+ uploaded: uploadResult.ok,
524
+ updated: updateResult.ok,
525
+ skipped: input.plan.toSkip.length,
526
+ deleted,
527
+ redirected,
528
+ invalidationId,
529
+ failures
530
+ };
531
+ }
532
+
533
+ // src/stages/list-remote.ts
534
+ import { ListObjectsV2Command } from "@aws-sdk/client-s3";
535
+ async function listRemoteFiles(input) {
536
+ const prefix = normalizeTargetPrefix(input.target);
537
+ const results = [];
538
+ let continuationToken;
539
+ do {
540
+ const command = new ListObjectsV2Command({
541
+ Bucket: input.bucket,
542
+ Prefix: prefix || undefined,
543
+ ContinuationToken: continuationToken
544
+ });
545
+ const response = await input.client.send(command);
546
+ for (const item of response.Contents ?? []) {
547
+ if (!item.Key)
548
+ continue;
549
+ results.push({
550
+ key: item.Key,
551
+ size: item.Size ?? 0,
552
+ etag: stripQuotes(item.ETag ?? "")
553
+ });
554
+ }
555
+ continuationToken = response.NextContinuationToken;
556
+ } while (continuationToken);
557
+ return results;
558
+ }
559
+ function stripQuotes(etag) {
560
+ return etag.replace(/^"+|"+$/g, "");
561
+ }
562
+
563
+ // src/stages/scan.ts
564
+ import { createHash } from "node:crypto";
565
+ import { existsSync as existsSync2, statSync } from "node:fs";
566
+ import { readFile as readFile3 } from "node:fs/promises";
567
+ import { sep } from "node:path";
568
+ import { glob } from "tinyglobby";
569
+ var ALWAYS_IGNORE = ["**/.DS_Store", "**/Thumbs.db", "**/.git/**", "**/*.swp"];
570
+ async function scanLocalFiles(input) {
571
+ if (!existsSync2(input.source) || !statSync(input.source).isDirectory()) {
572
+ throw new Error(`[scan] source directory does not exist: ${input.source}`);
573
+ }
574
+ const ignore = [...ALWAYS_IGNORE, ...input.ignore];
575
+ const paths = await glob(["**/*"], {
576
+ cwd: input.source,
577
+ dot: true,
578
+ onlyFiles: true,
579
+ ignore,
580
+ absolute: true
581
+ });
582
+ const files = await Promise.all(paths.map(async (absPath) => {
583
+ const buf = await readFile3(absPath);
584
+ const hash = createHash("md5").update(buf).digest("hex");
585
+ const rel = absPath.slice(input.source.length).replace(/^[\\/]/, "");
586
+ const key = rel.split(sep).join("/");
587
+ return { key, localPath: absPath, size: buf.byteLength, hash };
588
+ }));
589
+ files.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
590
+ return files;
591
+ }
592
+
593
+ // src/util/glob-match.ts
594
+ function escapeRegex(s) {
595
+ return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
596
+ }
597
+ function globToRegex(pattern) {
598
+ let out = "";
599
+ let i = 0;
600
+ while (i < pattern.length) {
601
+ const c = pattern[i];
602
+ if (c === "*") {
603
+ if (pattern[i + 1] === "*") {
604
+ out += ".*";
605
+ i += 2;
606
+ if (pattern[i] === "/")
607
+ i++;
608
+ } else {
609
+ out += "[^/]*";
610
+ i++;
611
+ }
612
+ } else if (c === "?") {
613
+ out += "[^/]";
614
+ i++;
615
+ } else {
616
+ out += escapeRegex(c);
617
+ i++;
618
+ }
619
+ }
620
+ return new RegExp(`^${out}$`);
621
+ }
622
+ function matchGlob(key, pattern) {
623
+ return globToRegex(pattern).test(key);
624
+ }
625
+
626
+ // src/deploy.ts
627
+ var DEFAULT_RETRY = { attempts: 3, baseMs: 200 };
628
+ var DEFAULT_CONCURRENCY = 10;
629
+ async function deploy(options) {
630
+ const loaded = await loadConfigFile({ cwd: options.cwd, configPath: options.configPath });
631
+ const validation = validateConfig(loaded.config);
632
+ if (!validation.ok) {
633
+ const summary = validation.errors.map((e) => ` - [${e.path || "<root>"}] ${e.message}`).join(`
634
+ `);
635
+ throw new Error(`[config] Invalid config (${loaded.sourcePath}):
636
+ ${summary}`);
637
+ }
638
+ const resolvedConfig = resolveConfig(validation.value, {
639
+ env: options.env,
640
+ profile: options.profile,
641
+ bucket: options.bucket,
642
+ target: options.target,
643
+ source: options.source,
644
+ syncDelete: options.syncDelete,
645
+ envVars: options.envVars
646
+ });
647
+ const s3Factory = options.s3ClientFactory ?? createS3Client;
648
+ const cfFactory = options.cfClientFactory ?? createCloudFrontClient;
649
+ const s3Client = s3Factory({
650
+ region: resolvedConfig.region,
651
+ profile: resolvedConfig.profile
652
+ });
653
+ const cfClient = resolvedConfig.cloudfront ? cfFactory({
654
+ region: resolvedConfig.region,
655
+ profile: resolvedConfig.profile
656
+ }) : undefined;
657
+ const sourcePath = resolveSourcePath(options.cwd, resolvedConfig.source);
658
+ const [localFiles, remoteObjects] = await Promise.all([
659
+ scanLocalFiles({ source: sourcePath, ignore: resolvedConfig.ignore }),
660
+ listRemoteFiles({
661
+ client: s3Client,
662
+ bucket: resolvedConfig.bucket,
663
+ target: resolvedConfig.target
664
+ })
665
+ ]);
666
+ const plan = computeDiff({
667
+ bucket: resolvedConfig.bucket,
668
+ target: resolvedConfig.target,
669
+ localFiles,
670
+ remoteObjects,
671
+ redirects: resolvedConfig.redirects,
672
+ syncDelete: resolvedConfig.syncDelete,
673
+ cloudfront: resolvedConfig.cloudfront
674
+ });
675
+ if (options.dryRun) {
676
+ return { resolvedConfig, plan, dryRun: true, configPath: loaded.sourcePath };
677
+ }
678
+ const cacheControlFor = buildCacheControlMatcher(resolvedConfig.cacheControl);
679
+ const report = await executePlan({
680
+ plan,
681
+ s3Client,
682
+ cfClient,
683
+ cacheControlFor,
684
+ retry: DEFAULT_RETRY,
685
+ concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
686
+ skipInvalidate: options.skipInvalidate
687
+ });
688
+ return { resolvedConfig, plan, report, dryRun: false, configPath: loaded.sourcePath };
689
+ }
690
+ function resolveSourcePath(cwd, source) {
691
+ if (source.startsWith("/"))
692
+ return source;
693
+ return `${cwd}/${source}`;
694
+ }
695
+ function buildCacheControlMatcher(rules) {
696
+ if (rules.length === 0)
697
+ return () => {
698
+ return;
699
+ };
700
+ return (key) => {
701
+ for (const rule of rules) {
702
+ if (matchGlob(key, rule.match))
703
+ return rule.cacheControl;
704
+ }
705
+ return;
706
+ };
707
+ }
708
+
709
+ // src/reporter.ts
710
+ var MAX_LISTED = 20;
711
+ function formatBytes(n) {
712
+ if (n < 1024)
713
+ return `${n} B`;
714
+ if (n < 1024 * 1024)
715
+ return `${(n / 1024).toFixed(1)} KB`;
716
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
717
+ }
718
+ function totalSize(files) {
719
+ return files.reduce((acc, f) => acc + f.size, 0);
720
+ }
721
+ function listSection(label, items, verbose) {
722
+ if (items.length === 0)
723
+ return "";
724
+ const limit = verbose ? items.length : MAX_LISTED;
725
+ const shown = items.slice(0, limit);
726
+ const overflow = items.length - shown.length;
727
+ const lines = [` ${label}:`, ...shown.map((k) => ` + ${k}`)];
728
+ if (overflow > 0)
729
+ lines.push(` … and ${overflow} more`);
730
+ return `${lines.join(`
731
+ `)}
732
+ `;
733
+ }
734
+ function fileLine(f, verbose) {
735
+ return verbose ? `${f.key} (${formatBytes(f.size)})` : f.key;
736
+ }
737
+ function formatPlan(plan, options = {}) {
738
+ const verbose = options.verbose === true;
739
+ const total = plan.toUpload.length + plan.toUpdate.length + plan.toDelete.length + plan.redirects.length;
740
+ const lines = [];
741
+ const targetLabel = plan.target || "(bucket root)";
742
+ lines.push(`Plan for s3://${plan.bucket}/${plan.target ? plan.target : ""}`);
743
+ lines.push(` target: ${targetLabel}`);
744
+ if (total === 0 && plan.toSkip.length === 0) {
745
+ lines.push(" no changes");
746
+ } else if (total === 0) {
747
+ lines.push(` no changes (${plan.toSkip.length} files already in sync)`);
748
+ } else {
749
+ lines.push(` upload: ${plan.toUpload.length} (${formatBytes(totalSize(plan.toUpload))})`);
750
+ lines.push(` update: ${plan.toUpdate.length} (${formatBytes(totalSize(plan.toUpdate))})`);
751
+ lines.push(` skip: ${plan.toSkip.length}`);
752
+ lines.push(` delete: ${plan.toDelete.length}`);
753
+ lines.push(` redirect: ${plan.redirects.length}`);
754
+ }
755
+ if (plan.cloudfront) {
756
+ lines.push(` cloudfront: ${plan.cloudfront.distributionId} (${plan.cloudfront.invalidationPaths.join(", ")})`);
757
+ }
758
+ let out = `${lines.join(`
759
+ `)}
760
+ `;
761
+ out += listSection("upload", plan.toUpload.map((f) => fileLine(f, verbose)), verbose);
762
+ out += listSection("update", plan.toUpdate.map((f) => fileLine(f, verbose)), verbose);
763
+ out += listSection("delete", plan.toDelete, verbose);
764
+ out += listSection("redirect", plan.redirects.map((r) => `${r.fullKey} -> ${r.to}`), verbose);
765
+ if (verbose) {
766
+ out += listSection("skip", plan.toSkip.map((f) => f.key), verbose);
767
+ }
768
+ return out;
769
+ }
770
+ function formatReport(report, options = {}) {
771
+ const verbose = options.verbose === true;
772
+ const lines = ["Deploy complete"];
773
+ lines.push(` uploaded: ${report.uploaded}`);
774
+ lines.push(` updated: ${report.updated}`);
775
+ lines.push(` skipped: ${report.skipped}`);
776
+ lines.push(` deleted: ${report.deleted}`);
777
+ lines.push(` redirected: ${report.redirected}`);
778
+ if (report.invalidationId) {
779
+ lines.push(` invalidation: ${report.invalidationId}`);
780
+ }
781
+ if (report.failures.length > 0) {
782
+ lines.push(` failures: ${report.failures.length}`);
783
+ const limit = verbose ? report.failures.length : MAX_LISTED;
784
+ for (const f of report.failures.slice(0, limit)) {
785
+ const msg = f.error instanceof Error ? f.error.message : String(f.error);
786
+ lines.push(` ! ${f.key}: ${msg}`);
787
+ }
788
+ if (report.failures.length > limit) {
789
+ lines.push(` … and ${report.failures.length - limit} more`);
790
+ }
791
+ }
792
+ return `${lines.join(`
793
+ `)}
794
+ `;
795
+ }
796
+
797
+ // src/cli/init.ts
798
+ import { existsSync as existsSync3 } from "node:fs";
799
+ import { writeFile } from "node:fs/promises";
800
+ import { join } from "node:path";
801
+ var TEMPLATES = {
802
+ ts: {
803
+ filename: "s3-ship.config.ts",
804
+ content: `import { defineConfig } from 's3-ship'
805
+
806
+ export default defineConfig({
807
+ source: 'dist',
808
+ bucket: 'my-bucket-name',
809
+ region: 'us-east-1',
810
+
811
+ // Uncomment to invalidate CloudFront after every deploy
812
+ // cloudfront: { distributionId: 'EXXXXXXX' },
813
+
814
+ // Uncomment to delete S3 keys not present locally (bounded to target prefix)
815
+ // syncDelete: true,
816
+
817
+ // redirects: [
818
+ // { from: 'old.html', to: '/new.html' },
819
+ // ],
820
+
821
+ // cacheControl: [
822
+ // { match: 'assets/**', cacheControl: 'public, max-age=31536000, immutable' },
823
+ // { match: '*.html', cacheControl: 'public, max-age=0, must-revalidate' },
824
+ // ],
825
+
826
+ // environments: {
827
+ // production: { bucket: 'prod-bucket', cloudfront: { distributionId: 'EPROD' } },
828
+ // staging: { bucket: 'staging-bucket' },
829
+ // },
830
+ })
831
+ `
832
+ },
833
+ js: {
834
+ filename: "s3-ship.config.js",
835
+ content: `/** @type {import('s3-ship').Config} */
836
+ export default {
837
+ source: 'dist',
838
+ bucket: 'my-bucket-name',
839
+ region: 'us-east-1',
840
+ }
841
+ `
842
+ },
843
+ mjs: {
844
+ filename: "s3-ship.config.mjs",
845
+ content: `/** @type {import('s3-ship').Config} */
846
+ export default {
847
+ source: 'dist',
848
+ bucket: 'my-bucket-name',
849
+ region: 'us-east-1',
850
+ }
851
+ `
852
+ },
853
+ json: {
854
+ filename: "s3-ship.config.json",
855
+ content: `${JSON.stringify({
856
+ source: "dist",
857
+ bucket: "my-bucket-name",
858
+ region: "us-east-1"
859
+ }, null, 2)}
860
+ `
861
+ },
862
+ yml: {
863
+ filename: "s3-ship.config.yml",
864
+ content: `source: dist
865
+ bucket: my-bucket-name
866
+ region: us-east-1
867
+ # cloudfront:
868
+ # distributionId: EXXXXXXX
869
+ # syncDelete: true
870
+ # redirects:
871
+ # - { from: old.html, to: /new.html }
872
+ # environments:
873
+ # production:
874
+ # bucket: prod-bucket
875
+ # staging:
876
+ # bucket: staging-bucket
877
+ `
878
+ },
879
+ yaml: {
880
+ filename: "s3-ship.config.yaml",
881
+ content: `source: dist
882
+ bucket: my-bucket-name
883
+ region: us-east-1
884
+ `
885
+ }
886
+ };
887
+ async function initConfig(options) {
888
+ const format = options.format ?? "ts";
889
+ const template = TEMPLATES[format];
890
+ if (!template) {
891
+ throw new Error(`[init] Unsupported format "${format}". Use one of: ${Object.keys(TEMPLATES).join(", ")}`);
892
+ }
893
+ const path = join(options.cwd, template.filename);
894
+ if (existsSync3(path)) {
895
+ throw new Error(`[init] ${template.filename} already exists at ${path}`);
896
+ }
897
+ await writeFile(path, template.content, "utf8");
898
+ return { path, format };
899
+ }
900
+
901
+ // src/cli/run.ts
902
+ function readVersion() {
903
+ try {
904
+ const here = dirname(fileURLToPath(import.meta.url));
905
+ const candidates = [join2(here, "..", "..", "package.json"), join2(here, "..", "package.json")];
906
+ for (const c of candidates) {
907
+ try {
908
+ const raw = readFileSync(c, "utf8");
909
+ const pkg = JSON.parse(raw);
910
+ if (pkg.name === "s3-ship" && pkg.version)
911
+ return pkg.version;
912
+ } catch {}
913
+ }
914
+ } catch {}
915
+ return "0.0.0";
916
+ }
917
+ async function runCli(argv, options = {}) {
918
+ const cli = cac("s3-ship");
919
+ const deployFn = options.deployFn ?? deploy;
920
+ cli.command("deploy", "Deploy the local source directory to S3").option("--env <name>", "Environment name from config.environments").option("--profile <name>", "Override AWS profile").option("--bucket <name>", "Override target bucket").option("--target <prefix>", "Override target prefix").option("--source <dir>", "Override local source directory").option("--dry-run", "Show plan without executing").option("--sync-delete", "Delete remote keys absent locally (bounded to target). Pass --no-sync-delete to disable.").option("--no-invalidate", "Skip CloudFront invalidation").option("--config <path>", "Path to config file").option("--verbose", "Verbose output").action(async (flags) => {
921
+ const result = await deployFn({
922
+ cwd: process.cwd(),
923
+ envVars: process.env,
924
+ env: flags.env,
925
+ configPath: flags.config,
926
+ profile: flags.profile,
927
+ bucket: flags.bucket,
928
+ target: flags.target,
929
+ source: flags.source,
930
+ dryRun: flags.dryRun,
931
+ syncDelete: flags.syncDelete,
932
+ skipInvalidate: flags.invalidate === false
933
+ });
934
+ const verbose = flags.verbose === true;
935
+ process.stdout.write(formatPlan(result.plan, { verbose }));
936
+ if (result.dryRun) {
937
+ process.stdout.write(`
938
+ (dry-run; no changes applied)
939
+ `);
940
+ return;
941
+ }
942
+ if (result.report) {
943
+ process.stdout.write(`
944
+ ${formatReport(result.report, { verbose })}`);
945
+ if (result.report.failures.length > 0) {
946
+ process.exitCode = 2;
947
+ }
948
+ }
949
+ });
950
+ cli.command("init", "Create a starter s3-ship config file").option("--format <fmt>", "Config format: ts | js | mjs | json | yml | yaml", {
951
+ default: "ts"
952
+ }).action(async (flags) => {
953
+ const result = await initConfig({
954
+ cwd: process.cwd(),
955
+ format: flags.format ?? "ts"
956
+ });
957
+ process.stdout.write(`Created ${result.path}
958
+ `);
959
+ });
960
+ cli.command("validate", "Validate the config file without contacting AWS").option("--config <path>", "Path to config file").action(async (flags) => {
961
+ const loaded = await loadConfigFile({
962
+ cwd: process.cwd(),
963
+ configPath: flags.config
964
+ });
965
+ const result = validateConfig(loaded.config);
966
+ if (!result.ok) {
967
+ process.stderr.write(`[config] Invalid config (${loaded.sourcePath}):
968
+ `);
969
+ for (const err of result.errors) {
970
+ process.stderr.write(` - [${err.path || "<root>"}] ${err.message}
971
+ `);
972
+ }
973
+ process.exitCode = 1;
974
+ return;
975
+ }
976
+ process.stdout.write(`OK ${loaded.sourcePath}
977
+ `);
978
+ });
979
+ cli.help();
980
+ cli.version(readVersion());
981
+ try {
982
+ cli.parse(argv, { run: false });
983
+ await cli.runMatchedCommand();
984
+ const code = process.exitCode;
985
+ return typeof code === "number" ? code : code ? 1 : 0;
986
+ } catch (err) {
987
+ const message = err instanceof Error ? err.message : String(err);
988
+ process.stderr.write(`${message}
989
+ `);
990
+ if (/\[config\]/.test(message))
991
+ return 1;
992
+ return 2;
993
+ }
994
+ }
995
+
996
+ // src/cli.ts
997
+ runCli(process.argv).catch((err) => {
998
+ console.error(err);
999
+ process.exit(1);
1000
+ });