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 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 KB.
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. Supported: `avif`, `gif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.
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, delete retired files
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 `c3b-assets u`.
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 been uploaded
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. A folder left empty by those deletions is deleted with them (Bunny keeps empty folders otherwise).
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 (e.g. colab-asset-manager/asset/logo.1a2b3c4d.svg).
17
- async function listFiles(folder) {
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 listFiles(path));
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
- '--help': 'print this help',
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
- + 'd8e-asset-manifest.json and changes nothing.\n\nCommands:');
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: config.storageHost ?? 'storage.bunnycdn.com',
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
- function planDeletions(remoteFiles, referenced, previouslyRetired, now) {
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
- + (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
245
+ + (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
229
246
  } else {
230
247
  console.log(`Run "c3b-assets u" to upload ${ toUpload } image(s)`
231
- + (due > 0 ? ` and delete ${ due } retired file(s).` : '.'));
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
- + 'Run "c3b-assets s" to download them, or add --allow-removals if you removed them on purpose.');
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 { retired, toDelete } = planDeletions(remoteFiles, referenced, previous.retired ?? {}, new Date());
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
- + `${ Object.keys(retired).length } waiting out the ${ GRACE_DAYS }-day grace period`);
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
- .sort((a, b) => b.length - a.length)
315
- .filter((folder) => !remaining.some((path) => path.startsWith(folder + '/')));
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
- .filter((since) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
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
- + 'set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
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
- + (dryRun ? ' — dry run, nothing written' : ''));
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
- : command === 's' ? sync
400
- : async () => status();
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.2.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
  }