c3b-assets 0.2.0 → 0.4.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 +25 -6
- package/adopt.js +34 -0
- package/bunny.js +10 -5
- package/c3b-assets.js +88 -21
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ Run `c3b-assets u` in a project root whenever you add, change or remove an image
|
|
|
12
12
|
`colab-asset-manager/hero-banner.3f2a9c1e.webp`. Change the image and it gets a new name automatically, so it can be
|
|
13
13
|
cached forever and a stale copy can never be served.
|
|
14
14
|
2. Uploads the ones the storage zone doesn't have yet (checksum-verified).
|
|
15
|
-
3. Writes `d8e-asset-manifest.json`: each image's path under `src/` and its CDN URL. **Commit this file** — it's a few
|
|
15
|
+
3. Writes `d8e-asset-manifest.json`: each image's path under `src/` and its CDN URL. **Commit this file** — it's a few
|
|
16
|
+
KB.
|
|
16
17
|
4. Deletes files nobody references any more, **7 days** after they stopped being referenced, so pages in browser
|
|
17
18
|
caches, sent emails and older staging builds keep working in the meantime.
|
|
18
19
|
|
|
@@ -21,7 +22,8 @@ local dev still works by opening `src/`. At deploy, d8e rewrites those reference
|
|
|
21
22
|
images out of `dist/`.
|
|
22
23
|
|
|
23
24
|
It only ever lists, uploads to and deletes inside the zone's `colab-asset-manager/` folder (the tool's old name,
|
|
24
|
-
kept so existing zones need no re-upload) — files uploaded by hand anywhere else in the zone are never touched.
|
|
25
|
+
kept so existing zones need no re-upload) — files uploaded by hand anywhere else in the zone are never touched.
|
|
26
|
+
Supported: `avif`, `gif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.
|
|
25
27
|
|
|
26
28
|
It runs on your machine only, never at deploy: the storage key stays off Heroku and Vercel, and staging can't delete
|
|
27
29
|
production's images.
|
|
@@ -73,11 +75,18 @@ your shell profile or a password manager — never in the repo.
|
|
|
73
75
|
|
|
74
76
|
```
|
|
75
77
|
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
|
|
78
|
+
c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json (deletes need --allow-removals)
|
|
77
79
|
c3b-assets s # sync: download the images the manifest lists that are missing locally, then upload as "u"
|
|
80
|
+
c3b-assets a # adopt: no manifest yet (a repo started over)? rebuild it from what the zone holds, then download
|
|
78
81
|
c3b-assets --help # the commands and flags
|
|
79
82
|
```
|
|
80
83
|
|
|
84
|
+
`a` is for a checkout that has no `d8e-asset-manifest.json` but whose zone already has the images — a repo
|
|
85
|
+
started over, or a project joining an existing zone: it lists the tool's folder in the zone, reads every stored
|
|
86
|
+
image back to its manifest key (`colab-asset-manager/arch/x.2ed3ab61.webp` → `asset/arch/x.webp`), writes the
|
|
87
|
+
manifest and downloads as `s` does. A key stored under several fingerprints keeps the most recently changed copy
|
|
88
|
+
and retires the rest from today. It refuses to run over an existing manifest.
|
|
89
|
+
|
|
81
90
|
Flags: `--dry-run` (report, change nothing), `--allow-removals` (see Safety checks). The version is printed first, so
|
|
82
91
|
you can tell which install is running.
|
|
83
92
|
|
|
@@ -89,7 +98,8 @@ upload or delete afterwards. So a fresh clone on a machine without the key still
|
|
|
89
98
|
1. **Fresh clone?** Run `c3b-assets s` first — images aren't in git, so `src/asset/` starts empty.
|
|
90
99
|
2. Replace, add or remove the file in `src/asset/` (kebab-case names). Keeping the same name for a replacement is
|
|
91
100
|
fine: the fingerprint changes anyway. Check it locally by opening `src/`.
|
|
92
|
-
3. `c3b-assets` to see what will happen, `c3b-assets u --dry-run` if you want the exact Bunny paths, then
|
|
101
|
+
3. `c3b-assets` to see what will happen, `c3b-assets u --dry-run` if you want the exact Bunny paths, then
|
|
102
|
+
`c3b-assets u`.
|
|
93
103
|
4. Commit `d8e-asset-manifest.json` (plus any code change that references a new image) and deploy as usual.
|
|
94
104
|
|
|
95
105
|
If two people sync on different branches, `d8e-asset-manifest.json` conflicts on merge: pull both sets of images
|
|
@@ -100,10 +110,19 @@ into `src/asset/` (`c3b-assets s` on each) and run `c3b-assets u` again — it r
|
|
|
100
110
|
- **Nothing is deleted just because it's missing locally.** If images the manifest lists aren't in `src/asset/` (a
|
|
101
111
|
fresh clone, a half-copied folder), `u` refuses and says to run `s`. If you removed them on purpose, add
|
|
102
112
|
`--allow-removals`.
|
|
103
|
-
- The download step of `s` never overwrites a local file that differs from Bunny — that's usually a change that hasn't
|
|
113
|
+
- The download step of `s` never overwrites a local file that differs from Bunny — that's usually a change that hasn't
|
|
114
|
+
been uploaded
|
|
104
115
|
yet. It only fills in missing ones, and checks each download against its fingerprint.
|
|
105
116
|
- Deletions only happen inside the zone's `colab-asset-manager/` folder, 7 days after a file stopped being
|
|
106
|
-
referenced
|
|
117
|
+
referenced, and only with `--allow-removals`: "unreferenced" is judged from the checkout you run in, which may be
|
|
118
|
+
a branch that dropped images `main` still serves. A plain `u` reports what is due and stops. A folder left empty by
|
|
119
|
+
those deletions is deleted with them (Bunny keeps empty folders otherwise).
|
|
120
|
+
- Only files this project's manifest has listed are ever retired or deleted. If two projects share a storage zone,
|
|
121
|
+
neither touches the other's images.
|
|
122
|
+
- The download step of `s` only fetches image files, and only from this project's `cdnUrl` folder — the manifest is
|
|
123
|
+
a committed file, so a tampered entry can't plant something else in `src/asset/`.
|
|
124
|
+
- `storageHost` must be a Bunny storage endpoint (`storage.bunnycdn.com`, optionally region-prefixed): the storage
|
|
125
|
+
key is sent to it, and the config is committed.
|
|
107
126
|
|
|
108
127
|
## Limitations
|
|
109
128
|
|
package/adopt.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// `c3b-assets a` (adopt): the manifest rebuilt from what the zone holds, for a checkout that has
|
|
2
|
+
// none — a repo started over, or a project joining a zone that already has its images. Every
|
|
3
|
+
// file under the tool's folder is read back to its manifest key: colab-asset-manager/arch/x.2ed3ab61.webp
|
|
4
|
+
// is asset/arch/x.webp at ${ cdnUrl }/colab-asset-manager/arch/x.2ed3ab61.webp. Only images; a
|
|
5
|
+
// key stored under more than one fingerprint keeps the most recently changed copy and retires
|
|
6
|
+
// the rest from today, as an upload would have.
|
|
7
|
+
export function manifestFromZone(entries, { cdnUrl, folder, imageExtensions, today }) {
|
|
8
|
+
const chosen = new Map();
|
|
9
|
+
const retired = {};
|
|
10
|
+
const skipped = [];
|
|
11
|
+
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const match = entry.path.match(/^(.*)\.([0-9a-f]{8})\.([^./]+)$/);
|
|
14
|
+
if (!match || !imageExtensions.includes(match[3].toLowerCase()) || !entry.path.startsWith(`${ folder }/`)) {
|
|
15
|
+
skipped.push(entry.path);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const key = `asset/${ match[1].slice(folder.length + 1) }.${ match[3] }`;
|
|
19
|
+
const current = chosen.get(key);
|
|
20
|
+
if (!current) {
|
|
21
|
+
chosen.set(key, entry);
|
|
22
|
+
} else if ((entry.changed ?? '') > (current.changed ?? '')) {
|
|
23
|
+
retired[current.path] = today;
|
|
24
|
+
chosen.set(key, entry);
|
|
25
|
+
} else {
|
|
26
|
+
retired[entry.path] = today;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const assets = Object.fromEntries([...chosen].sort(([a], [b]) => a.localeCompare(b))
|
|
31
|
+
.map(([key, entry]) => [key, `${ cdnUrl }/${ encodeURI(entry.path) }`]));
|
|
32
|
+
|
|
33
|
+
return { assets, retired: Object.fromEntries(Object.entries(retired).sort(([a], [b]) => a.localeCompare(b))), skipped };
|
|
34
|
+
}
|
package/bunny.js
CHANGED
|
@@ -13,8 +13,9 @@ export function createStorage({ host, zone, accessKey }) {
|
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// Every file under `folder`, as paths relative to the zone root
|
|
17
|
-
|
|
16
|
+
// Every file under `folder`, with when it last changed, as paths relative to the zone root
|
|
17
|
+
// (e.g. colab-asset-manager/asset/logo.1a2b3c4d.svg).
|
|
18
|
+
async function listEntries(folder) {
|
|
18
19
|
const response = await request('GET', folder + '/');
|
|
19
20
|
// a folder that doesn't exist yet just means nothing has been uploaded
|
|
20
21
|
if (response.status === 404) {
|
|
@@ -28,15 +29,19 @@ export function createStorage({ host, zone, accessKey }) {
|
|
|
28
29
|
for (const entry of await response.json()) {
|
|
29
30
|
const path = folder + '/' + entry.ObjectName;
|
|
30
31
|
if (entry.IsDirectory) {
|
|
31
|
-
files.push(...await
|
|
32
|
+
files.push(...await listEntries(path));
|
|
32
33
|
} else {
|
|
33
|
-
files.push(path);
|
|
34
|
+
files.push({ path, changed: entry.LastChanged ?? null });
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
return files;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
async function listFiles(folder) {
|
|
42
|
+
return (await listEntries(folder)).map((entry) => entry.path);
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
async function upload(path, content) {
|
|
41
46
|
// Bunny rejects the upload if the stored bytes don't match this, so a truncated upload can't
|
|
42
47
|
// end up being served from a URL that's cached forever.
|
|
@@ -58,5 +63,5 @@ export function createStorage({ host, zone, accessKey }) {
|
|
|
58
63
|
}
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
return { listFiles, upload, remove };
|
|
66
|
+
return { listEntries, listFiles, upload, remove };
|
|
62
67
|
}
|
package/c3b-assets.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'fs
|
|
|
4
4
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { createStorage } from './bunny.js';
|
|
7
|
+
import { manifestFromZone } from './adopt.js';
|
|
7
8
|
|
|
8
9
|
// Printed first so it's obvious which installed version is running (same as d8e does).
|
|
9
10
|
const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), 'package.json');
|
|
@@ -38,16 +39,18 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
38
39
|
const COMMANDS = {
|
|
39
40
|
'u': 'upload: fingerprint src/asset/, upload new images, write d8e-asset-manifest.json, delete retired files',
|
|
40
41
|
's': 'sync: download images the manifest lists that are missing locally, then upload (as "u")',
|
|
42
|
+
'a': 'adopt: for a checkout with no manifest, rebuild d8e-asset-manifest.json from what the zone holds, then download'
|
|
41
43
|
};
|
|
42
44
|
const FLAGS = {
|
|
43
45
|
'--dry-run': 'report what would be uploaded, downloaded, deleted and written, change nothing',
|
|
44
|
-
'--allow-removals': 'allow manifest entries with no local file to be dropped'
|
|
45
|
-
|
|
46
|
+
'--allow-removals': 'allow manifest entries with no local file to be dropped, and retired files past the grace '
|
|
47
|
+
+ 'period to be deleted from Bunny',
|
|
48
|
+
'--help': 'print this help'
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
function usage() {
|
|
49
52
|
console.log('Usage: c3b-assets [command] [flags]\n\nWith no command, prints the status of src/asset/ against '
|
|
50
|
-
|
|
53
|
+
+ 'd8e-asset-manifest.json and changes nothing.\n\nCommands:');
|
|
51
54
|
for (const [command, description] of Object.entries(COMMANDS)) {
|
|
52
55
|
console.log(` ${ command.padEnd(18) }${ description }`);
|
|
53
56
|
}
|
|
@@ -99,9 +102,16 @@ function readConfig() {
|
|
|
99
102
|
fail('c3b-assets.json needs "storageZone" and "cdnUrl"');
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
// The storage key goes to this host in every request, and the config is a committed file: only
|
|
106
|
+
// Bunny's own storage endpoints (optionally region-prefixed) may receive it.
|
|
107
|
+
const storageHost = config.storageHost ?? 'storage.bunnycdn.com';
|
|
108
|
+
if (!/^([a-z]{2,3}\.)?storage\.bunnycdn\.com$/.test(storageHost)) {
|
|
109
|
+
fail(`c3b-assets.json "storageHost" must be a Bunny storage endpoint, not ${ storageHost }`);
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
return {
|
|
103
113
|
storageZone: config.storageZone,
|
|
104
|
-
storageHost
|
|
114
|
+
storageHost,
|
|
105
115
|
cdnUrl: config.cdnUrl.replace(/\/+$/, '')
|
|
106
116
|
};
|
|
107
117
|
}
|
|
@@ -130,13 +140,15 @@ function scanImages() {
|
|
|
130
140
|
|
|
131
141
|
// Which unreferenced files to delete now, and the updated record of when each one stopped being
|
|
132
142
|
// referenced. That record is kept in the manifest, so the grace period survives between runs and
|
|
133
|
-
// machines.
|
|
134
|
-
|
|
143
|
+
// machines. Only files this project's manifest has known about (`known`) are candidates: a zone
|
|
144
|
+
// shared with another project holds files this checkout never referenced, and they are not ours
|
|
145
|
+
// to delete.
|
|
146
|
+
function planDeletions(remoteFiles, referenced, known, previouslyRetired, now) {
|
|
135
147
|
const retired = {};
|
|
136
148
|
const toDelete = [];
|
|
137
149
|
|
|
138
150
|
for (const path of remoteFiles) {
|
|
139
|
-
if (referenced.has(path)) {
|
|
151
|
+
if (referenced.has(path) || !known.has(path)) {
|
|
140
152
|
continue;
|
|
141
153
|
}
|
|
142
154
|
|
|
@@ -162,6 +174,10 @@ function sortedObject(entries) {
|
|
|
162
174
|
|
|
163
175
|
const config = readConfig();
|
|
164
176
|
|
|
177
|
+
// Every manifest URL is `${ cdnUrl }/${ encodeURI(remotePath) }`; anything else is not ours.
|
|
178
|
+
const CDN_PREFIX = `${ config.cdnUrl }/${ FOLDER }/`;
|
|
179
|
+
const remotePathOf = (url) => (url.startsWith(CDN_PREFIX) ? decodeURI(url.slice(config.cdnUrl.length + 1)) : null);
|
|
180
|
+
|
|
165
181
|
// Where src/asset/ stands against the manifest, without touching Bunny: images not in the manifest
|
|
166
182
|
// yet, images whose content no longer matches the URL the manifest holds, and manifest entries with
|
|
167
183
|
// no local file. The manifest is only written after every upload succeeded, so "in the manifest"
|
|
@@ -197,6 +213,7 @@ function status() {
|
|
|
197
213
|
console.log(`zone ${ config.storageZone } -> ${ config.cdnUrl }`);
|
|
198
214
|
if (!existsSync(ASSET_DIR)) {
|
|
199
215
|
console.log(`${ ASSET_DIR } not found — run "c3b-assets s" to download the manifest's images`);
|
|
216
|
+
|
|
200
217
|
return;
|
|
201
218
|
}
|
|
202
219
|
|
|
@@ -225,10 +242,10 @@ function status() {
|
|
|
225
242
|
console.log('Everything in src/asset/ is uploaded and in the manifest.');
|
|
226
243
|
} else if (missing.length > 0) {
|
|
227
244
|
console.log(`Run "c3b-assets s" to download ${ missing.length } missing image(s)`
|
|
228
|
-
|
|
245
|
+
+ (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
|
|
229
246
|
} else {
|
|
230
247
|
console.log(`Run "c3b-assets u" to upload ${ toUpload } image(s)`
|
|
231
|
-
|
|
248
|
+
+ (due > 0 ? `, with --allow-removals to delete ${ due } retired file(s).` : '.'));
|
|
232
249
|
}
|
|
233
250
|
}
|
|
234
251
|
|
|
@@ -247,17 +264,28 @@ async function upload(accessKey) {
|
|
|
247
264
|
const missing = Object.keys(previous.assets ?? {}).filter((key) => !localKeys.has(key));
|
|
248
265
|
if (missing.length > 0 && !allowRemovals) {
|
|
249
266
|
fail(`${ missing.length } image(s) in the manifest aren't in src/asset/ (e.g. ${ missing[0] }). `
|
|
250
|
-
|
|
267
|
+
+ 'Run "c3b-assets s" to download them, or add --allow-removals if you removed them on purpose.');
|
|
251
268
|
}
|
|
252
269
|
|
|
253
270
|
const remoteFiles = new Set(await storage.listFiles(FOLDER));
|
|
254
271
|
|
|
255
272
|
const toUpload = images.filter((image) => !remoteFiles.has(image.remotePath));
|
|
256
273
|
const referenced = new Set(images.map((image) => image.remotePath));
|
|
257
|
-
const
|
|
274
|
+
const known = new Set([
|
|
275
|
+
...Object.values(previous.assets ?? {}).map(remotePathOf),
|
|
276
|
+
...Object.keys(previous.retired ?? {})
|
|
277
|
+
]);
|
|
278
|
+
const { retired, toDelete } = planDeletions(remoteFiles, referenced, known, previous.retired ?? {}, new Date());
|
|
279
|
+
|
|
280
|
+
// Deleting is driven by this checkout, which may be a branch that dropped images main still
|
|
281
|
+
// serves — so it is never a side effect of a plain upload.
|
|
282
|
+
if (toDelete.length > 0 && !allowRemovals) {
|
|
283
|
+
fail(`${ toDelete.length } retired file(s) are past the ${ GRACE_DAYS }-day grace period. `
|
|
284
|
+
+ 'Add --allow-removals to delete them from Bunny, once this branch is what production serves.');
|
|
285
|
+
}
|
|
258
286
|
|
|
259
287
|
console.log(`${ images.length } images, ${ toUpload.length } to upload, ${ toDelete.length } to delete, `
|
|
260
|
-
|
|
288
|
+
+ `${ Object.keys(retired).length } waiting out the ${ GRACE_DAYS }-day grace period`);
|
|
261
289
|
|
|
262
290
|
for (const image of toUpload) {
|
|
263
291
|
console.log(` upload ${ image.remotePath }`);
|
|
@@ -285,9 +313,11 @@ async function upload(accessKey) {
|
|
|
285
313
|
}
|
|
286
314
|
|
|
287
315
|
// 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.
|
|
316
|
+
// ones. Deepest first, so a parent is empty by the time it is checked. The listing predates
|
|
317
|
+
// this run's uploads, so they are added back in — a folder that only just received a new
|
|
318
|
+
// image is not empty.
|
|
289
319
|
const deleted = new Set(toDelete);
|
|
290
|
-
const remaining = [...remoteFiles].filter((path) => !deleted.has(path));
|
|
320
|
+
const remaining = [...remoteFiles, ...referenced].filter((path) => !deleted.has(path));
|
|
291
321
|
for (const folder of emptiedFolders(toDelete, remaining)) {
|
|
292
322
|
console.log(` delete ${ folder }/`);
|
|
293
323
|
if (!dryRun) {
|
|
@@ -311,8 +341,8 @@ function emptiedFolders(deleted, remaining) {
|
|
|
311
341
|
}
|
|
312
342
|
|
|
313
343
|
return [...candidates]
|
|
314
|
-
|
|
315
|
-
|
|
344
|
+
.sort((a, b) => b.length - a.length)
|
|
345
|
+
.filter((folder) => !remaining.some((path) => path.startsWith(folder + '/')));
|
|
316
346
|
}
|
|
317
347
|
|
|
318
348
|
// Pull, then upload. The pull needs no storage key (the CDN is public), so a fresh clone without the
|
|
@@ -323,16 +353,17 @@ async function sync() {
|
|
|
323
353
|
const accessKey = process.env.BUNNY_STORAGE_KEY;
|
|
324
354
|
if (accessKey) {
|
|
325
355
|
await upload(accessKey);
|
|
356
|
+
|
|
326
357
|
return;
|
|
327
358
|
}
|
|
328
359
|
|
|
329
360
|
const previous = existsSync(MANIFEST) ? readJson(MANIFEST) : {};
|
|
330
361
|
const { added, changed } = localChanges(existsSync(ASSET_DIR) ? scanImages() : [], previous);
|
|
331
362
|
const due = Object.values(previous.retired ?? {})
|
|
332
|
-
|
|
363
|
+
.filter((since) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
|
|
333
364
|
if (added.length + changed.length + due > 0) {
|
|
334
365
|
fail(`${ added.length + changed.length } image(s) to upload and ${ due } retired file(s) to delete — `
|
|
335
|
-
|
|
366
|
+
+ 'set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
|
|
336
367
|
}
|
|
337
368
|
console.log('Nothing to upload.');
|
|
338
369
|
}
|
|
@@ -343,6 +374,7 @@ async function sync() {
|
|
|
343
374
|
async function pullImages() {
|
|
344
375
|
if (!existsSync(MANIFEST)) {
|
|
345
376
|
console.log('No manifest yet — nothing to download.');
|
|
377
|
+
|
|
346
378
|
return;
|
|
347
379
|
}
|
|
348
380
|
|
|
@@ -356,6 +388,14 @@ async function pullImages() {
|
|
|
356
388
|
if (!target.startsWith(ASSET_DIR + sep)) {
|
|
357
389
|
fail(`refusing to write outside src/asset/: ${ key }`);
|
|
358
390
|
}
|
|
391
|
+
// The manifest is a committed file, so what it points at is only trusted as far as this
|
|
392
|
+
// project's own CDN folder, and only ever written as an image.
|
|
393
|
+
if (!IMAGE_EXTENSIONS.includes(extname(key).slice(1).toLowerCase())) {
|
|
394
|
+
fail(`refusing to download ${ key }: not an image`);
|
|
395
|
+
}
|
|
396
|
+
if (remotePathOf(url) === null) {
|
|
397
|
+
fail(`refusing to download ${ key } from outside ${ CDN_PREFIX }: ${ url }`);
|
|
398
|
+
}
|
|
359
399
|
|
|
360
400
|
// the fingerprint in the stored name (hero.0100d254.webp) is the content hash we expect
|
|
361
401
|
const expected = decodeURI(url).match(/\.([0-9a-f]{8})\.[^./]+$/)?.[1];
|
|
@@ -391,11 +431,38 @@ async function pullImages() {
|
|
|
391
431
|
}
|
|
392
432
|
|
|
393
433
|
console.log(`${ downloaded } downloaded, ${ upToDate } already up to date, ${ kept } kept (local changes)`
|
|
394
|
-
|
|
434
|
+
+ (dryRun ? ' — dry run, nothing written' : ''));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// A checkout with no manifest (a repo started over) adopts what the zone already holds: the
|
|
438
|
+
// manifest is rebuilt from the zone's listing and the images downloaded as "s" would.
|
|
439
|
+
async function adopt() {
|
|
440
|
+
if (existsSync(MANIFEST)) {
|
|
441
|
+
fail('d8e-asset-manifest.json already exists — "a" is for a checkout without one; use "s" to sync');
|
|
442
|
+
}
|
|
443
|
+
const storage = createStorage({ host: config.storageHost, zone: config.storageZone, accessKey: requireStorageKey() });
|
|
444
|
+
const entries = await storage.listEntries(FOLDER);
|
|
445
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
446
|
+
const manifest = manifestFromZone(entries, { cdnUrl: config.cdnUrl, folder: FOLDER, imageExtensions: IMAGE_EXTENSIONS, today });
|
|
447
|
+
|
|
448
|
+
for (const key of Object.keys(manifest.assets)) {
|
|
449
|
+
console.log(` adopt ${ key }`);
|
|
450
|
+
}
|
|
451
|
+
for (const path of manifest.skipped) {
|
|
452
|
+
console.log(` skip ${ path } (not an image c3b-assets stored)`);
|
|
453
|
+
}
|
|
454
|
+
console.log(`${ Object.keys(manifest.assets).length } image(s) on Bunny, ${ Object.keys(manifest.retired).length } older copies retired`
|
|
455
|
+
+ (dryRun ? ' — dry run, nothing written' : ''));
|
|
456
|
+
if (dryRun) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
writeFileSync(MANIFEST, JSON.stringify({ assets: manifest.assets, retired: manifest.retired }, null, 2) + '\n');
|
|
460
|
+
await pullImages();
|
|
395
461
|
}
|
|
396
462
|
|
|
397
463
|
// Bunny API errors (wrong key, wrong zone or region) print as one line rather than a stack trace
|
|
398
464
|
const run = command === 'u' ? () => upload(requireStorageKey())
|
|
399
|
-
|
|
400
|
-
|
|
465
|
+
: command === 's' ? sync
|
|
466
|
+
: command === 'a' ? adopt
|
|
467
|
+
: async () => status();
|
|
401
468
|
run().catch((error) => fail(error.message));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c3b-assets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"c3b-assets.js",
|
|
12
|
-
"bunny.js"
|
|
12
|
+
"bunny.js",
|
|
13
|
+
"adopt.js"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=22"
|
|
@@ -22,7 +23,10 @@
|
|
|
22
23
|
"registry": "https://registry.npmjs.org"
|
|
23
24
|
},
|
|
24
25
|
"scripts": {
|
|
25
|
-
"test": "node --test test/*.test.js",
|
|
26
|
+
"test": "c3b-lint && node --test test/*.test.js",
|
|
26
27
|
"deploy": "npm run test && npm publish"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"c3b-lint": "0.0.8"
|
|
27
31
|
}
|
|
28
32
|
}
|