c3b-assets 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +17 -12
  2. package/c3b-assets.js +141 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@ git and never need uploading by hand. Writes `d8e-asset-manifest.json`, which [d
6
6
 
7
7
  ## How it works
8
8
 
9
- Run `c3b-assets` in a project root whenever you add, change or remove an image under `src/asset/`. It:
9
+ Run `c3b-assets u` in a project root whenever you add, change or remove an image under `src/asset/`. It:
10
10
 
11
11
  1. Fingerprints each image from its content — `asset/hero-banner.webp` is stored as
12
12
  `colab-asset-manager/hero-banner.3f2a9c1e.webp`. Change the image and it gets a new name automatically, so it can be
@@ -72,33 +72,38 @@ your shell profile or a password manager — never in the repo.
72
72
  ## Usage
73
73
 
74
74
  ```
75
- c3b-assets --help # lists every flag
76
- c3b-assets --pull # downloads the images the manifest lists into src/asset/ (no key needed)
77
- c3b-assets --dry-run # shows what would be uploaded and deleted; changes nothing
78
- c3b-assets # syncs and writes d8e-asset-manifest.json
75
+ c3b-assets # status: src/asset/ against the manifest — new, changed, missing, retired. Changes nothing, no key needed
76
+ c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json, delete retired files
77
+ c3b-assets s # sync: download the images the manifest lists that are missing locally, then upload as "u"
78
+ c3b-assets --help # the commands and flags
79
79
  ```
80
80
 
81
- The version is printed first, so you can tell which install is running.
81
+ Flags: `--dry-run` (report, change nothing), `--allow-removals` (see Safety checks). The version is printed first, so
82
+ you can tell which install is running.
83
+
84
+ `s` needs no key to download — the CDN is public — and only asks for `BUNNY_STORAGE_KEY` if there is something to
85
+ upload or delete afterwards. So a fresh clone on a machine without the key still gets its images.
82
86
 
83
87
  ### Changing an image
84
88
 
85
- 1. **Fresh clone?** Run `c3b-assets --pull` first — images aren't in git, so `src/asset/` starts empty.
89
+ 1. **Fresh clone?** Run `c3b-assets s` first — images aren't in git, so `src/asset/` starts empty.
86
90
  2. Replace, add or remove the file in `src/asset/` (kebab-case names). Keeping the same name for a replacement is
87
91
  fine: the fingerprint changes anyway. Check it locally by opening `src/`.
88
- 3. `c3b-assets --dry-run`, then `c3b-assets`.
92
+ 3. `c3b-assets` to see what will happen, `c3b-assets u --dry-run` if you want the exact Bunny paths, then `c3b-assets u`.
89
93
  4. Commit `d8e-asset-manifest.json` (plus any code change that references a new image) and deploy as usual.
90
94
 
91
95
  If two people sync on different branches, `d8e-asset-manifest.json` conflicts on merge: pull both sets of images
92
- into `src/asset/` (`--pull`) and run `c3b-assets` again — it rebuilds the manifest from what's there.
96
+ into `src/asset/` (`c3b-assets s` on each) and run `c3b-assets u` again — it rebuilds the manifest from what's there.
93
97
 
94
98
  ### Safety checks
95
99
 
96
100
  - **Nothing is deleted just because it's missing locally.** If images the manifest lists aren't in `src/asset/` (a
97
- fresh clone, a half-copied folder), syncing refuses and says to `--pull`. If you removed them on purpose, add
101
+ fresh clone, a half-copied folder), `u` refuses and says to run `s`. If you removed them on purpose, add
98
102
  `--allow-removals`.
99
- - `--pull` never overwrites a local file that differs from Bunny — that's usually a change that hasn't been uploaded
103
+ - The download step of `s` never overwrites a local file that differs from Bunny — that's usually a change that hasn't been uploaded
100
104
  yet. It only fills in missing ones, and checks each download against its fingerprint.
