c3b-assets 0.3.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 +7 -0
- package/adopt.js +34 -0
- package/bunny.js +10 -5
- package/c3b-assets.js +31 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -77,9 +77,16 @@ your shell profile or a password manager — never in the repo.
|
|
|
77
77
|
c3b-assets # status: src/asset/ against the manifest — new, changed, missing, retired. Changes nothing, no key needed
|
|
78
78
|
c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json (deletes need --allow-removals)
|
|
79
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
|
|
80
81
|
c3b-assets --help # the commands and flags
|
|
81
82
|
```
|
|
82
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
|
+
|
|
83
90
|
Flags: `--dry-run` (report, change nothing), `--allow-removals` (see Safety checks). The version is printed first, so
|
|
84
91
|
you can tell which install is running.
|
|
85
92
|
|
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');
|
|
@@ -37,7 +38,8 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
37
38
|
// anything not listed here is rejected rather than silently ignored.
|
|
38
39
|
const COMMANDS = {
|
|
39
40
|
'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
|
+
'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',
|
|
@@ -432,8 +434,35 @@ async function pullImages() {
|
|
|
432
434
|
+ (dryRun ? ' — dry run, nothing written' : ''));
|
|
433
435
|
}
|
|
434
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();
|
|
461
|
+
}
|
|
462
|
+
|
|
435
463
|
// Bunny API errors (wrong key, wrong zone or region) print as one line rather than a stack trace
|
|
436
464
|
const run = command === 'u' ? () => upload(requireStorageKey())
|
|
437
465
|
: command === 's' ? sync
|
|
438
|
-
:
|
|
466
|
+
: command === 'a' ? adopt
|
|
467
|
+
: async () => status();
|
|
439
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"
|