@maydotinc/s3-sync 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/README.md +234 -0
- package/dist/index.js +726 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# @maydotinc/s3-sync
|
|
2
|
+
|
|
3
|
+
A tool that syncs one or more local directories to S3-compatible storage, designed for GitHub Actions workflows.
|
|
4
|
+
|
|
5
|
+
It performs deterministic diffs (local file hash vs remote object ETag), uploads only changed files, and optionally deletes stale remote files.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why use this
|
|
10
|
+
|
|
11
|
+
- Sync static assets to S3 (or S3-compatible providers) on every push
|
|
12
|
+
- Support monorepos with one or many sync targets
|
|
13
|
+
- Avoid re-uploading unchanged files
|
|
14
|
+
- Optional Slack/Discord deployment notifications
|
|
15
|
+
- Config is validated with Zod for safer runtime behavior
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Node.js 22+
|
|
22
|
+
- A GitHub repository with Actions enabled
|
|
23
|
+
- S3 credentials with permissions for listing/uploading (and deleting if enabled)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
### 1) Configure your first target
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx @maydotinc/s3-sync <directory> sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx @maydotinc/s3-sync cdn sync --bucket my-assets --region us-east-1 --prefix assets --delete
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This creates/updates:
|
|
42
|
+
|
|
43
|
+
- `s3-sync.json`
|
|
44
|
+
- `.github/workflows/s3-sync.yml`
|
|
45
|
+
|
|
46
|
+
### 2) Add GitHub Actions secrets
|
|
47
|
+
|
|
48
|
+
Required:
|
|
49
|
+
|
|
50
|
+
- `S3_ACCESS_KEY_ID`
|
|
51
|
+
- `S3_SECRET_ACCESS_KEY`
|
|
52
|
+
|
|
53
|
+
Optional (only if notifications are enabled):
|
|
54
|
+
|
|
55
|
+
- `SLACK_WEBHOOK_URL`
|
|
56
|
+
- `DISCORD_WEBHOOK_URL`
|
|
57
|
+
|
|
58
|
+
### 3) Commit and push
|
|
59
|
+
|
|
60
|
+
Push to your configured branch and the workflow will run automatically.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Command usage
|
|
65
|
+
|
|
66
|
+
### Setup / add target
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx @maydotinc/s3-sync <directory> sync [options]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
|
|
74
|
+
- `--bucket <name>` S3 bucket name
|
|
75
|
+
- `--region <region>` AWS region
|
|
76
|
+
- `--endpoint <url>` Custom S3-compatible endpoint for this target
|
|
77
|
+
- `--prefix <prefix>` Remote key prefix (folder path in bucket)
|
|
78
|
+
- `--branch <branch>` Branch to trigger workflow
|
|
79
|
+
- `--delete` Delete remote files not present locally
|
|
80
|
+
- `--no-delete` Keep remote-only files
|
|
81
|
+
- `--slack` Enable Slack notifications
|
|
82
|
+
- `--discord` Enable Discord notifications
|
|
83
|
+
|
|
84
|
+
### Run sync manually
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx @maydotinc/s3-sync sync
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This is what GitHub Actions runs internally.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Monorepo patterns
|
|
95
|
+
|
|
96
|
+
### Single consolidated folder
|
|
97
|
+
|
|
98
|
+
If your repo builds everything into one folder (for example `cdn/`), configure one target:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx @maydotinc/s3-sync cdn sync --bucket my-bucket
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Multiple folders
|
|
105
|
+
|
|
106
|
+
Run setup once per folder:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx @maydotinc/s3-sync apps/web/public sync --bucket my-bucket --prefix web
|
|
110
|
+
npx @maydotinc/s3-sync apps/docs/public sync --bucket my-bucket --prefix docs
|
|
111
|
+
npx @maydotinc/s3-sync apps/admin/dist sync --bucket my-bucket --prefix admin
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
All targets are stored in a single `s3-sync.json`.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## How sync works
|
|
119
|
+
|
|
120
|
+
For each target:
|
|
121
|
+
|
|
122
|
+
1. Scan local files and compute MD5 hash per file
|
|
123
|
+
2. List remote objects in the configured bucket + prefix
|
|
124
|
+
3. Build a diff:
|
|
125
|
+
- upload when key is missing remotely
|
|
126
|
+
- upload when local hash != remote ETag
|
|
127
|
+
- unchanged when local hash == remote ETag
|
|
128
|
+
4. Optionally delete remote keys missing locally (`delete: true`)
|
|
129
|
+
|
|
130
|
+
No GitHub cache is required for correctness. Every run compares local files against live remote object metadata.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Endpoint behavior (important)
|
|
135
|
+
|
|
136
|
+
Per-target endpoint support is fully supported using `--endpoint` or `target.endpoint` in config.
|
|
137
|
+
|
|
138
|
+
Current resolution behavior:
|
|
139
|
+
|
|
140
|
+
- If a target has its own endpoint, that endpoint is used
|
|
141
|
+
- A global `S3_ENDPOINT` env value is only used when none of the targets define an endpoint
|
|
142
|
+
|
|
143
|
+
This prevents mixed setups (AWS + custom endpoint targets) from accidentally routing all targets to the same endpoint.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Generated configuration
|
|
148
|
+
|
|
149
|
+
`s3-sync.json` structure:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"targets": [
|
|
154
|
+
{
|
|
155
|
+
"directory": "cdn",
|
|
156
|
+
"bucket": "my-assets",
|
|
157
|
+
"region": "us-east-1",
|
|
158
|
+
"endpoint": "",
|
|
159
|
+
"prefix": "assets",
|
|
160
|
+
"delete": true
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
"branch": "main",
|
|
164
|
+
"notifications": {
|
|
165
|
+
"slack": false,
|
|
166
|
+
"discord": false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Config is validated with Zod on read/write. Invalid config shape fails fast with useful field-level error output.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Workflow details
|
|
176
|
+
|
|
177
|
+
Generated file: `.github/workflows/s3-sync.yml`
|
|
178
|
+
|
|
179
|
+
Key behavior:
|
|
180
|
+
|
|
181
|
+
- Triggered on your configured branch
|
|
182
|
+
- Triggered only when files under configured target directories change
|
|
183
|
+
- Runs `npx --yes <package>@<version> sync`
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## FAQs
|
|
188
|
+
|
|
189
|
+
### Does it upload unchanged files every run?
|
|
190
|
+
|
|
191
|
+
No. Unchanged files are skipped based on hash/ETag comparison.
|
|
192
|
+
|
|
193
|
+
### If I update `assets/logo.png`, will it upload the new version?
|
|
194
|
+
|
|
195
|
+
Yes. Same key + changed content = new upload (overwrite).
|
|
196
|
+
|
|
197
|
+
### Does it scan my whole bucket?
|
|
198
|
+
|
|
199
|
+
No, unless your prefix is empty. It lists objects scoped to each target's configured prefix.
|
|
200
|
+
|
|
201
|
+
### Can I use `assets/` instead of `public/`?
|
|
202
|
+
|
|
203
|
+
Yes. Any directory path is supported.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Troubleshooting
|
|
208
|
+
|
|
209
|
+
### `Missing S3_ACCESS_KEY_ID or S3_SECRET_ACCESS_KEY`
|
|
210
|
+
|
|
211
|
+
Add required secrets to GitHub repository settings:
|
|
212
|
+
`Settings -> Secrets and variables -> Actions`.
|
|
213
|
+
|
|
214
|
+
### `Directory "<name>" does not exist`
|
|
215
|
+
|
|
216
|
+
Ensure the target directory exists at workflow runtime (after build steps, if applicable).
|
|
217
|
+
|
|
218
|
+
### Sync runs but uploads unexpectedly many files
|
|
219
|
+
|
|
220
|
+
Check:
|
|
221
|
+
|
|
222
|
+
- prefix configuration changed
|
|
223
|
+
- files were rebuilt with different content hashes
|
|
224
|
+
- remote objects were modified outside this tool
|
|
225
|
+
|
|
226
|
+
### Invalid config error
|
|
227
|
+
|
|
228
|
+
Open `s3-sync.json` and fix fields listed in the validation error message.
|
|
229
|
+
|
|
230
|
+
## Security notes
|
|
231
|
+
|
|
232
|
+
- Use least-privilege IAM credentials for your target bucket/prefix.
|
|
233
|
+
- If delete is enabled, credentials must allow delete operations.
|
|
234
|
+
- Store credentials only in GitHub secrets.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/setup.ts
|
|
7
|
+
import { mkdir, writeFile as writeFile2 } from "fs/promises";
|
|
8
|
+
import { existsSync as existsSync3 } from "fs";
|
|
9
|
+
import path3 from "path";
|
|
10
|
+
import inquirer from "inquirer";
|
|
11
|
+
|
|
12
|
+
// src/utils/config.ts
|
|
13
|
+
import { readFile, writeFile } from "fs/promises";
|
|
14
|
+
import { existsSync } from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
var CONFIG_FILE = "s3-sync.json";
|
|
18
|
+
var syncTargetSchema = z.object({
|
|
19
|
+
directory: z.string().min(1),
|
|
20
|
+
bucket: z.string().min(1),
|
|
21
|
+
region: z.string().min(1),
|
|
22
|
+
endpoint: z.string().default(""),
|
|
23
|
+
prefix: z.string().default(""),
|
|
24
|
+
delete: z.boolean().default(false)
|
|
25
|
+
});
|
|
26
|
+
var notificationsSchema = z.object({
|
|
27
|
+
slack: z.boolean().default(false),
|
|
28
|
+
discord: z.boolean().default(false)
|
|
29
|
+
});
|
|
30
|
+
var configSchema = z.object({
|
|
31
|
+
targets: z.array(syncTargetSchema).default([]),
|
|
32
|
+
branch: z.string().min(1).default("main"),
|
|
33
|
+
notifications: notificationsSchema.default({ slack: false, discord: false })
|
|
34
|
+
});
|
|
35
|
+
var legacyConfigSchema = z.object({
|
|
36
|
+
directory: z.string().min(1),
|
|
37
|
+
bucket: z.string().min(1),
|
|
38
|
+
region: z.string().min(1),
|
|
39
|
+
endpoint: z.string().default(""),
|
|
40
|
+
prefix: z.string().default(""),
|
|
41
|
+
branch: z.string().min(1).default("main"),
|
|
42
|
+
delete: z.boolean().default(false),
|
|
43
|
+
notifications: notificationsSchema.default({ slack: false, discord: false })
|
|
44
|
+
});
|
|
45
|
+
function getConfigPath(cwd = process.cwd()) {
|
|
46
|
+
return path.join(cwd, CONFIG_FILE);
|
|
47
|
+
}
|
|
48
|
+
function configExists(cwd = process.cwd()) {
|
|
49
|
+
return existsSync(getConfigPath(cwd));
|
|
50
|
+
}
|
|
51
|
+
async function readConfig(cwd = process.cwd()) {
|
|
52
|
+
const configPath = getConfigPath(cwd);
|
|
53
|
+
const raw = JSON.parse(await readFile(configPath, "utf-8"));
|
|
54
|
+
const parsedConfig = configSchema.safeParse(raw);
|
|
55
|
+
if (parsedConfig.success) return parsedConfig.data;
|
|
56
|
+
const parsedLegacyConfig = legacyConfigSchema.safeParse(raw);
|
|
57
|
+
if (!parsedLegacyConfig.success) {
|
|
58
|
+
const issues = parsedConfig.error.issues.map((issue) => {
|
|
59
|
+
const path6 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
60
|
+
return `${path6}: ${issue.message}`;
|
|
61
|
+
}).join("; ");
|
|
62
|
+
throw new Error(`Invalid ${CONFIG_FILE}: ${issues}`);
|
|
63
|
+
}
|
|
64
|
+
const legacy = parsedLegacyConfig.data;
|
|
65
|
+
return {
|
|
66
|
+
targets: [
|
|
67
|
+
{
|
|
68
|
+
directory: legacy.directory,
|
|
69
|
+
bucket: legacy.bucket,
|
|
70
|
+
region: legacy.region,
|
|
71
|
+
endpoint: legacy.endpoint,
|
|
72
|
+
prefix: legacy.prefix,
|
|
73
|
+
delete: legacy.delete
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
branch: legacy.branch,
|
|
77
|
+
notifications: legacy.notifications
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async function writeConfig(config, cwd = process.cwd()) {
|
|
81
|
+
const configPath = getConfigPath(cwd);
|
|
82
|
+
const validatedConfig = configSchema.parse(config);
|
|
83
|
+
await writeFile(
|
|
84
|
+
configPath,
|
|
85
|
+
JSON.stringify(validatedConfig, null, 2) + "\n",
|
|
86
|
+
"utf-8"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
function defaultTarget() {
|
|
90
|
+
return {
|
|
91
|
+
directory: "public",
|
|
92
|
+
bucket: "",
|
|
93
|
+
region: "us-east-1",
|
|
94
|
+
endpoint: "",
|
|
95
|
+
prefix: "",
|
|
96
|
+
delete: false
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function defaultConfig() {
|
|
100
|
+
return {
|
|
101
|
+
targets: [],
|
|
102
|
+
branch: "main",
|
|
103
|
+
notifications: { slack: false, discord: false }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/workflow/generator.ts
|
|
108
|
+
function generateWorkflow(config, packageSpecifier) {
|
|
109
|
+
const envBlock = buildEnvBlock(config);
|
|
110
|
+
const pathFilters = config.targets.map((t) => ` - '${t.directory}/**'`).join("\n");
|
|
111
|
+
return `name: S3 Sync
|
|
112
|
+
|
|
113
|
+
on:
|
|
114
|
+
push:
|
|
115
|
+
branches: [${config.branch}]
|
|
116
|
+
paths:
|
|
117
|
+
${pathFilters}
|
|
118
|
+
|
|
119
|
+
jobs:
|
|
120
|
+
sync:
|
|
121
|
+
name: Sync to S3
|
|
122
|
+
runs-on: ubuntu-latest
|
|
123
|
+
|
|
124
|
+
steps:
|
|
125
|
+
- name: Checkout
|
|
126
|
+
uses: actions/checkout@v4
|
|
127
|
+
|
|
128
|
+
- name: Setup Node
|
|
129
|
+
uses: actions/setup-node@v4
|
|
130
|
+
with:
|
|
131
|
+
node-version: "22"
|
|
132
|
+
|
|
133
|
+
- name: Sync files
|
|
134
|
+
run: npx --yes ${packageSpecifier} sync
|
|
135
|
+
${envBlock}
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
function buildEnvBlock(config) {
|
|
139
|
+
const lines = [
|
|
140
|
+
" env:",
|
|
141
|
+
" S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}",
|
|
142
|
+
" S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}"
|
|
143
|
+
];
|
|
144
|
+
if (config.notifications.slack) {
|
|
145
|
+
lines.push(
|
|
146
|
+
" SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}"
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (config.notifications.discord) {
|
|
150
|
+
lines.push(
|
|
151
|
+
" DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/utils/logger.ts
|
|
158
|
+
import chalk from "chalk";
|
|
159
|
+
var log = {
|
|
160
|
+
info: (msg) => console.log(chalk.blue("\u2139"), msg),
|
|
161
|
+
success: (msg) => console.log(chalk.green("\u2713"), msg),
|
|
162
|
+
warn: (msg) => console.log(chalk.yellow("\u26A0"), msg),
|
|
163
|
+
error: (msg) => console.error(chalk.red("\u2717"), msg),
|
|
164
|
+
dim: (msg) => console.log(chalk.dim(msg)),
|
|
165
|
+
heading: (msg) => console.log(chalk.bold.cyan(`
|
|
166
|
+
${msg}
|
|
167
|
+
`))
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// src/utils/package-meta.ts
|
|
171
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
172
|
+
import path2 from "path";
|
|
173
|
+
import { fileURLToPath } from "url";
|
|
174
|
+
function findPackageJson(startDirectory) {
|
|
175
|
+
let currentDirectory = startDirectory;
|
|
176
|
+
while (true) {
|
|
177
|
+
const candidate = path2.join(currentDirectory, "package.json");
|
|
178
|
+
if (existsSync2(candidate)) return candidate;
|
|
179
|
+
const parentDirectory = path2.dirname(currentDirectory);
|
|
180
|
+
if (parentDirectory === currentDirectory) return void 0;
|
|
181
|
+
currentDirectory = parentDirectory;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function getCurrentPackageSpecifier(fallbackName = "@maydotinc/s3-sync") {
|
|
185
|
+
const currentFileDirectory = path2.dirname(fileURLToPath(import.meta.url));
|
|
186
|
+
const packageJsonPath = findPackageJson(currentFileDirectory);
|
|
187
|
+
if (!packageJsonPath) return fallbackName;
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
190
|
+
const packageName = parsed.name?.trim() || fallbackName;
|
|
191
|
+
const packageVersion = parsed.version?.trim();
|
|
192
|
+
return packageVersion ? `${packageName}@${packageVersion}` : packageName;
|
|
193
|
+
} catch {
|
|
194
|
+
return fallbackName;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/commands/setup.ts
|
|
199
|
+
async function setupCommand(directory, flags) {
|
|
200
|
+
log.heading("s3-sync setup");
|
|
201
|
+
let config;
|
|
202
|
+
let isAdding = false;
|
|
203
|
+
if (configExists()) {
|
|
204
|
+
const existing = await readConfig();
|
|
205
|
+
const alreadyHasDir = existing.targets.some(
|
|
206
|
+
(t) => t.directory === directory
|
|
207
|
+
);
|
|
208
|
+
if (alreadyHasDir) {
|
|
209
|
+
const { overwrite } = await inquirer.prompt([
|
|
210
|
+
{
|
|
211
|
+
type: "confirm",
|
|
212
|
+
name: "overwrite",
|
|
213
|
+
message: `Target "${directory}" already exists. Overwrite it?`,
|
|
214
|
+
default: false
|
|
215
|
+
}
|
|
216
|
+
]);
|
|
217
|
+
if (!overwrite) {
|
|
218
|
+
log.info("Setup cancelled.");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
existing.targets = existing.targets.filter(
|
|
222
|
+
(t) => t.directory !== directory
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
log.info(
|
|
226
|
+
`Existing config found with ${existing.targets.length} target(s). Adding "${directory}".`
|
|
227
|
+
);
|
|
228
|
+
isAdding = true;
|
|
229
|
+
}
|
|
230
|
+
config = existing;
|
|
231
|
+
} else {
|
|
232
|
+
config = defaultConfig();
|
|
233
|
+
}
|
|
234
|
+
const dirPath = path3.resolve(directory);
|
|
235
|
+
if (!existsSync3(dirPath)) {
|
|
236
|
+
log.warn(
|
|
237
|
+
`Directory "${directory}" doesn't exist yet \u2014 that's okay, it will be created later.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const target = await gatherTarget(directory, flags);
|
|
241
|
+
config.targets.push(target);
|
|
242
|
+
if (!isAdding) {
|
|
243
|
+
await gatherSharedConfig(config, flags);
|
|
244
|
+
}
|
|
245
|
+
await writeConfig(config);
|
|
246
|
+
log.success("Updated s3-sync.json");
|
|
247
|
+
const workflowDir = path3.join(process.cwd(), ".github", "workflows");
|
|
248
|
+
await mkdir(workflowDir, { recursive: true });
|
|
249
|
+
const workflowPath = path3.join(workflowDir, "s3-sync.yml");
|
|
250
|
+
const packageSpecifier = getCurrentPackageSpecifier();
|
|
251
|
+
const workflowContent = generateWorkflow(config, packageSpecifier);
|
|
252
|
+
await writeFile2(workflowPath, workflowContent, "utf-8");
|
|
253
|
+
log.success("Updated .github/workflows/s3-sync.yml");
|
|
254
|
+
printSecretInstructions(config);
|
|
255
|
+
}
|
|
256
|
+
async function gatherTarget(directory, flags) {
|
|
257
|
+
const target = defaultTarget();
|
|
258
|
+
target.directory = directory;
|
|
259
|
+
const questions = [];
|
|
260
|
+
if (flags.bucket === void 0) {
|
|
261
|
+
questions.push({
|
|
262
|
+
type: "input",
|
|
263
|
+
name: "bucket",
|
|
264
|
+
message: `[${directory}] S3 bucket name:`,
|
|
265
|
+
validate: (v) => v.trim() ? true : "Bucket name is required"
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
target.bucket = flags.bucket;
|
|
269
|
+
}
|
|
270
|
+
if (flags.region === void 0) {
|
|
271
|
+
questions.push({
|
|
272
|
+
type: "input",
|
|
273
|
+
name: "region",
|
|
274
|
+
message: `[${directory}] AWS region:`,
|
|
275
|
+
default: "us-east-1"
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
target.region = flags.region;
|
|
279
|
+
}
|
|
280
|
+
if (flags.endpoint === void 0) {
|
|
281
|
+
questions.push({
|
|
282
|
+
type: "input",
|
|
283
|
+
name: "endpoint",
|
|
284
|
+
message: `[${directory}] Custom S3 endpoint (leave blank for AWS):`,
|
|
285
|
+
default: ""
|
|
286
|
+
});
|
|
287
|
+
} else {
|
|
288
|
+
target.endpoint = flags.endpoint;
|
|
289
|
+
}
|
|
290
|
+
if (flags.prefix === void 0) {
|
|
291
|
+
questions.push({
|
|
292
|
+
type: "input",
|
|
293
|
+
name: "prefix",
|
|
294
|
+
message: `[${directory}] Path prefix (leave blank for root):`,
|
|
295
|
+
default: ""
|
|
296
|
+
});
|
|
297
|
+
} else {
|
|
298
|
+
target.prefix = flags.prefix;
|
|
299
|
+
}
|
|
300
|
+
if (flags.delete === void 0) {
|
|
301
|
+
questions.push({
|
|
302
|
+
type: "confirm",
|
|
303
|
+
name: "delete",
|
|
304
|
+
message: `[${directory}] Delete files from S3 that no longer exist locally?`,
|
|
305
|
+
default: false
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
target.delete = flags.delete;
|
|
309
|
+
}
|
|
310
|
+
if (questions.length > 0) {
|
|
311
|
+
const answers = await inquirer.prompt(questions);
|
|
312
|
+
if (answers.bucket !== void 0) target.bucket = answers.bucket;
|
|
313
|
+
if (answers.region !== void 0) target.region = answers.region;
|
|
314
|
+
if (answers.endpoint !== void 0) target.endpoint = answers.endpoint;
|
|
315
|
+
if (answers.prefix !== void 0) target.prefix = answers.prefix;
|
|
316
|
+
if (answers.delete !== void 0) target.delete = answers.delete;
|
|
317
|
+
}
|
|
318
|
+
return target;
|
|
319
|
+
}
|
|
320
|
+
async function gatherSharedConfig(config, flags) {
|
|
321
|
+
const questions = [];
|
|
322
|
+
if (flags.branch === void 0) {
|
|
323
|
+
questions.push({
|
|
324
|
+
type: "input",
|
|
325
|
+
name: "branch",
|
|
326
|
+
message: "Branch to trigger sync on:",
|
|
327
|
+
default: "main"
|
|
328
|
+
});
|
|
329
|
+
} else {
|
|
330
|
+
config.branch = flags.branch;
|
|
331
|
+
}
|
|
332
|
+
if (flags.slack === void 0) {
|
|
333
|
+
questions.push({
|
|
334
|
+
type: "confirm",
|
|
335
|
+
name: "slack",
|
|
336
|
+
message: "Enable Slack notifications?",
|
|
337
|
+
default: false
|
|
338
|
+
});
|
|
339
|
+
} else {
|
|
340
|
+
config.notifications.slack = flags.slack;
|
|
341
|
+
}
|
|
342
|
+
if (flags.discord === void 0) {
|
|
343
|
+
questions.push({
|
|
344
|
+
type: "confirm",
|
|
345
|
+
name: "discord",
|
|
346
|
+
message: "Enable Discord notifications?",
|
|
347
|
+
default: false
|
|
348
|
+
});
|
|
349
|
+
} else {
|
|
350
|
+
config.notifications.discord = flags.discord;
|
|
351
|
+
}
|
|
352
|
+
if (questions.length > 0) {
|
|
353
|
+
const answers = await inquirer.prompt(questions);
|
|
354
|
+
if (answers.branch !== void 0) config.branch = answers.branch;
|
|
355
|
+
if (answers.slack !== void 0) config.notifications.slack = answers.slack;
|
|
356
|
+
if (answers.discord !== void 0)
|
|
357
|
+
config.notifications.discord = answers.discord;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function printSecretInstructions(config) {
|
|
361
|
+
log.heading("Next steps");
|
|
362
|
+
log.info("Add these secrets to your GitHub repository:");
|
|
363
|
+
log.dim(
|
|
364
|
+
" Settings \u2192 Secrets and variables \u2192 Actions \u2192 New repository secret\n"
|
|
365
|
+
);
|
|
366
|
+
const secrets = [
|
|
367
|
+
["S3_ACCESS_KEY_ID", "Your S3 access key"],
|
|
368
|
+
["S3_SECRET_ACCESS_KEY", "Your S3 secret key"]
|
|
369
|
+
];
|
|
370
|
+
if (config.notifications.slack) {
|
|
371
|
+
secrets.push(["SLACK_WEBHOOK_URL", "Slack incoming webhook URL"]);
|
|
372
|
+
}
|
|
373
|
+
if (config.notifications.discord) {
|
|
374
|
+
secrets.push(["DISCORD_WEBHOOK_URL", "Discord webhook URL"]);
|
|
375
|
+
}
|
|
376
|
+
for (const [name, desc] of secrets) {
|
|
377
|
+
log.dim(` \u2022 ${name} \u2014 ${desc}`);
|
|
378
|
+
}
|
|
379
|
+
console.log();
|
|
380
|
+
log.heading("Configured targets");
|
|
381
|
+
for (const t of config.targets) {
|
|
382
|
+
log.info(` ${t.directory}/ \u2192 s3://${t.bucket}/${t.prefix}`);
|
|
383
|
+
}
|
|
384
|
+
console.log();
|
|
385
|
+
log.success(
|
|
386
|
+
`Push to "${config.branch}" to trigger a sync.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/commands/sync.ts
|
|
391
|
+
import { existsSync as existsSync4 } from "fs";
|
|
392
|
+
import path5 from "path";
|
|
393
|
+
|
|
394
|
+
// src/core/fingerprint.ts
|
|
395
|
+
import { createHash } from "crypto";
|
|
396
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
397
|
+
import path4 from "path";
|
|
398
|
+
import { glob } from "glob";
|
|
399
|
+
async function fingerprintDirectory(directory) {
|
|
400
|
+
const absoluteDir = path4.resolve(directory);
|
|
401
|
+
const files = await glob("**/*", {
|
|
402
|
+
cwd: absoluteDir,
|
|
403
|
+
nodir: true,
|
|
404
|
+
dot: false
|
|
405
|
+
});
|
|
406
|
+
const results = [];
|
|
407
|
+
await Promise.all(
|
|
408
|
+
files.map(async (relativePath) => {
|
|
409
|
+
const absolutePath = path4.join(absoluteDir, relativePath);
|
|
410
|
+
const content = await readFile2(absolutePath);
|
|
411
|
+
const md5 = createHash("md5").update(content).digest("hex");
|
|
412
|
+
results.push({
|
|
413
|
+
relativePath: relativePath.replace(/\\/g, "/"),
|
|
414
|
+
absolutePath,
|
|
415
|
+
md5,
|
|
416
|
+
size: content.length
|
|
417
|
+
});
|
|
418
|
+
})
|
|
419
|
+
);
|
|
420
|
+
return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/core/s3-client.ts
|
|
424
|
+
import {
|
|
425
|
+
S3Client,
|
|
426
|
+
ListObjectsV2Command,
|
|
427
|
+
PutObjectCommand,
|
|
428
|
+
DeleteObjectCommand
|
|
429
|
+
} from "@aws-sdk/client-s3";
|
|
430
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
431
|
+
import mime from "mime-types";
|
|
432
|
+
function createS3(config) {
|
|
433
|
+
return new S3Client({
|
|
434
|
+
region: config.region,
|
|
435
|
+
credentials: {
|
|
436
|
+
accessKeyId: config.accessKeyId,
|
|
437
|
+
secretAccessKey: config.secretAccessKey
|
|
438
|
+
},
|
|
439
|
+
...config.endpoint && {
|
|
440
|
+
endpoint: config.endpoint,
|
|
441
|
+
forcePathStyle: true
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async function listAllObjects(client, bucket, prefix) {
|
|
446
|
+
const objects = [];
|
|
447
|
+
let continuationToken;
|
|
448
|
+
do {
|
|
449
|
+
const command = new ListObjectsV2Command({
|
|
450
|
+
Bucket: bucket,
|
|
451
|
+
Prefix: prefix || void 0,
|
|
452
|
+
ContinuationToken: continuationToken
|
|
453
|
+
});
|
|
454
|
+
const response = await client.send(command);
|
|
455
|
+
if (response.Contents) {
|
|
456
|
+
for (const obj of response.Contents) {
|
|
457
|
+
if (obj.Key && obj.ETag && obj.Size !== void 0) {
|
|
458
|
+
objects.push({
|
|
459
|
+
key: obj.Key,
|
|
460
|
+
etag: obj.ETag.replace(/"/g, ""),
|
|
461
|
+
size: obj.Size
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
|
|
467
|
+
} while (continuationToken);
|
|
468
|
+
return objects;
|
|
469
|
+
}
|
|
470
|
+
async function uploadFile(client, bucket, key, filePath) {
|
|
471
|
+
const body = await readFile3(filePath);
|
|
472
|
+
const contentType = mime.lookup(filePath) || "application/octet-stream";
|
|
473
|
+
await client.send(
|
|
474
|
+
new PutObjectCommand({
|
|
475
|
+
Bucket: bucket,
|
|
476
|
+
Key: key,
|
|
477
|
+
Body: body,
|
|
478
|
+
ContentType: contentType
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
async function deleteObject(client, bucket, key) {
|
|
483
|
+
await client.send(
|
|
484
|
+
new DeleteObjectCommand({
|
|
485
|
+
Bucket: bucket,
|
|
486
|
+
Key: key
|
|
487
|
+
})
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/core/diff.ts
|
|
492
|
+
function computeDiff(localFiles, remoteObjects, prefix, shouldDelete) {
|
|
493
|
+
const remoteByKey = /* @__PURE__ */ new Map();
|
|
494
|
+
for (const obj of remoteObjects) {
|
|
495
|
+
remoteByKey.set(obj.key, obj);
|
|
496
|
+
}
|
|
497
|
+
const toUpload = [];
|
|
498
|
+
const unchanged = [];
|
|
499
|
+
const localKeys = /* @__PURE__ */ new Set();
|
|
500
|
+
for (const file of localFiles) {
|
|
501
|
+
const key = prefix ? `${prefix}/${file.relativePath}` : file.relativePath;
|
|
502
|
+
localKeys.add(key);
|
|
503
|
+
const remote = remoteByKey.get(key);
|
|
504
|
+
if (!remote || remote.etag !== file.md5) {
|
|
505
|
+
toUpload.push(file);
|
|
506
|
+
} else {
|
|
507
|
+
unchanged.push(file);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const toDelete = [];
|
|
511
|
+
if (shouldDelete) {
|
|
512
|
+
for (const obj of remoteObjects) {
|
|
513
|
+
if (!localKeys.has(obj.key)) {
|
|
514
|
+
toDelete.push(obj);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return { toUpload, toDelete, unchanged };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/notifications/slack.ts
|
|
522
|
+
async function sendSlackNotification(webhookUrl, message) {
|
|
523
|
+
const response = await fetch(webhookUrl, {
|
|
524
|
+
method: "POST",
|
|
525
|
+
headers: { "Content-Type": "application/json" },
|
|
526
|
+
body: JSON.stringify({ text: message })
|
|
527
|
+
});
|
|
528
|
+
if (!response.ok) {
|
|
529
|
+
throw new Error(`Slack notification failed: ${response.statusText}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/notifications/discord.ts
|
|
534
|
+
async function sendDiscordNotification(webhookUrl, message) {
|
|
535
|
+
const response = await fetch(webhookUrl, {
|
|
536
|
+
method: "POST",
|
|
537
|
+
headers: { "Content-Type": "application/json" },
|
|
538
|
+
body: JSON.stringify({ content: message })
|
|
539
|
+
});
|
|
540
|
+
if (!response.ok) {
|
|
541
|
+
throw new Error(`Discord notification failed: ${response.statusText}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/commands/sync.ts
|
|
546
|
+
async function syncCommand() {
|
|
547
|
+
log.heading("s3-sync");
|
|
548
|
+
const config = await readConfig();
|
|
549
|
+
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
|
|
550
|
+
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
|
|
551
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
552
|
+
log.error(
|
|
553
|
+
"Missing S3_ACCESS_KEY_ID or S3_SECRET_ACCESS_KEY environment variables."
|
|
554
|
+
);
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
if (config.targets.length === 0) {
|
|
558
|
+
log.error("No sync targets configured. Run setup first.");
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
const summaries = [];
|
|
562
|
+
let hasErrors = false;
|
|
563
|
+
const globalEndpoint = process.env.S3_ENDPOINT?.trim() || void 0;
|
|
564
|
+
const hasTargetSpecificEndpoints = config.targets.some(
|
|
565
|
+
(target) => target.endpoint.trim().length > 0
|
|
566
|
+
);
|
|
567
|
+
for (const target of config.targets) {
|
|
568
|
+
try {
|
|
569
|
+
const endpoint = resolveEndpoint(
|
|
570
|
+
target.endpoint,
|
|
571
|
+
globalEndpoint,
|
|
572
|
+
hasTargetSpecificEndpoints
|
|
573
|
+
);
|
|
574
|
+
const diff = await syncTarget(
|
|
575
|
+
target,
|
|
576
|
+
accessKeyId,
|
|
577
|
+
secretAccessKey,
|
|
578
|
+
endpoint
|
|
579
|
+
);
|
|
580
|
+
summaries.push(buildTargetSummary(target, diff));
|
|
581
|
+
} catch (err) {
|
|
582
|
+
log.error(`[${target.directory}] ${err.message}`);
|
|
583
|
+
hasErrors = true;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (summaries.length > 0) {
|
|
587
|
+
const fullSummary = summaries.join("\n\n---\n\n");
|
|
588
|
+
await notify(config, fullSummary);
|
|
589
|
+
}
|
|
590
|
+
if (hasErrors) {
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
log.success("All targets synced.");
|
|
594
|
+
}
|
|
595
|
+
async function syncTarget(target, accessKeyId, secretAccessKey, endpoint) {
|
|
596
|
+
log.heading(`Syncing ${target.directory}/ \u2192 s3://${target.bucket}/${target.prefix}`);
|
|
597
|
+
const directory = path5.resolve(target.directory);
|
|
598
|
+
if (!existsSync4(directory)) {
|
|
599
|
+
throw new Error(`Directory "${target.directory}" does not exist.`);
|
|
600
|
+
}
|
|
601
|
+
const client = createS3({
|
|
602
|
+
bucket: target.bucket,
|
|
603
|
+
region: target.region,
|
|
604
|
+
endpoint,
|
|
605
|
+
accessKeyId,
|
|
606
|
+
secretAccessKey
|
|
607
|
+
});
|
|
608
|
+
log.info(`Scanning ${target.directory}/...`);
|
|
609
|
+
const localFiles = await fingerprintDirectory(directory);
|
|
610
|
+
log.info(`Found ${localFiles.length} local files`);
|
|
611
|
+
log.info(`Listing objects in s3://${target.bucket}/${target.prefix}...`);
|
|
612
|
+
const remoteObjects = await listAllObjects(
|
|
613
|
+
client,
|
|
614
|
+
target.bucket,
|
|
615
|
+
target.prefix
|
|
616
|
+
);
|
|
617
|
+
log.info(`Found ${remoteObjects.length} remote objects`);
|
|
618
|
+
const diff = computeDiff(
|
|
619
|
+
localFiles,
|
|
620
|
+
remoteObjects,
|
|
621
|
+
target.prefix,
|
|
622
|
+
target.delete
|
|
623
|
+
);
|
|
624
|
+
log.info(
|
|
625
|
+
`${diff.toUpload.length} to upload, ${diff.toDelete.length} to delete, ${diff.unchanged.length} unchanged`
|
|
626
|
+
);
|
|
627
|
+
if (diff.toUpload.length === 0 && diff.toDelete.length === 0) {
|
|
628
|
+
log.success(`[${target.directory}] Already in sync.`);
|
|
629
|
+
return diff;
|
|
630
|
+
}
|
|
631
|
+
for (const file of diff.toUpload) {
|
|
632
|
+
const key = target.prefix ? `${target.prefix}/${file.relativePath}` : file.relativePath;
|
|
633
|
+
log.dim(` uploading ${key}`);
|
|
634
|
+
await uploadFile(client, target.bucket, key, file.absolutePath);
|
|
635
|
+
}
|
|
636
|
+
if (diff.toUpload.length > 0) {
|
|
637
|
+
log.success(`[${target.directory}] Uploaded ${diff.toUpload.length} files`);
|
|
638
|
+
}
|
|
639
|
+
for (const obj of diff.toDelete) {
|
|
640
|
+
log.dim(` deleting ${obj.key}`);
|
|
641
|
+
await deleteObject(client, target.bucket, obj.key);
|
|
642
|
+
}
|
|
643
|
+
if (diff.toDelete.length > 0) {
|
|
644
|
+
log.success(`[${target.directory}] Deleted ${diff.toDelete.length} files`);
|
|
645
|
+
}
|
|
646
|
+
return diff;
|
|
647
|
+
}
|
|
648
|
+
function resolveEndpoint(targetEndpoint, globalEndpoint, hasTargetSpecificEndpoints) {
|
|
649
|
+
const trimmedTargetEndpoint = targetEndpoint.trim();
|
|
650
|
+
if (trimmedTargetEndpoint) return trimmedTargetEndpoint;
|
|
651
|
+
if (!hasTargetSpecificEndpoints) return globalEndpoint;
|
|
652
|
+
return void 0;
|
|
653
|
+
}
|
|
654
|
+
function buildTargetSummary(target, diff) {
|
|
655
|
+
const lines = [
|
|
656
|
+
`*${target.directory}/ \u2192 s3://${target.bucket}/${target.prefix}*`,
|
|
657
|
+
`Uploaded: ${diff.toUpload.length}`,
|
|
658
|
+
`Deleted: ${diff.toDelete.length}`,
|
|
659
|
+
`Unchanged: ${diff.unchanged.length}`
|
|
660
|
+
];
|
|
661
|
+
if (diff.toUpload.length > 0) {
|
|
662
|
+
const fileList = diff.toUpload.slice(0, 10).map((f) => ` \u2022 ${f.relativePath}`).join("\n");
|
|
663
|
+
lines.push(`
|
|
664
|
+
Uploaded files:
|
|
665
|
+
${fileList}`);
|
|
666
|
+
if (diff.toUpload.length > 10) {
|
|
667
|
+
lines.push(` ...and ${diff.toUpload.length - 10} more`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return lines.join("\n");
|
|
671
|
+
}
|
|
672
|
+
async function notify(config, message) {
|
|
673
|
+
if (config.notifications?.slack) {
|
|
674
|
+
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
|
|
675
|
+
if (webhookUrl) {
|
|
676
|
+
try {
|
|
677
|
+
await sendSlackNotification(webhookUrl, message);
|
|
678
|
+
log.success("Slack notification sent");
|
|
679
|
+
} catch (err) {
|
|
680
|
+
log.warn(`Slack notification failed: ${err.message}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (config.notifications?.discord) {
|
|
685
|
+
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
|
|
686
|
+
if (webhookUrl) {
|
|
687
|
+
try {
|
|
688
|
+
await sendDiscordNotification(webhookUrl, message);
|
|
689
|
+
log.success("Discord notification sent");
|
|
690
|
+
} catch (err) {
|
|
691
|
+
log.warn(`Discord notification failed: ${err.message}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/index.ts
|
|
698
|
+
var program = new Command();
|
|
699
|
+
program.name("s3-sync").description("Sync local directories to S3-compatible buckets via GitHub Actions").version("0.1.0");
|
|
700
|
+
program.command("sync").description("Execute sync for all configured targets").action(async () => {
|
|
701
|
+
try {
|
|
702
|
+
await syncCommand();
|
|
703
|
+
} catch (err) {
|
|
704
|
+
console.error(err.message);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
program.argument("<directory>", "Local directory to sync (e.g. public, apps/web/dist)").argument("sync", "Generate workflow and config for syncing this directory").option("--bucket <name>", "S3 bucket name").option("--region <region>", "AWS region").option("--endpoint <url>", "Custom S3-compatible endpoint").option("--prefix <prefix>", "Path prefix in bucket").option("--branch <branch>", "Git branch to trigger on").option("--delete", "Delete remote files not present locally").option("--no-delete", "Keep remote files not present locally").option("--slack", "Enable Slack notifications").option("--discord", "Enable Discord notifications").action(async (directory, _syncArg, options) => {
|
|
709
|
+
try {
|
|
710
|
+
await setupCommand(directory, {
|
|
711
|
+
bucket: options.bucket,
|
|
712
|
+
region: options.region,
|
|
713
|
+
endpoint: options.endpoint,
|
|
714
|
+
prefix: options.prefix,
|
|
715
|
+
branch: options.branch,
|
|
716
|
+
delete: options.delete === true ? true : options.delete === false ? false : void 0,
|
|
717
|
+
slack: options.slack,
|
|
718
|
+
discord: options.discord
|
|
719
|
+
});
|
|
720
|
+
} catch (err) {
|
|
721
|
+
console.error(err.message);
|
|
722
|
+
process.exit(1);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
program.parse();
|
|
726
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/commands/setup.ts","../src/utils/config.ts","../src/workflow/generator.ts","../src/utils/logger.ts","../src/utils/package-meta.ts","../src/commands/sync.ts","../src/core/fingerprint.ts","../src/core/s3-client.ts","../src/core/diff.ts","../src/notifications/slack.ts","../src/notifications/discord.ts"],"sourcesContent":["import { Command } from \"commander\";\nimport { setupCommand } from \"./commands/setup.js\";\nimport { syncCommand } from \"./commands/sync.js\";\n\nconst program = new Command();\n\nprogram\n .name(\"s3-sync\")\n .description(\"Sync local directories to S3-compatible buckets via GitHub Actions\")\n .version(\"0.1.0\");\n\nprogram\n .command(\"sync\")\n .description(\"Execute sync for all configured targets\")\n .action(async () => {\n try {\n await syncCommand();\n } catch (err: any) {\n console.error(err.message);\n process.exit(1);\n }\n });\n\nprogram\n .argument(\"<directory>\", \"Local directory to sync (e.g. public, apps/web/dist)\")\n .argument(\"sync\", \"Generate workflow and config for syncing this directory\")\n .option(\"--bucket <name>\", \"S3 bucket name\")\n .option(\"--region <region>\", \"AWS region\")\n .option(\"--endpoint <url>\", \"Custom S3-compatible endpoint\")\n .option(\"--prefix <prefix>\", \"Path prefix in bucket\")\n .option(\"--branch <branch>\", \"Git branch to trigger on\")\n .option(\"--delete\", \"Delete remote files not present locally\")\n .option(\"--no-delete\", \"Keep remote files not present locally\")\n .option(\"--slack\", \"Enable Slack notifications\")\n .option(\"--discord\", \"Enable Discord notifications\")\n .action(async (directory: string, _syncArg: string, options: any) => {\n try {\n await setupCommand(directory, {\n bucket: options.bucket,\n region: options.region,\n endpoint: options.endpoint,\n prefix: options.prefix,\n branch: options.branch,\n delete: options.delete === true ? true : options.delete === false ? false : undefined,\n slack: options.slack,\n discord: options.discord,\n });\n } catch (err: any) {\n console.error(err.message);\n process.exit(1);\n }\n });\n\nprogram.parse();\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport inquirer from \"inquirer\";\nimport {\n defaultConfig,\n defaultTarget,\n writeConfig,\n readConfig,\n configExists,\n type S3SyncConfig,\n type SyncTarget,\n} from \"../utils/config.js\";\nimport { generateWorkflow } from \"../workflow/generator.js\";\nimport { log } from \"../utils/logger.js\";\nimport { getCurrentPackageSpecifier } from \"../utils/package-meta.js\";\n\ninterface SetupFlags {\n bucket?: string;\n region?: string;\n endpoint?: string;\n prefix?: string;\n branch?: string;\n delete?: boolean;\n slack?: boolean;\n discord?: boolean;\n}\n\nexport async function setupCommand(\n directory: string,\n flags: SetupFlags\n): Promise<void> {\n log.heading(\"s3-sync setup\");\n\n let config: S3SyncConfig;\n let isAdding = false;\n\n if (configExists()) {\n const existing = await readConfig();\n const alreadyHasDir = existing.targets.some(\n (t) => t.directory === directory\n );\n\n if (alreadyHasDir) {\n const { overwrite } = await inquirer.prompt([\n {\n type: \"confirm\",\n name: \"overwrite\",\n message: `Target \"${directory}\" already exists. Overwrite it?`,\n default: false,\n },\n ]);\n if (!overwrite) {\n log.info(\"Setup cancelled.\");\n return;\n }\n existing.targets = existing.targets.filter(\n (t) => t.directory !== directory\n );\n } else {\n log.info(\n `Existing config found with ${existing.targets.length} target(s). Adding \"${directory}\".`\n );\n isAdding = true;\n }\n config = existing;\n } else {\n config = defaultConfig();\n }\n\n const dirPath = path.resolve(directory);\n if (!existsSync(dirPath)) {\n log.warn(\n `Directory \"${directory}\" doesn't exist yet — that's okay, it will be created later.`\n );\n }\n\n const target = await gatherTarget(directory, flags);\n config.targets.push(target);\n\n if (!isAdding) {\n await gatherSharedConfig(config, flags);\n }\n\n await writeConfig(config);\n log.success(\"Updated s3-sync.json\");\n\n const workflowDir = path.join(process.cwd(), \".github\", \"workflows\");\n await mkdir(workflowDir, { recursive: true });\n\n const workflowPath = path.join(workflowDir, \"s3-sync.yml\");\n const packageSpecifier = getCurrentPackageSpecifier();\n const workflowContent = generateWorkflow(config, packageSpecifier);\n await writeFile(workflowPath, workflowContent, \"utf-8\");\n log.success(\"Updated .github/workflows/s3-sync.yml\");\n\n printSecretInstructions(config);\n}\n\nasync function gatherTarget(\n directory: string,\n flags: SetupFlags\n): Promise<SyncTarget> {\n const target = defaultTarget();\n target.directory = directory;\n\n const questions: any[] = [];\n\n if (flags.bucket === undefined) {\n questions.push({\n type: \"input\",\n name: \"bucket\",\n message: `[${directory}] S3 bucket name:`,\n validate: (v: string) => (v.trim() ? true : \"Bucket name is required\"),\n });\n } else {\n target.bucket = flags.bucket;\n }\n\n if (flags.region === undefined) {\n questions.push({\n type: \"input\",\n name: \"region\",\n message: `[${directory}] AWS region:`,\n default: \"us-east-1\",\n });\n } else {\n target.region = flags.region;\n }\n\n if (flags.endpoint === undefined) {\n questions.push({\n type: \"input\",\n name: \"endpoint\",\n message: `[${directory}] Custom S3 endpoint (leave blank for AWS):`,\n default: \"\",\n });\n } else {\n target.endpoint = flags.endpoint;\n }\n\n if (flags.prefix === undefined) {\n questions.push({\n type: \"input\",\n name: \"prefix\",\n message: `[${directory}] Path prefix (leave blank for root):`,\n default: \"\",\n });\n } else {\n target.prefix = flags.prefix;\n }\n\n if (flags.delete === undefined) {\n questions.push({\n type: \"confirm\",\n name: \"delete\",\n message: `[${directory}] Delete files from S3 that no longer exist locally?`,\n default: false,\n });\n } else {\n target.delete = flags.delete;\n }\n\n if (questions.length > 0) {\n const answers = await inquirer.prompt(questions);\n if (answers.bucket !== undefined) target.bucket = answers.bucket;\n if (answers.region !== undefined) target.region = answers.region;\n if (answers.endpoint !== undefined) target.endpoint = answers.endpoint;\n if (answers.prefix !== undefined) target.prefix = answers.prefix;\n if (answers.delete !== undefined) target.delete = answers.delete;\n }\n\n return target;\n}\n\nasync function gatherSharedConfig(\n config: S3SyncConfig,\n flags: SetupFlags\n): Promise<void> {\n const questions: any[] = [];\n\n if (flags.branch === undefined) {\n questions.push({\n type: \"input\",\n name: \"branch\",\n message: \"Branch to trigger sync on:\",\n default: \"main\",\n });\n } else {\n config.branch = flags.branch;\n }\n\n if (flags.slack === undefined) {\n questions.push({\n type: \"confirm\",\n name: \"slack\",\n message: \"Enable Slack notifications?\",\n default: false,\n });\n } else {\n config.notifications.slack = flags.slack;\n }\n\n if (flags.discord === undefined) {\n questions.push({\n type: \"confirm\",\n name: \"discord\",\n message: \"Enable Discord notifications?\",\n default: false,\n });\n } else {\n config.notifications.discord = flags.discord;\n }\n\n if (questions.length > 0) {\n const answers = await inquirer.prompt(questions);\n if (answers.branch !== undefined) config.branch = answers.branch;\n if (answers.slack !== undefined) config.notifications.slack = answers.slack;\n if (answers.discord !== undefined)\n config.notifications.discord = answers.discord;\n }\n}\n\nfunction printSecretInstructions(config: S3SyncConfig): void {\n log.heading(\"Next steps\");\n log.info(\"Add these secrets to your GitHub repository:\");\n log.dim(\n \" Settings → Secrets and variables → Actions → New repository secret\\n\"\n );\n\n const secrets = [\n [\"S3_ACCESS_KEY_ID\", \"Your S3 access key\"],\n [\"S3_SECRET_ACCESS_KEY\", \"Your S3 secret key\"],\n ];\n\n if (config.notifications.slack) {\n secrets.push([\"SLACK_WEBHOOK_URL\", \"Slack incoming webhook URL\"]);\n }\n\n if (config.notifications.discord) {\n secrets.push([\"DISCORD_WEBHOOK_URL\", \"Discord webhook URL\"]);\n }\n\n for (const [name, desc] of secrets) {\n log.dim(` • ${name} — ${desc}`);\n }\n\n console.log();\n log.heading(\"Configured targets\");\n for (const t of config.targets) {\n log.info(` ${t.directory}/ → s3://${t.bucket}/${t.prefix}`);\n }\n\n console.log();\n log.success(\n `Push to \"${config.branch}\" to trigger a sync.`\n );\n}\n","import { readFile, writeFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { z } from \"zod\";\n\nexport interface SyncTarget {\n directory: string;\n bucket: string;\n region: string;\n endpoint: string;\n prefix: string;\n delete: boolean;\n}\n\nexport interface S3SyncConfig {\n targets: SyncTarget[];\n branch: string;\n notifications: {\n slack: boolean;\n discord: boolean;\n };\n}\n\nconst CONFIG_FILE = \"s3-sync.json\";\n\nconst syncTargetSchema: z.ZodType<SyncTarget> = z.object({\n directory: z.string().min(1),\n bucket: z.string().min(1),\n region: z.string().min(1),\n endpoint: z.string().default(\"\"),\n prefix: z.string().default(\"\"),\n delete: z.boolean().default(false),\n});\n\nconst notificationsSchema: z.ZodType<S3SyncConfig[\"notifications\"]> = z.object({\n slack: z.boolean().default(false),\n discord: z.boolean().default(false),\n});\n\nconst configSchema: z.ZodType<S3SyncConfig> = z.object({\n targets: z.array(syncTargetSchema).default([]),\n branch: z.string().min(1).default(\"main\"),\n notifications: notificationsSchema.default({ slack: false, discord: false }),\n});\n\nconst legacyConfigSchema = z.object({\n directory: z.string().min(1),\n bucket: z.string().min(1),\n region: z.string().min(1),\n endpoint: z.string().default(\"\"),\n prefix: z.string().default(\"\"),\n branch: z.string().min(1).default(\"main\"),\n delete: z.boolean().default(false),\n notifications: notificationsSchema.default({ slack: false, discord: false }),\n});\n\nexport function getConfigPath(cwd: string = process.cwd()): string {\n return path.join(cwd, CONFIG_FILE);\n}\n\nexport function configExists(cwd: string = process.cwd()): boolean {\n return existsSync(getConfigPath(cwd));\n}\n\nexport async function readConfig(\n cwd: string = process.cwd()\n): Promise<S3SyncConfig> {\n const configPath = getConfigPath(cwd);\n const raw = JSON.parse(await readFile(configPath, \"utf-8\"));\n const parsedConfig = configSchema.safeParse(raw);\n if (parsedConfig.success) return parsedConfig.data;\n\n const parsedLegacyConfig = legacyConfigSchema.safeParse(raw);\n if (!parsedLegacyConfig.success) {\n const issues = parsedConfig.error.issues\n .map((issue) => {\n const path = issue.path.length > 0 ? issue.path.join(\".\") : \"root\";\n return `${path}: ${issue.message}`;\n })\n .join(\"; \");\n throw new Error(`Invalid ${CONFIG_FILE}: ${issues}`);\n }\n\n const legacy = parsedLegacyConfig.data;\n return {\n targets: [\n {\n directory: legacy.directory,\n bucket: legacy.bucket,\n region: legacy.region,\n endpoint: legacy.endpoint,\n prefix: legacy.prefix,\n delete: legacy.delete,\n },\n ],\n branch: legacy.branch,\n notifications: legacy.notifications,\n };\n}\n\nexport async function writeConfig(\n config: S3SyncConfig,\n cwd: string = process.cwd()\n): Promise<void> {\n const configPath = getConfigPath(cwd);\n const validatedConfig = configSchema.parse(config);\n await writeFile(\n configPath,\n JSON.stringify(validatedConfig, null, 2) + \"\\n\",\n \"utf-8\"\n );\n}\n\nexport function defaultTarget(): SyncTarget {\n return {\n directory: \"public\",\n bucket: \"\",\n region: \"us-east-1\",\n endpoint: \"\",\n prefix: \"\",\n delete: false,\n };\n}\n\nexport function defaultConfig(): S3SyncConfig {\n return {\n targets: [],\n branch: \"main\",\n notifications: { slack: false, discord: false },\n };\n}\n","import type { S3SyncConfig } from \"../utils/config.js\";\n\nexport function generateWorkflow(\n config: S3SyncConfig,\n packageSpecifier: string\n): string {\n const envBlock = buildEnvBlock(config);\n const pathFilters = config.targets\n .map((t) => ` - '${t.directory}/**'`)\n .join(\"\\n\");\n\n return `name: S3 Sync\n\non:\n push:\n branches: [${config.branch}]\n paths:\n${pathFilters}\n\njobs:\n sync:\n name: Sync to S3\n runs-on: ubuntu-latest\n\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n\n - name: Setup Node\n uses: actions/setup-node@v4\n with:\n node-version: \"22\"\n\n - name: Sync files\n run: npx --yes ${packageSpecifier} sync\n${envBlock}\n`;\n}\n\nfunction buildEnvBlock(config: S3SyncConfig): string {\n const lines = [\n \" env:\",\n \" S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}\",\n \" S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}\",\n ];\n\n if (config.notifications.slack) {\n lines.push(\n \" SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\"\n );\n }\n\n if (config.notifications.discord) {\n lines.push(\n \" DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}\"\n );\n }\n\n return lines.join(\"\\n\");\n}\n","import chalk from \"chalk\";\n\nexport const log = {\n info: (msg: string) => console.log(chalk.blue(\"ℹ\"), msg),\n success: (msg: string) => console.log(chalk.green(\"✓\"), msg),\n warn: (msg: string) => console.log(chalk.yellow(\"⚠\"), msg),\n error: (msg: string) => console.error(chalk.red(\"✗\"), msg),\n dim: (msg: string) => console.log(chalk.dim(msg)),\n heading: (msg: string) => console.log(chalk.bold.cyan(`\\n${msg}\\n`)),\n};\n","import { existsSync, readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\ninterface PackageMeta {\n name?: string;\n version?: string;\n}\n\nfunction findPackageJson(startDirectory: string): string | undefined {\n let currentDirectory = startDirectory;\n while (true) {\n const candidate = path.join(currentDirectory, \"package.json\");\n if (existsSync(candidate)) return candidate;\n const parentDirectory = path.dirname(currentDirectory);\n if (parentDirectory === currentDirectory) return undefined;\n currentDirectory = parentDirectory;\n }\n}\n\nexport function getCurrentPackageSpecifier(\n fallbackName = \"@maydotinc/s3-sync\"\n): string {\n const currentFileDirectory = path.dirname(fileURLToPath(import.meta.url));\n const packageJsonPath = findPackageJson(currentFileDirectory);\n if (!packageJsonPath) return fallbackName;\n\n try {\n const parsed = JSON.parse(readFileSync(packageJsonPath, \"utf-8\")) as PackageMeta;\n const packageName = parsed.name?.trim() || fallbackName;\n const packageVersion = parsed.version?.trim();\n return packageVersion ? `${packageName}@${packageVersion}` : packageName;\n } catch {\n return fallbackName;\n }\n}\n","import { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { readConfig, type SyncTarget } from \"../utils/config.js\";\nimport { log } from \"../utils/logger.js\";\nimport { fingerprintDirectory } from \"../core/fingerprint.js\";\nimport {\n createS3,\n listAllObjects,\n uploadFile,\n deleteObject,\n} from \"../core/s3-client.js\";\nimport { computeDiff, type DiffResult } from \"../core/diff.js\";\nimport { sendSlackNotification } from \"../notifications/slack.js\";\nimport { sendDiscordNotification } from \"../notifications/discord.js\";\n\nexport async function syncCommand(): Promise<void> {\n log.heading(\"s3-sync\");\n\n const config = await readConfig();\n\n const accessKeyId = process.env.S3_ACCESS_KEY_ID;\n const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;\n\n if (!accessKeyId || !secretAccessKey) {\n log.error(\n \"Missing S3_ACCESS_KEY_ID or S3_SECRET_ACCESS_KEY environment variables.\"\n );\n process.exit(1);\n }\n\n if (config.targets.length === 0) {\n log.error(\"No sync targets configured. Run setup first.\");\n process.exit(1);\n }\n\n const summaries: string[] = [];\n let hasErrors = false;\n const globalEndpoint = process.env.S3_ENDPOINT?.trim() || undefined;\n const hasTargetSpecificEndpoints = config.targets.some(\n (target) => target.endpoint.trim().length > 0\n );\n\n for (const target of config.targets) {\n try {\n const endpoint = resolveEndpoint(\n target.endpoint,\n globalEndpoint,\n hasTargetSpecificEndpoints\n );\n const diff = await syncTarget(\n target,\n accessKeyId,\n secretAccessKey,\n endpoint\n );\n summaries.push(buildTargetSummary(target, diff));\n } catch (err: any) {\n log.error(`[${target.directory}] ${err.message}`);\n hasErrors = true;\n }\n }\n\n if (summaries.length > 0) {\n const fullSummary = summaries.join(\"\\n\\n---\\n\\n\");\n await notify(config, fullSummary);\n }\n\n if (hasErrors) {\n process.exit(1);\n }\n\n log.success(\"All targets synced.\");\n}\n\nasync function syncTarget(\n target: SyncTarget,\n accessKeyId: string,\n secretAccessKey: string,\n endpoint: string | undefined\n): Promise<DiffResult> {\n log.heading(`Syncing ${target.directory}/ → s3://${target.bucket}/${target.prefix}`);\n\n const directory = path.resolve(target.directory);\n if (!existsSync(directory)) {\n throw new Error(`Directory \"${target.directory}\" does not exist.`);\n }\n\n const client = createS3({\n bucket: target.bucket,\n region: target.region,\n endpoint,\n accessKeyId,\n secretAccessKey,\n });\n\n log.info(`Scanning ${target.directory}/...`);\n const localFiles = await fingerprintDirectory(directory);\n log.info(`Found ${localFiles.length} local files`);\n\n log.info(`Listing objects in s3://${target.bucket}/${target.prefix}...`);\n const remoteObjects = await listAllObjects(\n client,\n target.bucket,\n target.prefix\n );\n log.info(`Found ${remoteObjects.length} remote objects`);\n\n const diff = computeDiff(\n localFiles,\n remoteObjects,\n target.prefix,\n target.delete\n );\n\n log.info(\n `${diff.toUpload.length} to upload, ${diff.toDelete.length} to delete, ${diff.unchanged.length} unchanged`\n );\n\n if (diff.toUpload.length === 0 && diff.toDelete.length === 0) {\n log.success(`[${target.directory}] Already in sync.`);\n return diff;\n }\n\n for (const file of diff.toUpload) {\n const key = target.prefix\n ? `${target.prefix}/${file.relativePath}`\n : file.relativePath;\n log.dim(` uploading ${key}`);\n await uploadFile(client, target.bucket, key, file.absolutePath);\n }\n\n if (diff.toUpload.length > 0) {\n log.success(`[${target.directory}] Uploaded ${diff.toUpload.length} files`);\n }\n\n for (const obj of diff.toDelete) {\n log.dim(` deleting ${obj.key}`);\n await deleteObject(client, target.bucket, obj.key);\n }\n\n if (diff.toDelete.length > 0) {\n log.success(`[${target.directory}] Deleted ${diff.toDelete.length} files`);\n }\n\n return diff;\n}\n\nfunction resolveEndpoint(\n targetEndpoint: string,\n globalEndpoint: string | undefined,\n hasTargetSpecificEndpoints: boolean\n): string | undefined {\n const trimmedTargetEndpoint = targetEndpoint.trim();\n if (trimmedTargetEndpoint) return trimmedTargetEndpoint;\n if (!hasTargetSpecificEndpoints) return globalEndpoint;\n return undefined;\n}\n\nfunction buildTargetSummary(target: SyncTarget, diff: DiffResult): string {\n const lines = [\n `*${target.directory}/ → s3://${target.bucket}/${target.prefix}*`,\n `Uploaded: ${diff.toUpload.length}`,\n `Deleted: ${diff.toDelete.length}`,\n `Unchanged: ${diff.unchanged.length}`,\n ];\n\n if (diff.toUpload.length > 0) {\n const fileList = diff.toUpload\n .slice(0, 10)\n .map((f) => ` • ${f.relativePath}`)\n .join(\"\\n\");\n lines.push(`\\nUploaded files:\\n${fileList}`);\n if (diff.toUpload.length > 10) {\n lines.push(` ...and ${diff.toUpload.length - 10} more`);\n }\n }\n\n return lines.join(\"\\n\");\n}\n\nasync function notify(config: any, message: string): Promise<void> {\n if (config.notifications?.slack) {\n const webhookUrl = process.env.SLACK_WEBHOOK_URL;\n if (webhookUrl) {\n try {\n await sendSlackNotification(webhookUrl, message);\n log.success(\"Slack notification sent\");\n } catch (err: any) {\n log.warn(`Slack notification failed: ${err.message}`);\n }\n }\n }\n\n if (config.notifications?.discord) {\n const webhookUrl = process.env.DISCORD_WEBHOOK_URL;\n if (webhookUrl) {\n try {\n await sendDiscordNotification(webhookUrl, message);\n log.success(\"Discord notification sent\");\n } catch (err: any) {\n log.warn(`Discord notification failed: ${err.message}`);\n }\n }\n }\n}\n","import { createHash } from \"node:crypto\";\nimport { readFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { glob } from \"glob\";\n\nexport interface LocalFile {\n relativePath: string;\n absolutePath: string;\n md5: string;\n size: number;\n}\n\nexport async function fingerprintDirectory(\n directory: string\n): Promise<LocalFile[]> {\n const absoluteDir = path.resolve(directory);\n const files = await glob(\"**/*\", {\n cwd: absoluteDir,\n nodir: true,\n dot: false,\n });\n\n const results: LocalFile[] = [];\n\n await Promise.all(\n files.map(async (relativePath) => {\n const absolutePath = path.join(absoluteDir, relativePath);\n const content = await readFile(absolutePath);\n const md5 = createHash(\"md5\").update(content).digest(\"hex\");\n\n results.push({\n relativePath: relativePath.replace(/\\\\/g, \"/\"),\n absolutePath,\n md5,\n size: content.length,\n });\n })\n );\n\n return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));\n}\n","import {\n S3Client,\n ListObjectsV2Command,\n type ListObjectsV2CommandOutput,\n PutObjectCommand,\n DeleteObjectCommand,\n} from \"@aws-sdk/client-s3\";\nimport { readFile } from \"node:fs/promises\";\nimport mime from \"mime-types\";\n\nexport interface S3Object {\n key: string;\n etag: string;\n size: number;\n}\n\nexport interface S3Config {\n bucket: string;\n region: string;\n endpoint?: string;\n accessKeyId: string;\n secretAccessKey: string;\n}\n\nexport function createS3(config: S3Config): S3Client {\n return new S3Client({\n region: config.region,\n credentials: {\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n },\n ...(config.endpoint && {\n endpoint: config.endpoint,\n forcePathStyle: true,\n }),\n });\n}\n\nexport async function listAllObjects(\n client: S3Client,\n bucket: string,\n prefix: string\n): Promise<S3Object[]> {\n const objects: S3Object[] = [];\n let continuationToken: string | undefined;\n\n do {\n const command = new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: prefix || undefined,\n ContinuationToken: continuationToken,\n });\n\n const response: ListObjectsV2CommandOutput = await client.send(command);\n\n if (response.Contents) {\n for (const obj of response.Contents) {\n if (obj.Key && obj.ETag && obj.Size !== undefined) {\n objects.push({\n key: obj.Key,\n etag: obj.ETag.replace(/\"/g, \"\"),\n size: obj.Size,\n });\n }\n }\n }\n\n continuationToken = response.IsTruncated\n ? response.NextContinuationToken\n : undefined;\n } while (continuationToken);\n\n return objects;\n}\n\nexport async function uploadFile(\n client: S3Client,\n bucket: string,\n key: string,\n filePath: string\n): Promise<void> {\n const body = await readFile(filePath);\n const contentType = mime.lookup(filePath) || \"application/octet-stream\";\n\n await client.send(\n new PutObjectCommand({\n Bucket: bucket,\n Key: key,\n Body: body,\n ContentType: contentType,\n })\n );\n}\n\nexport async function deleteObject(\n client: S3Client,\n bucket: string,\n key: string\n): Promise<void> {\n await client.send(\n new DeleteObjectCommand({\n Bucket: bucket,\n Key: key,\n })\n );\n}\n","import type { LocalFile } from \"./fingerprint.js\";\nimport type { S3Object } from \"./s3-client.js\";\n\nexport interface DiffResult {\n toUpload: LocalFile[];\n toDelete: S3Object[];\n unchanged: LocalFile[];\n}\n\nexport function computeDiff(\n localFiles: LocalFile[],\n remoteObjects: S3Object[],\n prefix: string,\n shouldDelete: boolean\n): DiffResult {\n const remoteByKey = new Map<string, S3Object>();\n for (const obj of remoteObjects) {\n remoteByKey.set(obj.key, obj);\n }\n\n const toUpload: LocalFile[] = [];\n const unchanged: LocalFile[] = [];\n const localKeys = new Set<string>();\n\n for (const file of localFiles) {\n const key = prefix ? `${prefix}/${file.relativePath}` : file.relativePath;\n localKeys.add(key);\n\n const remote = remoteByKey.get(key);\n\n if (!remote || remote.etag !== file.md5) {\n toUpload.push(file);\n } else {\n unchanged.push(file);\n }\n }\n\n const toDelete: S3Object[] = [];\n if (shouldDelete) {\n for (const obj of remoteObjects) {\n if (!localKeys.has(obj.key)) {\n toDelete.push(obj);\n }\n }\n }\n\n return { toUpload, toDelete, unchanged };\n}\n","export async function sendSlackNotification(\n webhookUrl: string,\n message: string\n): Promise<void> {\n const response = await fetch(webhookUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ text: message }),\n });\n\n if (!response.ok) {\n throw new Error(`Slack notification failed: ${response.statusText}`);\n }\n}\n","export async function sendDiscordNotification(\n webhookUrl: string,\n message: string\n): Promise<void> {\n const response = await fetch(webhookUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ content: message }),\n });\n\n if (!response.ok) {\n throw new Error(`Discord notification failed: ${response.statusText}`);\n }\n}\n"],"mappings":"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","names":["writeFile","existsSync","path","path","existsSync","path","path","existsSync","writeFile","existsSync","path","readFile","path","readFile","path","existsSync"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maydotinc/s3-sync",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI to sync local directories to S3-compatible buckets via GitHub Actions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"s3-sync": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"prepublishOnly": "npm run build && npm run typecheck"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"s3",
|
|
23
|
+
"sync",
|
|
24
|
+
"upload",
|
|
25
|
+
"github-actions",
|
|
26
|
+
"cdn",
|
|
27
|
+
"static-assets"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
32
|
+
"chalk": "^5.4.0",
|
|
33
|
+
"commander": "^13.0.0",
|
|
34
|
+
"glob": "^11.0.0",
|
|
35
|
+
"inquirer": "^12.3.0",
|
|
36
|
+
"mime-types": "^2.1.35",
|
|
37
|
+
"zod": "^4.3.6"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/inquirer": "^9.0.7",
|
|
41
|
+
"@types/mime-types": "^2.1.4",
|
|
42
|
+
"@types/node": "^22.0.0",
|
|
43
|
+
"tsup": "^8.3.0",
|
|
44
|
+
"tsx": "^4.19.0",
|
|
45
|
+
"typescript": "^5.7.0"
|
|
46
|
+
}
|
|
47
|
+
}
|