@casomoltd/tooling 0.37.0 → 0.39.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 +8 -2
- package/bin/check-gates.mjs +51 -32
- package/bin/check-routes.mjs +218 -0
- package/bin/pre-push.sh +5 -0
- package/bin/verify-pack.mjs +15 -5
- package/identity/index.d.mts +15 -0
- package/identity/index.mjs +6 -0
- package/identity/organization.mjs +27 -0
- package/package.json +30 -23
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
|
|
|
15
15
|
| `@casomoltd/tooling/knip` | Knip config for unused exports/dependencies |
|
|
16
16
|
| `@casomoltd/tooling/jscpd` | Copy-paste detection config |
|
|
17
17
|
| `@casomoltd/tooling/readability` | `scoreText()` — readability scoring for page content |
|
|
18
|
+
| `@casomoltd/tooling/identity` | `CASOMO_ORGANIZATION`, the schema.org `Organization`, logo included, that every Casomo site names as publisher in its JSON-LD. App code imports it at build time, so a site that installs only production dependencies at runtime lists the package under `dependencies` |
|
|
18
19
|
| `@casomoltd/tooling/buttondown` | For a repo using the `buttondown` command: the shared email blocks and skins (`createEmailBlocks`, `emailBaseCss`, `composeWebSkin`), `tokenReader()`, `loadSettings()` and `buildPayload()`, and `EMAIL_CHROME_HIDES` and `WEB_CHROME_HIDES` ([docs/buttondown.md](docs/buttondown.md)) |
|
|
19
20
|
|
|
20
21
|
### Bin commands
|
|
@@ -23,7 +24,8 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
|
|
|
23
24
|
|---|---|
|
|
24
25
|
| `check-version` | Pre-push guard — rejects push if `package.json` version hasn't changed vs `origin/main` |
|
|
25
26
|
| `check-linear` | Pre-push guard — rejects a push that would put a merge commit on the remote. History is linear; a branch catches up by rebasing. Only what the push adds is judged, so merges already on the remote stay |
|
|
26
|
-
| `pre-push` | Husky pre-push hook — runs check, then the version, tag and linear-history guards |
|
|
27
|
+
| `pre-push` | Husky pre-push hook — runs check, then `check:routes` where a repo defines it, then the version, tag and linear-history guards |
|
|
28
|
+
| `check-routes` | Pre-push gate for a Next.js site — starts `next start` against the build `check` made, requests every page in `/sitemap.xml`, and fails if any is not a 200. Run it as `"check:routes": "check-routes"`; [why it runs on push](#quality-gates) |
|
|
27
29
|
| `pre-commit` | Husky pre-commit hook — runs `npm run check` |
|
|
28
30
|
| `commit-msg` | Husky commit-msg hook — runs commitlint, then `check-listed-files` |
|
|
29
31
|
| `check-listed-files` | Refuse a commit that changes a file still listed in `eslint-suppressions.json`, unless a `Lint-deferred:` trailer says why ([docs/lint.md](docs/lint.md)) |
|
|
@@ -286,13 +288,17 @@ the local hooks are the gate, CI is the backstop.
|
|
|
286
288
|
|
|
287
289
|
- **pre-commit**: `npm run check` — the repo's full health gate.
|
|
288
290
|
- **commit-msg**: commitlint (house rules + the AI-attribution ban).
|
|
289
|
-
- **pre-push**: `npm run check`, then
|
|
291
|
+
- **pre-push**: `npm run check`, then `check:routes` where the repo
|
|
292
|
+
defines it, then the version, tag and linear-history guards.
|
|
290
293
|
|
|
291
294
|
**`check` owns the build.** A repo whose build fails is not
|
|
292
295
|
healthy, and any assertion that reads build output has to run
|
|
293
296
|
after the build in the same script — so `build` belongs in
|
|
294
297
|
`check`, and appears nowhere else. Declaring it in the pre-push
|
|
295
298
|
gate as well would give it two owners and build twice.
|
|
299
|
+
Exception: `check:routes` reads the build outside `check`. It
|
|
300
|
+
starts a server, which belongs to a push rather than to every
|
|
301
|
+
commit, so the pre-push hook runs it after `check`.
|
|
296
302
|
|
|
297
303
|
### What every repo's `check` must contain
|
|
298
304
|
|
package/bin/check-gates.mjs
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
//
|
|
10
10
|
// So this does not orchestrate the gates; it checks the composition:
|
|
11
11
|
// 1. every REQUIRED gate is defined and actually run by `check`
|
|
12
|
-
// 2. no gate-shaped script is defined but left unreferenced (a dead gate)
|
|
12
|
+
// 2. no gate-shaped script is defined but left unreferenced (a dead gate),
|
|
13
|
+
// other than a pre-push gate, which the hook runs instead
|
|
13
14
|
// 3. no exception outlives the problem it was written for
|
|
15
|
+
// 4. no pre-push gate is run by `check`, which every commit pays for
|
|
14
16
|
//
|
|
15
17
|
// A repo opts out by declaring a reason in its own package.json. A blank
|
|
16
18
|
// reason fails, so an opt-out can never be silent:
|
|
@@ -38,6 +40,11 @@ export const REQUIRED = [
|
|
|
38
40
|
/** Gate-shaped but not mandatory: still dead if defined and never run. */
|
|
39
41
|
export const OPTIONAL = ["check:readability"];
|
|
40
42
|
|
|
43
|
+
/** Gates the pre-push hook runs after `check`, never `check` itself: they
|
|
44
|
+
* start a server against the build, which a commit should not pay for.
|
|
45
|
+
* bin/pre-push.sh runs each by name, so the two lists must match. */
|
|
46
|
+
export const PRE_PUSH = ["check:routes"];
|
|
47
|
+
|
|
41
48
|
/** A script is a gate only by exact membership or the `check:` prefix — never
|
|
42
49
|
* by heuristic, so `dev`, `start`, `clean`, `lint:fix`, `test:watch`,
|
|
43
50
|
* `build:clean` and `indexnow` need no denylist to be ignored. */
|
|
@@ -54,45 +61,57 @@ export const referencedGates = (check) =>
|
|
|
54
61
|
.filter(Boolean),
|
|
55
62
|
);
|
|
56
63
|
|
|
64
|
+
/** Each required gate that is not defined, or defined but never run. */
|
|
65
|
+
const requiredFailures = (scripts, run) =>
|
|
66
|
+
REQUIRED.flatMap((gate) => {
|
|
67
|
+
if (!(gate in scripts)) return [[gate, `required gate is not defined`]];
|
|
68
|
+
if (!run.has(gate)) {
|
|
69
|
+
return [[gate, `required gate is defined but \`check\` never runs it`]];
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** Each other gate-shaped script that `check` never runs. `check` itself is
|
|
75
|
+
* the chain, not a step within it, and the hook runs a pre-push gate. */
|
|
76
|
+
const deadGates = (scripts, run) =>
|
|
77
|
+
Object.keys(scripts)
|
|
78
|
+
.filter((n) => isGate(n) && n !== "check" && !REQUIRED.includes(n))
|
|
79
|
+
.filter((gate) => !PRE_PUSH.includes(gate))
|
|
80
|
+
.filter((gate) => !run.has(gate))
|
|
81
|
+
.map((gate) => [gate, `defined but \`check\` never runs it — a dead gate`]);
|
|
82
|
+
|
|
83
|
+
/** Each pre-push gate that `check` runs, so every commit starts a server. */
|
|
84
|
+
const prePushInCheck = (run) =>
|
|
85
|
+
PRE_PUSH.filter((gate) => run.has(gate)).map((gate) => [
|
|
86
|
+
gate,
|
|
87
|
+
`a pre-push gate: \`check\` runs on every commit`,
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/** Sort the failures by their exception: none, a blank reason, or a reason. */
|
|
91
|
+
const byException = (failures, exceptions) => {
|
|
92
|
+
const sorted = {live: [], excused: [], unreasoned: []};
|
|
93
|
+
for (const [gate, reason] of failures) {
|
|
94
|
+
if (!(gate in exceptions)) sorted.live.push([gate, reason]);
|
|
95
|
+
else if (!String(exceptions[gate] ?? "").trim()) sorted.unreasoned.push(gate);
|
|
96
|
+
else sorted.excused.push([gate, exceptions[gate]]);
|
|
97
|
+
}
|
|
98
|
+
return sorted;
|
|
99
|
+
};
|
|
100
|
+
|
|
57
101
|
export const evaluate = (pkg) => {
|
|
58
102
|
const scripts = pkg.scripts ?? {};
|
|
59
103
|
const exceptions = pkg.casomo?.gates ?? {};
|
|
60
104
|
const run = referencedGates(scripts.check);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
for (const gate of REQUIRED) {
|
|
67
|
-
if (!(gate in scripts)) failures.push([gate, `required gate is not defined`]);
|
|
68
|
-
else if (!run.has(gate))
|
|
69
|
-
failures.push([gate, `required gate is defined but \`check\` never runs it`]);
|
|
70
|
-
}
|
|
71
|
-
for (const gate of defined) {
|
|
72
|
-
if (run.has(gate)) continue;
|
|
73
|
-
if (failures.some(([g]) => g === gate)) continue; // already reported above
|
|
74
|
-
failures.push([gate, `defined but \`check\` never runs it — a dead gate`]);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const excused = [];
|
|
78
|
-
const unreasoned = [];
|
|
79
|
-
const live = [];
|
|
80
|
-
for (const [gate, reason] of failures) {
|
|
81
|
-
if (!(gate in exceptions)) {
|
|
82
|
-
live.push([gate, reason]);
|
|
83
|
-
} else if (!String(exceptions[gate] ?? "").trim()) {
|
|
84
|
-
unreasoned.push(gate);
|
|
85
|
-
} else {
|
|
86
|
-
excused.push([gate, exceptions[gate]]);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
105
|
+
const failures = [
|
|
106
|
+
...requiredFailures(scripts, run),
|
|
107
|
+
...deadGates(scripts, run),
|
|
108
|
+
...prePushInCheck(run),
|
|
109
|
+
];
|
|
90
110
|
// An exception whose failure has gone away is the same rot in reverse.
|
|
91
111
|
const stale = Object.keys(exceptions).filter(
|
|
92
112
|
(gate) => !failures.some(([g]) => g === gate),
|
|
93
113
|
);
|
|
94
|
-
|
|
95
|
-
return {live, excused, unreasoned, stale};
|
|
114
|
+
return {...byException(failures, exceptions), stale};
|
|
96
115
|
};
|
|
97
116
|
|
|
98
117
|
const main = () => {
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Request every page a built Next.js site lists in its sitemap from
|
|
4
|
+
* `next start` on a local port, and fail if any answers anything but 200.
|
|
5
|
+
*
|
|
6
|
+
* A build can pass while a page still fails at request time: a server
|
|
7
|
+
* component that throws, or a fetch that breaks only when the page renders.
|
|
8
|
+
* The pre-push hook runs this after `npm run check`, against the build
|
|
9
|
+
* `check` has just made; it makes no build of its own. It stays out of
|
|
10
|
+
* `check` because the pre-commit hook runs `check`, and starting a server
|
|
11
|
+
* belongs to a push, not to every commit.
|
|
12
|
+
*
|
|
13
|
+
* It reads the sitemap Next emits: plain `<loc>` elements, in a urlset or a
|
|
14
|
+
* sitemap index one level deep, with `&` the only entity in a URL.
|
|
15
|
+
*
|
|
16
|
+
* Usage, in the site's root after `next build`:
|
|
17
|
+
* check-routes
|
|
18
|
+
*
|
|
19
|
+
* Exit codes:
|
|
20
|
+
* 0 every listed page answered 200
|
|
21
|
+
* 1 a page did not, the sitemap failed or listed none, or the server
|
|
22
|
+
* never started
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {spawn} from "node:child_process";
|
|
26
|
+
import {existsSync, realpathSync} from "node:fs";
|
|
27
|
+
import {createServer} from "node:net";
|
|
28
|
+
import {setTimeout as sleep} from "node:timers/promises";
|
|
29
|
+
import {pathToFileURL} from "node:url";
|
|
30
|
+
|
|
31
|
+
const HOST = "127.0.0.1";
|
|
32
|
+
const SITEMAP = "/sitemap.xml";
|
|
33
|
+
// The default distDir; a site that moves it is not supported.
|
|
34
|
+
const BUILD_ID = ".next/BUILD_ID";
|
|
35
|
+
// The site's own `next`, never one `npx` would offer to download.
|
|
36
|
+
const NEXT_BIN = "node_modules/.bin/next";
|
|
37
|
+
const HTTP_OK = 200;
|
|
38
|
+
const READY_POLL_MS = 250;
|
|
39
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
40
|
+
// A page waiting on a dead upstream must fail the push, not hang it.
|
|
41
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
42
|
+
// Enough to keep a few hundred pages quick without starving the server.
|
|
43
|
+
const CONCURRENCY = 8;
|
|
44
|
+
|
|
45
|
+
/** Probe every path, CONCURRENCY at a time; return the ones not 200. */
|
|
46
|
+
export const failingPages = async (base, paths) => {
|
|
47
|
+
const queue = [...paths];
|
|
48
|
+
const failures = [];
|
|
49
|
+
const worker = async () => {
|
|
50
|
+
for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
|
|
51
|
+
const result = await probe(base, path);
|
|
52
|
+
if (result.status !== HTTP_OK) failures.push(result);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
await Promise.all(Array.from({length: CONCURRENCY}, worker));
|
|
56
|
+
return failures;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The path and query of every `<loc>` in a sitemap or sitemap index. The
|
|
60
|
+
* sitemap names the production host; the check asks the local server. */
|
|
61
|
+
const sitemapPaths = (xml) =>
|
|
62
|
+
[...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map(([, loc]) => {
|
|
63
|
+
const url = new URL(loc.trim().replaceAll("&", "&"));
|
|
64
|
+
return url.pathname + url.search;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** A sitemap index lists other sitemaps, not pages. */
|
|
68
|
+
const isSitemapIndex = (xml) => /<sitemapindex[\s>]/.test(xml);
|
|
69
|
+
|
|
70
|
+
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
71
|
+
|
|
72
|
+
const get = (url, init = {}) =>
|
|
73
|
+
fetch(url, {...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)});
|
|
74
|
+
|
|
75
|
+
/** A sitemap's text; any answer but 200 is the failure, not a retry. */
|
|
76
|
+
const readSitemap = async (base, path) => {
|
|
77
|
+
const res = await get(base + path);
|
|
78
|
+
if (res.status !== HTTP_OK) throw new Error(`${path} answered ${res.status}`);
|
|
79
|
+
return res.text();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Every page path, once each, following a sitemap index one level down. */
|
|
83
|
+
const collectPaths = async (base, rootXml) => {
|
|
84
|
+
if (!isSitemapIndex(rootXml)) return [...new Set(sitemapPaths(rootXml))];
|
|
85
|
+
const children = await Promise.all(
|
|
86
|
+
sitemapPaths(rootXml).map((path) => readSitemap(base, path)),
|
|
87
|
+
);
|
|
88
|
+
return [...new Set(children.flatMap(sitemapPaths))];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** One page's status, or the error that stopped the request. A redirect
|
|
92
|
+
* counts as a failure: a sitemap should list where a page lives. */
|
|
93
|
+
const probe = async (base, path) => {
|
|
94
|
+
try {
|
|
95
|
+
const res = await get(base + path, {redirect: "manual"});
|
|
96
|
+
await res.arrayBuffer();
|
|
97
|
+
return {path, status: res.status};
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {path, error: message(err)};
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** A free local port, so a dev server already on 3000 is never in the way. */
|
|
104
|
+
const freePort = () =>
|
|
105
|
+
new Promise((resolve, reject) => {
|
|
106
|
+
const listener = createServer();
|
|
107
|
+
listener.on("error", reject);
|
|
108
|
+
listener.listen(0, HOST, () => {
|
|
109
|
+
const {port} = listener.address();
|
|
110
|
+
listener.close(() => resolve(port));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** `next start` in its own process group, so the stop reaches `next-server`
|
|
115
|
+
* and not only its launcher. `died` rejects the moment the server fails to
|
|
116
|
+
* spawn or exits, so a dead server fails the check now, not at the timeout. */
|
|
117
|
+
const startServer = (port) => {
|
|
118
|
+
const server = spawn(NEXT_BIN, ["start", "-p", String(port), "-H", HOST], {
|
|
119
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
120
|
+
detached: true,
|
|
121
|
+
});
|
|
122
|
+
const output = [];
|
|
123
|
+
server.stdout.on("data", (d) => output.push(String(d)));
|
|
124
|
+
server.stderr.on("data", (d) => output.push(String(d)));
|
|
125
|
+
const died = new Promise((_, reject) => {
|
|
126
|
+
server.on("error", (err) => reject(new Error(`could not start next: ${err}`)));
|
|
127
|
+
server.on("exit", (code) =>
|
|
128
|
+
reject(new Error(`next start exited (${code})\n${output.join("")}`)),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
// The exit the stop itself causes is expected, so nothing awaits it then.
|
|
132
|
+
died.catch(() => {});
|
|
133
|
+
return {server, output, died};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Stop the server's process group; one that has already exited is fine. */
|
|
137
|
+
const stopServer = (server) => {
|
|
138
|
+
if (server.pid === undefined) return;
|
|
139
|
+
try {
|
|
140
|
+
process.kill(-server.pid, "SIGTERM");
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err?.code !== "ESRCH") throw err;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** The root sitemap once the server answers; keep polling only while the
|
|
147
|
+
* connection is refused. */
|
|
148
|
+
const waitForSitemap = async (base, output) => {
|
|
149
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
150
|
+
while (Date.now() < deadline) {
|
|
151
|
+
try {
|
|
152
|
+
return await readSitemap(base, SITEMAP);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (!(err instanceof TypeError)) throw err;
|
|
155
|
+
}
|
|
156
|
+
await sleep(READY_POLL_MS);
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`the server never answered ${SITEMAP}\n${output.join("")}`);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const report = (count, failures) => {
|
|
162
|
+
if (count === 0) {
|
|
163
|
+
console.error(`✗ check-routes: ${SITEMAP} lists no pages`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
if (failures.length > 0) {
|
|
167
|
+
console.error(`✗ check-routes: ${failures.length} of ${count} pages failed`);
|
|
168
|
+
for (const {path, status, error} of failures) {
|
|
169
|
+
console.error(` ${status ?? error} ${path}`);
|
|
170
|
+
}
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
console.log(`✓ check-routes: ${count} pages answered 200`);
|
|
174
|
+
return 0;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Start the server, check every page, stop it; the exit code. */
|
|
178
|
+
const run = async () => {
|
|
179
|
+
const port = await freePort();
|
|
180
|
+
const base = `http://${HOST}:${port}`;
|
|
181
|
+
const {server, output, died} = startServer(port);
|
|
182
|
+
try {
|
|
183
|
+
const rootXml = await Promise.race([waitForSitemap(base, output), died]);
|
|
184
|
+
const paths = await collectPaths(base, rootXml);
|
|
185
|
+
return report(paths.length, await failingPages(base, paths));
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error(`✗ check-routes: ${message(err)}`);
|
|
188
|
+
return 1;
|
|
189
|
+
} finally {
|
|
190
|
+
stopServer(server);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const main = async () => {
|
|
195
|
+
if (!existsSync(BUILD_ID)) {
|
|
196
|
+
console.error(
|
|
197
|
+
`✗ check-routes: no ${BUILD_ID}; run \`next build\` first`
|
|
198
|
+
+ " (a custom distDir is not supported)",
|
|
199
|
+
);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
const code = await run();
|
|
203
|
+
// The server's pipes can keep node alive past the stop; the check is done.
|
|
204
|
+
process.exit(code);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** True only when this file is the entry point. npm installs a bin as a
|
|
208
|
+
* symlink, so `process.argv[1]` is the link while `import.meta.url` is the
|
|
209
|
+
* target it resolves to: resolve before comparing, or `main` never runs. */
|
|
210
|
+
const isEntryPoint = () => {
|
|
211
|
+
try {
|
|
212
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (isEntryPoint()) await main();
|
package/bin/pre-push.sh
CHANGED
|
@@ -14,7 +14,12 @@ export PRE_PUSH_REFS
|
|
|
14
14
|
# owners and run it twice wherever `check` builds. A repo that needs a build
|
|
15
15
|
# gate adds `npm run build` to its own `check`; one that produces no artifact
|
|
16
16
|
# simply doesn't.
|
|
17
|
+
#
|
|
18
|
+
# `check:routes` runs after `check`, not in it: see the README, "Quality
|
|
19
|
+
# gates". Repos without it skip it. Every gate in PRE_PUSH
|
|
20
|
+
# (bin/check-gates.mjs) must be run here.
|
|
17
21
|
npm run check \
|
|
22
|
+
&& npm run --if-present check:routes \
|
|
18
23
|
&& echo "$PRE_PUSH_REFS" | check-version \
|
|
19
24
|
&& echo "$PRE_PUSH_REFS" | check-tags \
|
|
20
25
|
&& echo "$PRE_PUSH_REFS" | check-linear
|
package/bin/verify-pack.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// the allowlist and never look like a secret. Defence-in-depth on top of
|
|
5
5
|
// package.json "files" — this fails CI if that allowlist is ever loosened
|
|
6
6
|
// or a secret-bearing file sneaks in. The tooling repo is public, so the
|
|
7
|
-
// published/installed surface must stay configs
|
|
7
|
+
// published/installed surface must stay configs, CLIs and the build-time
|
|
8
|
+
// data sites import, and nothing private.
|
|
8
9
|
import {execSync} from "node:child_process";
|
|
9
10
|
|
|
10
11
|
// Dirs/files a consumer is allowed to receive. npm always adds
|
|
@@ -20,12 +21,21 @@ const ALLOWED_PREFIXES = [
|
|
|
20
21
|
"report/",
|
|
21
22
|
"styles/",
|
|
22
23
|
"buttondown/",
|
|
24
|
+
"identity/",
|
|
23
25
|
];
|
|
24
26
|
const ALLOWED_FILES = ["README.md", "package.json", "LICENSE", "LICENSE.md"];
|
|
25
27
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// A file whose name alone marks it as a secret must never ship, even from an
|
|
29
|
+
// allowed dir: an env file, local settings, a key, or an npm token file.
|
|
30
|
+
const SECRET_NAMES = new Set([".env", ".npmrc", "id_rsa", "id_dsa"]);
|
|
31
|
+
const SECRET_ENDINGS = [".pem", ".key", "settings.local.json"];
|
|
32
|
+
|
|
33
|
+
function looksSecret(path) {
|
|
34
|
+
const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
|
|
35
|
+
return SECRET_NAMES.has(name)
|
|
36
|
+
|| name.startsWith(".env.")
|
|
37
|
+
|| SECRET_ENDINGS.some((ending) => name.endsWith(ending));
|
|
38
|
+
}
|
|
29
39
|
|
|
30
40
|
// Tests live beside what they test, which puts them inside an allowed dir —
|
|
31
41
|
// so the allowlist alone would ship them. Consumers get the tool, not its
|
|
@@ -40,7 +50,7 @@ const escaped = files.filter(
|
|
|
40
50
|
!ALLOWED_FILES.includes(f) &&
|
|
41
51
|
!ALLOWED_PREFIXES.some((prefix) => f.startsWith(prefix)),
|
|
42
52
|
);
|
|
43
|
-
const secrets = files.filter(
|
|
53
|
+
const secrets = files.filter(looksSecret);
|
|
44
54
|
const tests = files.filter((f) => TEST_PATTERN.test(f));
|
|
45
55
|
|
|
46
56
|
if (escaped.length || secrets.length || tests.length) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Casomo Ltd as a schema.org `Organization`, for a page's JSON-LD.
|
|
2
|
+
*
|
|
3
|
+
* Every field is read-only because the value is frozen: spread it into an
|
|
4
|
+
* untyped object literal, or type a JSON-LD field that holds it as
|
|
5
|
+
* `readonly string[]`, never as `string[]`. */
|
|
6
|
+
export interface CasomoOrganization {
|
|
7
|
+
readonly "@type": "Organization";
|
|
8
|
+
readonly "@id": string;
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly url: string;
|
|
11
|
+
readonly logo: string;
|
|
12
|
+
readonly sameAs: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export declare const CASOMO_ORGANIZATION: CasomoOrganization;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public face of the identity entry: what a site imports. The value
|
|
3
|
+
* lives in organization.mjs, where the typecheck can read it against the
|
|
4
|
+
* declaration here; beside a .d.mts, TypeScript would skip it.
|
|
5
|
+
*/
|
|
6
|
+
export {CASOMO_ORGANIZATION} from "./organization.mjs";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Casomo Ltd as search engines should see it: the one schema.org
|
|
3
|
+
* `Organization` every Casomo site names in its JSON-LD. Each site imports
|
|
4
|
+
* this rather than typing its own, so no two sites can disagree about who
|
|
5
|
+
* publishes them, and `sameAs` ties them all to the same profiles.
|
|
6
|
+
*
|
|
7
|
+
* Public facts only: the company's name, its mark and the public addresses it
|
|
8
|
+
* is known by. A person, such as an article's author, stays with the site.
|
|
9
|
+
*/
|
|
10
|
+
const SITE = "https://casomoltd.com";
|
|
11
|
+
|
|
12
|
+
/** @type {import("./index.d.mts").CasomoOrganization} */
|
|
13
|
+
export const CASOMO_ORGANIZATION = Object.freeze({
|
|
14
|
+
"@type": "Organization",
|
|
15
|
+
// One id across every site, so a crawler merges them into one entity.
|
|
16
|
+
"@id": `${SITE}/#organization`,
|
|
17
|
+
name: "Casomo Ltd",
|
|
18
|
+
url: SITE,
|
|
19
|
+
// The company's mark, as its own site serves it. Each site keeps its own
|
|
20
|
+
// favicon and palette; this is the one logo the company is known by.
|
|
21
|
+
logo: `${SITE}/assets/icon.svg`,
|
|
22
|
+
sameAs: Object.freeze([
|
|
23
|
+
"https://casomo.co.uk",
|
|
24
|
+
"https://www.facebook.com/casomouk",
|
|
25
|
+
"https://www.linkedin.com/company/111967567/",
|
|
26
|
+
]),
|
|
27
|
+
});
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets
|
|
3
|
+
"version": "0.39.0",
|
|
4
|
+
"description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets, dev CLIs (git hooks, readability, screenshots, design specs), and the build-time data and helpers sites import (the Buttondown engine, the shared Organization)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/casomoltd/tooling.git"
|
|
8
|
+
"url": "git+https://github.com/casomoltd/tooling.git"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin/",
|
|
@@ -18,14 +18,16 @@
|
|
|
18
18
|
"report/",
|
|
19
19
|
"styles/",
|
|
20
20
|
"buttondown/",
|
|
21
|
+
"identity/",
|
|
21
22
|
"README.md",
|
|
22
|
-
"!**/*.test.mjs"
|
|
23
|
+
"!**/*.test.mjs",
|
|
24
|
+
"!identity/tsconfig.json"
|
|
23
25
|
],
|
|
24
26
|
"scripts": {
|
|
25
27
|
"check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run check:personal-data && npm run lint && npm run typecheck && npm run test && npm run lint:md",
|
|
26
28
|
"lint": "eslint .",
|
|
27
|
-
"typecheck": "tsc --noEmit -p hooks/tsconfig.json",
|
|
28
|
-
"test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-listed-files.test.mjs && node bin/check-personal-data.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs",
|
|
29
|
+
"typecheck": "tsc --noEmit -p hooks/tsconfig.json && tsc --noEmit -p identity/tsconfig.json",
|
|
30
|
+
"test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-listed-files.test.mjs && node bin/check-personal-data.test.mjs && node bin/check-routes.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs && node identity/index.test.mjs",
|
|
29
31
|
"lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
|
|
30
32
|
"build": "echo 'no-op: source-only repo, nothing to build'",
|
|
31
33
|
"prepare": "husky || true",
|
|
@@ -51,26 +53,31 @@
|
|
|
51
53
|
"./buttondown": {
|
|
52
54
|
"types": "./buttondown/index.d.mts",
|
|
53
55
|
"default": "./buttondown/index.mjs"
|
|
56
|
+
},
|
|
57
|
+
"./identity": {
|
|
58
|
+
"types": "./identity/index.d.mts",
|
|
59
|
+
"default": "./identity/index.mjs"
|
|
54
60
|
}
|
|
55
61
|
},
|
|
56
62
|
"bin": {
|
|
57
|
-
"build-report": "
|
|
58
|
-
"buttondown": "
|
|
59
|
-
"check-gates": "
|
|
60
|
-
"check-hooks": "
|
|
61
|
-
"check-listed-files": "
|
|
62
|
-
"check-linear": "
|
|
63
|
-
"check-personal-data": "
|
|
64
|
-
"check-private-refs": "
|
|
65
|
-
"check-
|
|
66
|
-
"check-
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"pre-
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
63
|
+
"build-report": "bin/build-report.mjs",
|
|
64
|
+
"buttondown": "bin/buttondown.mjs",
|
|
65
|
+
"check-gates": "bin/check-gates.mjs",
|
|
66
|
+
"check-hooks": "bin/check-hooks.sh",
|
|
67
|
+
"check-listed-files": "bin/check-listed-files.mjs",
|
|
68
|
+
"check-linear": "bin/check-linear.sh",
|
|
69
|
+
"check-personal-data": "bin/check-personal-data.mjs",
|
|
70
|
+
"check-private-refs": "bin/check-private-refs.mjs",
|
|
71
|
+
"check-routes": "bin/check-routes.mjs",
|
|
72
|
+
"check-tags": "bin/check-tags.sh",
|
|
73
|
+
"check-version": "bin/check-version.sh",
|
|
74
|
+
"commit-msg": "bin/commit-msg.sh",
|
|
75
|
+
"pre-commit": "bin/pre-commit.sh",
|
|
76
|
+
"pre-push": "bin/pre-push.sh",
|
|
77
|
+
"readability": "bin/readability.mjs",
|
|
78
|
+
"render-figures": "bin/render-figures.mjs",
|
|
79
|
+
"screenshot": "bin/screenshot.mjs",
|
|
80
|
+
"spec-check": "bin/spec-check.mjs"
|
|
74
81
|
},
|
|
75
82
|
"publishConfig": {
|
|
76
83
|
"access": "public"
|