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.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/aws/cloudfront-client.d.ts +10 -0
- package/dist/aws/cloudfront-client.d.ts.map +1 -0
- package/dist/aws/s3-client.d.ts +11 -0
- package/dist/aws/s3-client.d.ts.map +1 -0
- package/dist/cli/init.d.ts +11 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1000 -0
- package/dist/config/define.d.ts +3 -0
- package/dist/config/define.d.ts.map +1 -0
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/merge.d.ts +28 -0
- package/dist/config/merge.d.ts.map +1 -0
- package/dist/config/schema.d.ts +348 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/deploy.d.ts +32 -0
- package/dist/deploy.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +707 -0
- package/dist/reporter.d.ts +8 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/stages/delete-stale.d.ts +10 -0
- package/dist/stages/delete-stale.d.ts.map +1 -0
- package/dist/stages/diff.d.ts +34 -0
- package/dist/stages/diff.d.ts.map +1 -0
- package/dist/stages/execute.d.ts +28 -0
- package/dist/stages/execute.d.ts.map +1 -0
- package/dist/stages/invalidate.d.ts +8 -0
- package/dist/stages/invalidate.d.ts.map +1 -0
- package/dist/stages/list-remote.d.ts +13 -0
- package/dist/stages/list-remote.d.ts.map +1 -0
- package/dist/stages/redirects.d.ts +11 -0
- package/dist/stages/redirects.d.ts.map +1 -0
- package/dist/stages/scan.d.ts +12 -0
- package/dist/stages/scan.d.ts.map +1 -0
- package/dist/stages/upload.d.ts +12 -0
- package/dist/stages/upload.d.ts.map +1 -0
- package/dist/util/content-type.d.ts +2 -0
- package/dist/util/content-type.d.ts.map +1 -0
- package/dist/util/glob-match.d.ts +3 -0
- package/dist/util/glob-match.d.ts.map +1 -0
- package/dist/util/parallel-limit.d.ts +10 -0
- package/dist/util/parallel-limit.d.ts.map +1 -0
- package/dist/util/retry.d.ts +6 -0
- package/dist/util/retry.d.ts.map +1 -0
- 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
|
+
});
|