101
- - Deletions only happen inside the zone's `colab-asset-manager/` folder, 7 days after a file stopped being referenced.
105
+ - Deletions only happen inside the zone's `colab-asset-manager/` folder, 7 days after a file stopped being
106
+ referenced. A folder left empty by those deletions is deleted with them (Bunny keeps empty folders otherwise).
102
107
 
103
108
  ## Limitations
104
109
 
package/c3b-assets.js CHANGED
@@ -33,17 +33,25 @@ const FOLDER = 'colab-asset-manager';
33
33
  const GRACE_DAYS = 7;
34
34
  const DAY_MS = 24 * 60 * 60 * 1000;
35
35
 
36
- // Every flag the tool accepts, with its one-line description. `--help` prints them; anything not
37
- // listed here is rejected rather than silently ignored.
36
+ // The commands and flags the tool accepts, each with its one-line description. `--help` prints them;
37
+ // anything not listed here is rejected rather than silently ignored.
38
+ const COMMANDS = {
39
+ 'u': 'upload: fingerprint src/asset/, upload new images, write d8e-asset-manifest.json, delete retired files',
40
+ 's': 'sync: download images the manifest lists that are missing locally, then upload (as "u")',
41
+ };
38
42
  const FLAGS = {
39
- '--dry-run': 'report what would be uploaded, deleted and written, change nothing',
40
- '--pull': 'download images listed in the manifest that are missing locally',
43
+ '--dry-run': 'report what would be uploaded, downloaded, deleted and written, change nothing',
41
44
  '--allow-removals': 'allow manifest entries with no local file to be dropped',
42
45
  '--help': 'print this help',
43
46
  };
44
47
 
