@bongos/core 1.20.79 → 1.20.81
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/.bongos-core.json +104 -49
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0161-publish-on-merge.md +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +258 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +59 -53
- package/docs/copy-registry.json +120 -66
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +86 -81
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/cadence.js +3 -0
- package/modules/autonomy/db.js +136 -0
- package/modules/autonomy/fence.js +88 -3
- package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
- package/modules/autonomy/routes/autonomy.js +123 -3
- package/modules/government/catalog.js +9 -1
- package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
- package/modules/hall-ui/public/gate.html +5 -2
- package/modules/hall-ui/public/gate.js +73 -4
- package/modules/hall-ui/public/settings-autobongos.js +156 -0
- package/modules/hall-ui/public/settings.html +19 -0
- package/modules/hall-ui/public/settings.js +1 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/lifecycle.js +7 -0
- package/modules/lifecycle/workflow-dispatch.js +45 -0
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/public/work.js +74 -0
- package/modules/npm-release/release.js +129 -0
- package/modules/npm-release/routes/release.js +65 -0
- package/modules/npm-release/work.js +16 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/provision-core-upgrade.js +30 -2
- package/scripts/gds/release-core.js +80 -0
- package/scripts/gds/update-channel.js +12 -3
- package/src/bongos/core-update.js +17 -8
- package/src/module-api.js +1 -1
- package/tests/autonomy_builder_scope.mjs +474 -0
- package/tests/autonomy_fence_priority.mjs +3 -1
- package/tests/core_update_banner.mjs +41 -5
- package/tests/core_upgrade_runner.mjs +66 -1
- package/tests/npm_release_release.mjs +195 -0
- package/tests/release_core.mjs +110 -0
- package/tests/update_channel.mjs +7 -0
package/release-notes.json
CHANGED
|
@@ -8615,5 +8615,21 @@
|
|
|
8615
8615
|
"id": "1004484",
|
|
8616
8616
|
"text": "New projects no longer have their 'start' command pointing at the Bongos hall, so when an owner later connects Render with the usual 'npm start', it runs their app instead of a broken second copy of the hall."
|
|
8617
8617
|
}
|
|
8618
|
+
],
|
|
8619
|
+
"1.20.80": [
|
|
8620
|
+
{
|
|
8621
|
+
"id": "1004298",
|
|
8622
|
+
"text": "New versions can now run on Bongos first and reach other projects only when the owner presses Release on the deploy page, which shows how long each version has run on Bongos and whether it was ever rolled back. Switched off un"
|
|
8623
|
+
},
|
|
8624
|
+
{
|
|
8625
|
+
"id": "1003907",
|
|
8626
|
+
"text": "Autobongos has now been proven against the live project: it shipped work by itself with nobody watching, refused every kind of work it is not allowed to touch, and recovered from restarts and network failures on its own. The o"
|
|
8627
|
+
}
|
|
8628
|
+
],
|
|
8629
|
+
"1.20.81": [
|
|
8630
|
+
{
|
|
8631
|
+
"id": "1004501",
|
|
8632
|
+
"text": "Each builder now chooses which goals their own Autobongos runner works, in Settings, from the goals the project allows. A builder who chooses nothing gets no unattended work. Who may run a runner at all is now a project permis"
|
|
8633
|
+
}
|
|
8618
8634
|
]
|
|
8619
8635
|
}
|
|
@@ -166,6 +166,34 @@ function instanceChannel(inst, channelMod) {
|
|
|
166
166
|
return channelMod.normalizeChannel(inst && inst.update_channel != null ? inst.update_channel : channelMod.DEFAULT_CHANNEL);
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// The versions this project may be moved to: the published ones its FOLLOW admits (task 1004298,
|
|
170
|
+
// ADR 0361). The platform hall (control-plane) takes every candidate; every other project only what
|
|
171
|
+
// has been released, and NOTHING when the registry cannot say which version that is — a project that
|
|
172
|
+
// follows releases is never moved on a guess. A channel module without the follow (an old core
|
|
173
|
+
// resolved at drain time) offers the whole list, which is what every version meant before candidates.
|
|
174
|
+
//
|
|
175
|
+
// The released label is one fact about the registry, the same for every project in a pass, and
|
|
176
|
+
// reading it is a blocking `npm view`. So it is read once per channel module per RELEASE_TAGS_TTL_MS
|
|
177
|
+
// and shared: a pass over N projects costs one extra subprocess, not N. A failed read is not kept.
|
|
178
|
+
const RELEASE_TAGS_TTL_MS = 60 * 1000;
|
|
179
|
+
const releaseTagsMemo = new WeakMap();
|
|
180
|
+
function releaseTagsFor(channelMod, instanceDir, now = Date.now()) {
|
|
181
|
+
const hit = releaseTagsMemo.get(channelMod);
|
|
182
|
+
if (hit && now - hit.at < RELEASE_TAGS_TTL_MS) return hit.tags;
|
|
183
|
+
const tags = channelMod.readReleaseTags({ cwd: instanceDir });
|
|
184
|
+
if (tags && tags.ok) releaseTagsMemo.set(channelMod, { at: now, tags });
|
|
185
|
+
return tags;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function offeredVersions(channelMod, inst, instanceDir) {
|
|
189
|
+
const listed = channelMod.listAvailableVersions({ cwd: instanceDir });
|
|
190
|
+
if (!listed || !listed.ok || typeof channelMod.followForShape !== 'function') return listed;
|
|
191
|
+
if (channelMod.followForShape(inst && inst.hosting_shape) === 'candidates') return listed;
|
|
192
|
+
const tags = releaseTagsFor(channelMod, instanceDir);
|
|
193
|
+
if (!tags.ok) return { ok: false, error: `could not read which version is released (${tags.error})`, versions: [] };
|
|
194
|
+
return { ok: true, versions: channelMod.versionsForFollow({ follow: 'released', available: listed.versions, released: tags.released }) };
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
// A classified refusal (task 1004446): the drain loop stores `failure` beside last_error,
|
|
170
198
|
// so the rest of the self-heal chain acts on a class, not on a sentence. PURE.
|
|
171
199
|
function failed(reason, error, extra = {}) {
|
|
@@ -246,7 +274,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
246
274
|
// pointed at nothing. A row from before the column (undefined) still normalises to that
|
|
247
275
|
// default, so an old runner schema reads exactly as it always did.
|
|
248
276
|
const channel = instanceChannel(inst, channelMod);
|
|
249
|
-
const listed = channelMod
|
|
277
|
+
const listed = offeredVersions(channelMod, inst, instanceDir);
|
|
250
278
|
const available = (listed && listed.versions) || [];
|
|
251
279
|
const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
|
|
252
280
|
if (fault) return failed(outcome.runnerReason(fault), fault);
|
|
@@ -1013,7 +1041,7 @@ async function snapshotCoreVersions(inst, deps) {
|
|
|
1013
1041
|
const body = probe && !probe.softFailed ? (probe.stdout || '') : null;
|
|
1014
1042
|
const served = parseServedVersion(body);
|
|
1015
1043
|
const schemaPending = parseSchemaPending(body);
|
|
1016
|
-
const listed = channelMod
|
|
1044
|
+
const listed = offeredVersions(channelMod, inst, instanceDir);
|
|
1017
1045
|
const available = (listed && listed.versions) || [];
|
|
1018
1046
|
// What the channel would take FROM the version the box is actually on. Resolving it
|
|
1019
1047
|
// here rather than in the browser is what keeps the button's promise and the
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// scripts/gds/release-core.js — may this version of @bongos/core be RELEASED? (task 1004298, ADR 0361)
|
|
4
|
+
//
|
|
5
|
+
// Merges publish as candidates (under the `next` label). A version reaches other projects only when
|
|
6
|
+
// the owner presses Release on /deploy, which runs publish.yml's release job, which asks this script
|
|
7
|
+
// and then moves the registry's `latest` label. Every consumer that offers a version to a project
|
|
8
|
+
// other than the platform hall reads `latest` (update-channel.js versionsForFollow), so this label IS
|
|
9
|
+
// the release.
|
|
10
|
+
//
|
|
11
|
+
// WHY A SCRIPT AND NOT A SHELL STEP. The rule has four ways to say no, and a shell `if` chain inside
|
|
12
|
+
// a workflow file is the one place nothing can test it. The decision is pure and unit-tested
|
|
13
|
+
// (tests/release_core.mjs); the workflow only runs it and acts on `go`.
|
|
14
|
+
//
|
|
15
|
+
// THE RULE:
|
|
16
|
+
// - the version must be a plain x.y.z (it is interpolated into an npm command line);
|
|
17
|
+
// - it must be PUBLISHED — Release labels a version, it never builds one;
|
|
18
|
+
// - it must be newer than the current release. Releasing an OLDER version would move every
|
|
19
|
+
// project's offer backwards, which is a rollback for everyone, not a release; that is a
|
|
20
|
+
// different act and needs a person at a terminal, not a button.
|
|
21
|
+
// - releasing the version already released is a no-op, not an error — a double press is harmless.
|
|
22
|
+
//
|
|
23
|
+
// Usage (in publish.yml's release job):
|
|
24
|
+
// node scripts/gds/release-core.js --version 1.20.80
|
|
25
|
+
// Prints the decision; writes go=true|false to $GITHUB_OUTPUT when set. Exit 0 for go and no-op,
|
|
26
|
+
// 1 for a refusal.
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
const { CORE_PKG, parseSemver, compareSemver, isStable } = require('./update-channel.js');
|
|
31
|
+
|
|
32
|
+
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* PURE. `versions`: every published version. `latest`: the version the registry labels `latest`
|
|
36
|
+
* now, or null. Returns { go, noop, reason }.
|
|
37
|
+
*/
|
|
38
|
+
function releaseDecision({ version, versions, latest } = {}) {
|
|
39
|
+
const v = String(version == null ? '' : version).trim();
|
|
40
|
+
if (!VERSION_RE.test(v)) return { go: false, noop: false, reason: `"${v}" is not a version (x.y.z)` };
|
|
41
|
+
const list = Array.isArray(versions) ? versions : [];
|
|
42
|
+
if (!list.includes(v)) return { go: false, noop: false, reason: `${v} is not published — Release labels a published version, it never builds one` };
|
|
43
|
+
if (!isStable(v)) return { go: false, noop: false, reason: `${v} is a prerelease` };
|
|
44
|
+
if (latest && parseSemver(latest)) {
|
|
45
|
+
const c = compareSemver(v, latest);
|
|
46
|
+
if (c === 0) return { go: false, noop: true, reason: `${v} is already the release` };
|
|
47
|
+
if (c < 0) return { go: false, noop: false, reason: `${v} is older than the current release ${latest} — releasing it would move every project backwards` };
|
|
48
|
+
}
|
|
49
|
+
return { go: true, noop: false, reason: latest ? `release ${v} (was ${latest})` : `release ${v} (no release yet)` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readRegistry({ pkg = CORE_PKG, run = spawnSync } = {}) {
|
|
53
|
+
const res = run('npm', ['view', pkg, 'versions', 'dist-tags', '--json'], { encoding: 'utf8', timeout: 60000 });
|
|
54
|
+
if (!res || res.status !== 0) return { ok: false, error: String((res && res.stderr) || 'npm view failed').trim().split('\n')[0] };
|
|
55
|
+
try {
|
|
56
|
+
const doc = JSON.parse(String(res.stdout || '{}'));
|
|
57
|
+
const versions = Array.isArray(doc.versions) ? doc.versions : (typeof doc.versions === 'string' ? [doc.versions] : []);
|
|
58
|
+
const tags = doc['dist-tags'] || {};
|
|
59
|
+
return { ok: true, versions, latest: typeof tags.latest === 'string' ? tags.latest : null };
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { ok: false, error: `unparseable npm output: ${e.message}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function main(argv = process.argv.slice(2), { env = process.env, read = readRegistry, log = console.log, err = console.error } = {}) {
|
|
66
|
+
const i = argv.indexOf('--version');
|
|
67
|
+
const version = i >= 0 ? argv[i + 1] : '';
|
|
68
|
+
const out = (go) => { if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `go=${go}\n`); };
|
|
69
|
+
const reg = read();
|
|
70
|
+
if (!reg.ok) { err(`release-core: could not read the registry (${reg.error})`); out(false); return 1; }
|
|
71
|
+
const d = releaseDecision({ version, versions: reg.versions, latest: reg.latest });
|
|
72
|
+
out(d.go);
|
|
73
|
+
if (d.go || d.noop) { log(`release-core: ${d.reason}`); return 0; }
|
|
74
|
+
err(`release-core: refused — ${d.reason}`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (require.main === module) process.exit(main());
|
|
79
|
+
|
|
80
|
+
module.exports = { releaseDecision, main };
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
// has released to other projects. DEFAULT, for every project.
|
|
24
24
|
// candidates — every published version, the moment it is published. The platform's own hall, and
|
|
25
25
|
// only it: Bongos runs each version before anyone else is offered it.
|
|
26
|
-
// The channel bounds how FAR a jump may go; the follow bounds which versions exist to jump to.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// The channel bounds how FAR a jump may go; the follow bounds which versions exist to jump to. Once
|
|
27
|
+
// merges publish as candidates (task 1004298, ADR 0361), `latest` moves only when the owner presses
|
|
28
|
+
// Release, so the two follows part: the platform hall takes each candidate, everyone else the release.
|
|
29
29
|
//
|
|
30
30
|
// WHY the registry as the source (not vendored tarballs): a subscription needs a LIVE source of
|
|
31
31
|
// truth for "what's the newest published core?" — that's `npm view @bongos/core versions` against the
|
|
@@ -139,6 +139,14 @@ function versionsForFollow({ follow, available, released } = {}) {
|
|
|
139
139
|
return list.filter((v) => compareSemver(v, released) <= 0);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// The follow a provisioned project takes, from its hosting shape (task 1004298, ADR 0361). The
|
|
143
|
+
// control-plane row IS the platform's own hall, the one project that runs every candidate; every other
|
|
144
|
+
// shape follows releases. Derived, not stored: there is exactly one platform hall, and a column an
|
|
145
|
+
// operator could set on a tenant would be one more way to hand a project an unreleased version.
|
|
146
|
+
function followForShape(hostingShape) {
|
|
147
|
+
return hostingShape === 'control-plane' ? 'candidates' : DEFAULT_FOLLOW;
|
|
148
|
+
}
|
|
149
|
+
|
|
142
150
|
// Would `channel` permit an unattended jump from `installed` to `candidate`? True only when candidate
|
|
143
151
|
// is a STABLE release strictly newer than installed AND within the channel's blast radius. Any invalid
|
|
144
152
|
// input, an unknown installed version, a prerelease candidate, or a major bump ⇒ false (fail-closed).
|
|
@@ -463,6 +471,7 @@ module.exports = {
|
|
|
463
471
|
normalizeFollow,
|
|
464
472
|
versionsForFollow,
|
|
465
473
|
readReleaseTags,
|
|
474
|
+
followForShape,
|
|
466
475
|
SUBSCRIPTIONS_REL,
|
|
467
476
|
parseSemver,
|
|
468
477
|
isStable,
|
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
// OWN function (scripts/gds/update-channel.js) and is imported, not copied: the
|
|
26
26
|
// banner must never offer a version the runner would then refuse, and ADR 0280
|
|
27
27
|
// is the record of what a second copy of this contract costs when it drifts.
|
|
28
|
+
//
|
|
29
|
+
// ONLY RELEASED VERSIONS ARE OFFERED (task 1004298, ADR 0361). Merges publish as candidates; a
|
|
30
|
+
// version reaches other projects when the owner presses Release, which moves the registry's
|
|
31
|
+
// `latest` label. So the banner offers versions at or below `latest` (versionsForFollow, the same
|
|
32
|
+
// function the sweep uses), and with no readable label it has no answer rather than offering a
|
|
33
|
+
// candidate. The platform hall runs ahead of `latest` by design, so it simply reads "current".
|
|
28
34
|
|
|
29
35
|
const https = require('node:https');
|
|
30
36
|
const {
|
|
@@ -32,6 +38,7 @@ const {
|
|
|
32
38
|
DEFAULT_CHANNEL,
|
|
33
39
|
normalizeChannel,
|
|
34
40
|
resolveChannelTarget,
|
|
41
|
+
versionsForFollow,
|
|
35
42
|
} = require('../../scripts/gds/update-channel.js');
|
|
36
43
|
|
|
37
44
|
const REGISTRY_ORIGIN = 'https://registry.npmjs.org';
|
|
@@ -50,9 +57,9 @@ const FETCH_TIMEOUT_MS = 4000;
|
|
|
50
57
|
* same field for the same reason; the failure it prevents is a project that is
|
|
51
58
|
* months behind being told, confidently, that it is current.
|
|
52
59
|
*/
|
|
53
|
-
function coreUpdateVerdict({ running, available, channel } = {}) {
|
|
60
|
+
function coreUpdateVerdict({ running, available, released, channel } = {}) {
|
|
54
61
|
const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
|
|
55
|
-
const versions =
|
|
62
|
+
const versions = versionsForFollow({ follow: 'released', available, released });
|
|
56
63
|
if (!running || typeof running !== 'string' || !versions.length) {
|
|
57
64
|
return { running: running || null, latest: null, behind: false, reading: 'no_answer', channel: ch };
|
|
58
65
|
}
|
|
@@ -88,7 +95,9 @@ function fetchPublishedVersions({ pkg = CORE_PKG, origin = REGISTRY_ORIGIN, time
|
|
|
88
95
|
try {
|
|
89
96
|
const doc = JSON.parse(body);
|
|
90
97
|
const versions = Object.keys((doc && doc.versions) || {});
|
|
91
|
-
|
|
98
|
+
const tag = doc && doc['dist-tags'] && doc['dist-tags'].latest;
|
|
99
|
+
const released = typeof tag === 'string' ? tag : null;
|
|
100
|
+
done(versions.length ? { ok: true, versions, released } : { ok: false, versions: [], error: 'no versions in packument' });
|
|
92
101
|
} catch (e) { done({ ok: false, versions: [], error: `unparseable packument: ${e.message}` }); }
|
|
93
102
|
});
|
|
94
103
|
});
|
|
@@ -103,13 +112,13 @@ function fetchPublishedVersions({ pkg = CORE_PKG, origin = REGISTRY_ORIGIN, time
|
|
|
103
112
|
|
|
104
113
|
// Process-local cache. Deliberately not a table: it is a derived fact with a
|
|
105
114
|
// six-hour life, and a restart re-deriving it costs one request.
|
|
106
|
-
const cache = { at: 0, versions: [], ok: false, inflight: null };
|
|
115
|
+
const cache = { at: 0, versions: [], released: null, ok: false, inflight: null };
|
|
107
116
|
|
|
108
117
|
|
|
109
118
|
|
|
110
119
|
async function publishedVersions({ now = Date.now, ttlMs = TTL_MS, fetch = fetchPublishedVersions, store = cache } = {}) {
|
|
111
120
|
const t = now();
|
|
112
|
-
if (store.ok && t - store.at < ttlMs) return { ok: true, versions: store.versions, cached: true };
|
|
121
|
+
if (store.ok && t - store.at < ttlMs) return { ok: true, versions: store.versions, released: store.released, cached: true };
|
|
113
122
|
// IN-FLIGHT DE-DUPE. Without it, every request arriving during a cold-cache
|
|
114
123
|
// window starts its OWN registry read — the cache only collapses them once the
|
|
115
124
|
// first one has landed. Small, because the audience is archon-only and the TTL
|
|
@@ -119,8 +128,8 @@ async function publishedVersions({ now = Date.now, ttlMs = TTL_MS, fetch = fetch
|
|
|
119
128
|
store.inflight = (async () => {
|
|
120
129
|
try {
|
|
121
130
|
const r = await fetch();
|
|
122
|
-
if (r.ok) { store.ok = true; store.at = t; store.versions = r.versions; }
|
|
123
|
-
return { ok: r.ok, versions: r.versions, cached: false, error: r.error };
|
|
131
|
+
if (r.ok) { store.ok = true; store.at = t; store.versions = r.versions; store.released = r.released || null; }
|
|
132
|
+
return { ok: r.ok, versions: r.versions, released: r.released || null, cached: false, error: r.error };
|
|
124
133
|
} finally {
|
|
125
134
|
store.inflight = null;
|
|
126
135
|
}
|
|
@@ -141,7 +150,7 @@ async function publishedVersions({ now = Date.now, ttlMs = TTL_MS, fetch = fetch
|
|
|
141
150
|
async function readCoreUpdate({ running, channel, get, ...deps } = {}) {
|
|
142
151
|
const fetch = deps.fetch || (get ? () => fetchPublishedVersions({ get }) : fetchPublishedVersions);
|
|
143
152
|
const r = await publishedVersions({ ...deps, fetch });
|
|
144
|
-
return coreUpdateVerdict({ running, available: r.ok ? r.versions : [], channel });
|
|
153
|
+
return coreUpdateVerdict({ running, available: r.ok ? r.versions : [], released: r.ok ? r.released : null, channel });
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
// readCoreUpgrades — the instance's own core-upgrade ledger, newest first: when the
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.81'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|