45
48
  function usage() {
46
- console.log(`Usage: c3b-assets [flags]\n\nFingerprints images under src/asset/, syncs them to Bunny, writes d8e-asset-manifest.json.\n\nFlags:`);
49
+ console.log('Usage: c3b-assets [command] [flags]\n\nWith no command, prints the status of src/asset/ against '
50
+ + 'd8e-asset-manifest.json and changes nothing.\n\nCommands:');
51
+ for (const [command, description] of Object.entries(COMMANDS)) {
52
+ console.log(` ${ command.padEnd(18) }${ description }`);
53
+ }
54
+ console.log('\nFlags:');
47
55
  for (const [flag, description] of Object.entries(FLAGS)) {
48
56
  console.log(` ${ flag.padEnd(18) }${ description }`);
49
57
  }
@@ -54,16 +62,16 @@ if (args.includes('--help') || args.includes('-h')) {
54
62
  usage();
55
63
  process.exit(0);
56
64
  }
57
- for (const arg of args) {
65
+ const command = args[0] in COMMANDS ? args[0] : null;
66
+ for (const arg of args.slice(command ? 1 : 0)) {
58
67
  if (!(arg in FLAGS)) {
59
- console.error(`c3b-assets: unknown option ${ arg }\n`);
68
+ console.error(`c3b-assets: unknown ${ arg.startsWith('-') ? 'option' : 'command' } ${ arg }\n`);
60
69
  usage();
61
70
  process.exit(1);
62
71
  }
63
72
  }
64
73
 
65
74
  const dryRun = args.includes('--dry-run');
66
- const pull = args.includes('--pull');
67
75
  const allowRemovals = args.includes('--allow-removals');
68
76
 
69
77
  function fail(message) {
@@ -154,13 +162,79 @@ function sortedObject(entries) {
154
162
 
155
163
  const config = readConfig();
156
164
 
157
- async function sync() {
165
+ // Where src/asset/ stands against the manifest, without touching Bunny: images not in the manifest
166
+ // yet, images whose content no longer matches the URL the manifest holds, and manifest entries with
167
+ // no local file. The manifest is only written after every upload succeeded, so "in the manifest"
168
+ // means "on Bunny" — this is what a bare run reports and what a keyless sync decides on.
169
+ function localChanges(images, previous) {
170
+ const assets = previous.assets ?? {};
171
+ const localKeys = new Set(images.map((image) => image.key));
172
+ const added = [];
173
+ const changed = [];
174
+ for (const image of images) {
175
+ if (!(image.key in assets)) {
176
+ added.push(image.key);
177
+ } else if (!assets[image.key].endsWith('/' + encodeURI(image.remotePath))) {
178
+ changed.push(image.key);
179
+ }
180
+ }
181
+ const missing = Object.keys(assets).filter((key) => !localKeys.has(key));
182
+
183
+ return { added, changed, missing };
184
+ }
185
+
186
+ function requireStorageKey() {
158
187
  const accessKey = process.env.BUNNY_STORAGE_KEY;
159
188
  if (!accessKey) {
160
189
  fail('set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
161
190
  }
191
+
192
+ return accessKey;
193
+ }
194
+
195
+ // The bare run: a read-only report of src/asset/ against the manifest, so it needs no key.
196
+ function status() {
197
+ console.log(`zone ${ config.storageZone } -> ${ config.cdnUrl }`);
198
+ if (!existsSync(ASSET_DIR)) {
199
+ console.log(`${ ASSET_DIR } not found — run "c3b-assets s" to download the manifest's images`);
200
+ return;
201
+ }
202
+
203
+ const images = scanImages();
204
+ const previous = existsSync(MANIFEST) ? readJson(MANIFEST) : {};
205
+ const { added, changed, missing } = localChanges(images, previous);
206
+ const retired = Object.entries(previous.retired ?? {});
207
+ const due = retired.filter(([, since]) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
208
+
209
+ console.log(`${ images.length } images in src/asset/, ${ Object.keys(previous.assets ?? {}).length } in the manifest`);
210
+ for (const key of added) {
211
+ console.log(` new ${ key }`);
212
+ }
213
+ for (const key of changed) {
214
+ console.log(` changed ${ key }`);
215
+ }
216
+ for (const key of missing) {
217
+ console.log(` missing ${ key } (in the manifest, not in src/asset/)`);
218
+ }
219
+ if (retired.length > 0) {
220
+ console.log(` ${ retired.length } retired on Bunny, ${ due } past the ${ GRACE_DAYS }-day grace period`);
221
+ }
222
+
223
+ const toUpload = added.length + changed.length;
224
+ if (toUpload === 0 && missing.length === 0 && due === 0) {
225
+ console.log('Everything in src/asset/ is uploaded and in the manifest.');
226
+ } else if (missing.length > 0) {
227
+ console.log(`Run "c3b-assets s" to download ${ missing.length } missing image(s)`
228
+ + (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
229
+ } else {
230
+ console.log(`Run "c3b-assets u" to upload ${ toUpload } image(s)`
231
+ + (due > 0 ? ` and delete ${ due } retired file(s).` : '.'));
232
+ }
233
+ }
234
+
235
+ async function upload(accessKey) {
162
236
  if (!existsSync(ASSET_DIR)) {
163
- fail(`${ ASSET_DIR } not found — on a fresh clone, run "c3b-assets --pull" first`);
237
+ fail(`${ ASSET_DIR } not found — on a fresh clone, run "c3b-assets s" first`);
164
238
  }
165
239
 
166
240
  const storage = createStorage({ host: config.storageHost, zone: config.storageZone, accessKey });
@@ -173,7 +247,7 @@ async function sync() {
173
247
  const missing = Object.keys(previous.assets ?? {}).filter((key) => !localKeys.has(key));
174
248
  if (missing.length > 0 && !allowRemovals) {
175
249
  fail(`${ missing.length } image(s) in the manifest aren't in src/asset/ (e.g. ${ missing[0] }). `
176
- + 'Run "c3b-assets --pull" to download them, or add --allow-removals if you removed them on purpose.');
250
+ + 'Run "c3b-assets s" to download them, or add --allow-removals if you removed them on purpose.');
177
251
  }
178
252
 
179
253
  const remoteFiles = new Set(await storage.listFiles(FOLDER));
@@ -210,15 +284,66 @@ async function sync() {
210
284
  }
211
285
  }
212
286
 
287
+ // Bunny leaves a folder behind when its last file goes, so the zone slowly fills with empty
288
+ // ones. Deepest first, so a parent is empty by the time it is checked.
289
+ const deleted = new Set(toDelete);
290
+ const remaining = [...remoteFiles].filter((path) => !deleted.has(path));
291
+ for (const folder of emptiedFolders(toDelete, remaining)) {
292
+ console.log(` delete ${ folder }/`);
293
+ if (!dryRun) {
294
+ await storage.remove(folder + '/');
295
+ }
296
+ }
297
+
213
298
  console.log(dryRun ? 'Dry run — nothing uploaded, deleted or written.' : `Wrote ${ MANIFEST }`);
214
299
  }
215
300
 
301
+ // Every folder under FOLDER that the deletions left with nothing in it, deepest first. FOLDER
302
+ // itself is never returned — an empty zone folder is harmless and the next upload needs it.
303
+ function emptiedFolders(deleted, remaining) {
304
+ const candidates = new Set();
305
+ for (const path of deleted) {
306
+ let folder = dirname(path);
307
+ while (folder !== FOLDER && folder !== '.') {
308
+ candidates.add(folder);
309
+ folder = dirname(folder);
310
+ }
311
+ }
312
+
313
+ return [...candidates]
314
+ .sort((a, b) => b.length - a.length)
315
+ .filter((folder) => !remaining.some((path) => path.startsWith(folder + '/')));
316
+ }
317
+
318
+ // Pull, then upload. The pull needs no storage key (the CDN is public), so a fresh clone without the
319
+ // key still gets its images: the key is only demanded when the upload step actually has work to do.
320
+ async function sync() {
321
+ await pullImages();
322
+
323
+ const accessKey = process.env.BUNNY_STORAGE_KEY;
324
+ if (accessKey) {
325
+ await upload(accessKey);
326
+ return;
327
+ }
328
+
329
+ const previous = existsSync(MANIFEST) ? readJson(MANIFEST) : {};
330
+ const { added, changed } = localChanges(existsSync(ASSET_DIR) ? scanImages() : [], previous);
331
+ const due = Object.values(previous.retired ?? {})
332
+ .filter((since) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
333
+ if (added.length + changed.length + due > 0) {
334
+ fail(`${ added.length + changed.length } image(s) to upload and ${ due } retired file(s) to delete — `
335
+ + 'set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
336
+ }
337
+ console.log('Nothing to upload.');
338
+ }
339
+
216
340
  // Downloads every image in the manifest that src/asset/ is missing, straight from the CDN — so it
217
341
  // needs no storage key, just the committed manifest. A local file that differs from the manifest is
218
342
  // kept, not overwritten: it's most likely a change that hasn't been uploaded yet.
219
343
  async function pullImages() {
220
344
  if (!existsSync(MANIFEST)) {
221
- fail(`${ MANIFEST } not found — nothing to pull`);
345
+ console.log('No manifest yet — nothing to download.');
346
+ return;
222
347
  }
223
348
 
224
349
  const assets = readJson(MANIFEST).assets ?? {};
@@ -270,4 +395,7 @@ async function pullImages() {
270
395
  }
271
396
 
272
397
  // Bunny API errors (wrong key, wrong zone or region) print as one line rather than a stack trace
273
- (pull ? pullImages() : sync()).catch((error) => fail(error.message));
398
+ const run = command === 'u' ? () => upload(requireStorageKey())
399
+ : command === 's' ? sync
400
+ : async () => status();
401
+ run().catch((error) => fail(error.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c3b-assets",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Fingerprints a project's images and syncs them to a Bunny storage zone, writing the d8e-asset-manifest.json d8e reads.",
5
5
  "license": "GPL-3.0",
6
6
  "type": "module",