@eventmodelers/cli 1.0.90 → 1.0.91
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 +2 -0
- package/cli.js +26 -2
- package/lib/update-check.js +67 -0
- package/package.json +4 -1
- package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +11 -3
- package/shared/build-kit/lib/adapters/realtime-adapter.js +3 -1
- package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +14 -3
- package/shared/build-kit/lib/ralph.js +136 -59
- package/shared/build-kit/lib/redact.js +36 -0
- package/shared/build-kit/lib/turn.js +56 -20
- package/shared/build-kit/ralph-claude.js +7 -19
- package/shared/build-kit/ralph-exec.js +5 -9
- package/shared/build-kit/realtime-agent.js +4 -2
- package/shared/skills/connect/SKILL.md +14 -9
- package/shared/skills/learn-eventmodelers-api/SKILL.md +60 -21
- package/shared/skills/load-slice/SKILL.md +3 -1
- package/stacks/modeling-kit/templates/.claude/skills/add-next-slice/SKILL.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +18 -9
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/references/api-fallback.md +31 -11
- package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +16 -13
- package/stacks/modeling-kit/templates/.claude/skills/attributes/references/api-fallback.md +3 -1
- package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/SKILL.md +6 -6
- package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/references/api-fallback.md +3 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/SKILL.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/references/api-fallback.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +8 -12
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/references/api-fallback.md +16 -6
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +3 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/references/api-fallback.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/SKILL.md +2 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/references/api-fallback.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/references/api-fallback.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +3 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/references/api-fallback.md +3 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/SKILL.md +2 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/references/api-fallback.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +3 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/references/api-fallback.md +12 -4
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +7 -6
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/references/api-fallback.md +4 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +22 -6
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/references/api-fallback.md +12 -3
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/SKILL.md +18 -6
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/references/api-fallback.md +12 -3
- package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +2 -2
- package/stacks/modeling-kit/templates/.claude/skills/examples/references/api-fallback.md +20 -14
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +12 -12
- package/stacks/modeling-kit/templates/.claude/skills/place-element/references/api-fallback.md +6 -4
- package/stacks/modeling-kit/templates/.claude/skills/storyboard/SKILL.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/storyboard/references/api-fallback.md +2 -2
- package/stacks/modeling-kit/templates/.claude/skills/timeline/SKILL.md +10 -10
- package/stacks/modeling-kit/templates/.claude/skills/timeline/references/api-fallback.md +4 -4
- package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +11 -24
- package/stacks/modeling-kit/templates/kit/AGENTS.md +3 -2
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +6 -4
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +9 -4
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +106 -102
- package/stacks/react/templates/build-kit/lib/prompt.md +102 -106
package/README.md
CHANGED
|
@@ -635,6 +635,8 @@ npx @eventmodelers/cli re-init --stack supabase # override which stack to r
|
|
|
635
635
|
|
|
636
636
|
`re-init` re-runs `init` against whichever stack `install-manifest.json` says was installed (no need to pass `--stack` again), but skips step 2 of `init` entirely — the root project scaffold (`package.json`, `src/`, `server.ts`, `docker-compose.yml`, etc.) and the root `CLAUDE.md` router are never touched. Use it after upgrading the CLI to pick up fixes to `ralph.js`/`ralph.sh`/skills without re-scaffolding a project you've since built on top of.
|
|
637
637
|
|
|
638
|
+
You don't have to remember to: on startup every command prints a 💡 hint (on stderr) when a newer CLI is on npm, or when a kit in the current directory was installed by an older CLI than the one running — naming the exact `re-init` command to run. The npm lookup happens at most once a day (cached in `~/.eventmodelers/update-check.json`, 1.5 s timeout, silent when offline). Set `EVENTMODELERS_NO_UPDATE_CHECK=1` to turn it off; it is also off when `CI` is set.
|
|
639
|
+
|
|
638
640
|
Pass `--stack <name>` to override the stack instead of relying on the manifest — useful if the manifest is missing/stale, or you want to point a `.build-kit` install at a different built-in stack's templates. It's mutually exclusive with `--modeling`.
|
|
639
641
|
|
|
640
642
|
Credentials are left alone unless you pass `--force` — same rule `init` already follows when everything required is already configured. `--global` defaults to however skills were originally installed; pass it explicitly to move them.
|
package/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
|
|
|
21
21
|
import { runFetch, FetchAuthError } from './lib/fetch.js';
|
|
22
22
|
import { createModelingLocalAiRunner } from './lib/modeling-local-ai.js';
|
|
23
23
|
import { run as runSpecKittyAdapter } from './lib/adapters/spec-kitty-adapter.js';
|
|
24
|
+
import { latestPublishedVersion, updateHints } from './lib/update-check.js';
|
|
24
25
|
// Not a root-level adapter like spec-kitty-adapter.js above: this is the one canonical
|
|
25
26
|
// copy that every useShared:true stack also gets copied into its installed kit (see
|
|
26
27
|
// copyDirContents in installStack) for ralph.js to import standalone — see
|
|
@@ -1282,6 +1283,11 @@ async function configureCredentials({ config, configPath, targetDir, requiredFie
|
|
|
1282
1283
|
async function refreshSharedRuntime({ targetDir, kitDir, globalOpts, opts }) {
|
|
1283
1284
|
console.log(`📦 Refreshing the shared runtime in ${relative(targetDir, kitDir)}/ (stack files untouched)...`);
|
|
1284
1285
|
copyDirContents(join(__dirname, 'shared', 'build-kit'), kitDir);
|
|
1286
|
+
// The kit is now this CLI's runtime — record that, so the startup "kit is outdated" hint stops.
|
|
1287
|
+
const manifestPath = join(kitDir, '.eventmodelers', 'install-manifest.json');
|
|
1288
|
+
if (existsSync(manifestPath)) {
|
|
1289
|
+
writeFileSync(manifestPath, JSON.stringify({ ...readJsonSafe(manifestPath), version: CLI_VERSION }, null, 2));
|
|
1290
|
+
}
|
|
1285
1291
|
for (const script of ['ralph.sh', 'lib/agent.sh', 'ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
|
|
1286
1292
|
const p = join(kitDir, script);
|
|
1287
1293
|
if (existsSync(p)) {
|
|
@@ -1943,13 +1949,16 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
|
|
|
1943
1949
|
// board-change turn can just as well be the first turn a (re)spawned process
|
|
1944
1950
|
// ever sees, so both turn builders go through this rather than buildTurn owning it.
|
|
1945
1951
|
function withSessionHeader(body) {
|
|
1952
|
+
// token= names the env var, never its value: claudeEnv already carries EVENTMODELERS_TOKEN,
|
|
1953
|
+
// and a literal token here would sit in every session transcript (the connect skill treats
|
|
1954
|
+
// the reference as an inline token — see its Step 0).
|
|
1946
1955
|
// Nothing to prepend for a local model: it has no CLAUDE.md to read and no /connect to run
|
|
1947
1956
|
// (its tools are already authenticated), and handing it the raw token would put credentials
|
|
1948
1957
|
// in a context that has no way to use them.
|
|
1949
1958
|
if (localRunner) return body;
|
|
1950
1959
|
if (!firstTurn) return body;
|
|
1951
1960
|
firstTurn = false;
|
|
1952
|
-
return `MODE=modeling token=$
|
|
1961
|
+
return `MODE=modeling token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''} subagent_model=${subagentModel}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
|
|
1953
1962
|
}
|
|
1954
1963
|
|
|
1955
1964
|
function buildTurn(p) {
|
|
@@ -2705,7 +2714,7 @@ const program = new Command();
|
|
|
2705
2714
|
program
|
|
2706
2715
|
.name('eventmodelers')
|
|
2707
2716
|
.description('Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack')
|
|
2708
|
-
.version(
|
|
2717
|
+
.version(CLI_VERSION)
|
|
2709
2718
|
.option('--config <path>', 'Path to an explicit config.json, overriding directory-based resolution (individual fields can also be set via EVENTMODELERS_* env vars, which always win)')
|
|
2710
2719
|
.option('--print', 'Print follow-up commands (e.g. claude mcp add) instead of prompting to run them');
|
|
2711
2720
|
|
|
@@ -2725,6 +2734,21 @@ program
|
|
|
2725
2734
|
// than this generic gate can.
|
|
2726
2735
|
const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run']);
|
|
2727
2736
|
|
|
2737
|
+
// Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
|
|
2738
|
+
// front of them would only be noise.
|
|
2739
|
+
const NO_KIT_UPDATE_HINT = new Set(['init', 're-init', 'uninstall']);
|
|
2740
|
+
|
|
2741
|
+
// Startup hint when this CLI is behind npm, or an installed kit here is behind this CLI.
|
|
2742
|
+
// Best effort and on stderr, so it never breaks piped output; EVENTMODELERS_NO_UPDATE_CHECK
|
|
2743
|
+
// (or CI) turns it off.
|
|
2744
|
+
program.hook('preAction', async (_thisCommand, actionCommand) => {
|
|
2745
|
+
if (process.env.EVENTMODELERS_NO_UPDATE_CHECK || process.env.CI) return;
|
|
2746
|
+
const latestVersion = await latestPublishedVersion({ cachePath: join(GLOBAL_DIR, 'update-check.json') });
|
|
2747
|
+
const kitDirs = NO_KIT_UPDATE_HINT.has(actionCommand.name()) ? [] : findAllInstalledKitDirs(process.cwd());
|
|
2748
|
+
const hints = updateHints({ currentVersion: CLI_VERSION, latestVersion, kitDirs });
|
|
2749
|
+
if (hints.length) console.error(`${hints.join('\n')}\n`);
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2728
2752
|
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
2729
2753
|
if (NO_INIT_REQUIRED.has(actionCommand.name())) return;
|
|
2730
2754
|
if (findInstalledKitDir(process.cwd())) return;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { basename, dirname, join, relative } from 'path';
|
|
3
|
+
|
|
4
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@eventmodelers/cli/latest';
|
|
5
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
7
|
+
|
|
8
|
+
// Kit dirs `re-init` can refresh, and how to ask it to.
|
|
9
|
+
const REINIT_COMMANDS = {
|
|
10
|
+
'.build-kit': 'eventmodelers re-init',
|
|
11
|
+
'.agent-modeling-kit': 'eventmodelers re-init --modeling',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Numeric compare of x.y.z versions — negative when a < b. Pre-release suffixes are ignored.
|
|
15
|
+
export function compareVersions(a, b) {
|
|
16
|
+
const pa = String(a).split('-')[0].split('.').map(Number);
|
|
17
|
+
const pb = String(b).split('-')[0].split('.').map(Number);
|
|
18
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
19
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
20
|
+
if (d) return d;
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The latest published version, asked of the npm registry at most once a day — in between,
|
|
30
|
+
// the answer cached at cachePath is reused. Best effort: offline, slow or broken registry
|
|
31
|
+
// responses just yield whatever was cached last (or null).
|
|
32
|
+
export async function latestPublishedVersion({ cachePath, now = Date.now(), fetchImpl = fetch } = {}) {
|
|
33
|
+
const cache = readJson(cachePath);
|
|
34
|
+
if (cache.latest && now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS) return cache.latest;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetchImpl(REGISTRY_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
38
|
+
if (!res.ok) return cache.latest || null;
|
|
39
|
+
const { version } = await res.json();
|
|
40
|
+
if (!version) return cache.latest || null;
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
43
|
+
writeFileSync(cachePath, JSON.stringify({ latest: version, checkedAt: now }));
|
|
44
|
+
} catch {}
|
|
45
|
+
return version;
|
|
46
|
+
} catch {
|
|
47
|
+
return cache.latest || null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Hint lines for everything that's behind: this CLI against npm, and each installed kit
|
|
52
|
+
// against the CLI running now (its install manifest records the version that wrote it).
|
|
53
|
+
export function updateHints({ currentVersion, latestVersion, kitDirs = [], cwd = process.cwd() }) {
|
|
54
|
+
const hints = [];
|
|
55
|
+
if (latestVersion && compareVersions(currentVersion, latestVersion) < 0) {
|
|
56
|
+
hints.push(`💡 A new eventmodelers CLI is available: ${currentVersion} → ${latestVersion}. Update with: npm i -g @eventmodelers/cli@latest (or use npx @eventmodelers/cli@latest)`);
|
|
57
|
+
}
|
|
58
|
+
for (const kitDir of kitDirs) {
|
|
59
|
+
const reinit = REINIT_COMMANDS[basename(kitDir)];
|
|
60
|
+
const manifestPath = join(kitDir, '.eventmodelers', 'install-manifest.json');
|
|
61
|
+
if (!reinit || !existsSync(manifestPath)) continue;
|
|
62
|
+
const kitVersion = readJson(manifestPath).version;
|
|
63
|
+
if (kitVersion && compareVersions(kitVersion, currentVersion) >= 0) continue;
|
|
64
|
+
hints.push(`💡 ${relative(cwd, kitDir) || kitDir}/ was installed by CLI ${kitVersion || '(unknown version)'} — refresh it to ${currentVersion} with: ${reinit}`);
|
|
65
|
+
}
|
|
66
|
+
return hints;
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.91",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"eventmodelers": "cli.js"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test test/*.test.js"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"cli.js",
|
|
11
14
|
"lib",
|
|
@@ -16,18 +16,26 @@ const REALTIME_EVENTS_COLLECTION = 'realtime_events';
|
|
|
16
16
|
// ourselves.
|
|
17
17
|
const RECONNECT_INTERVALS_MS = [200, 300, 500, 1000, 1200, 1500, 2000];
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// deps.PocketBase is injectable for tests; by default it is the pocketbase SDK's client.
|
|
20
|
+
export async function createPocketBaseRealtimeAdapter(cfg, initialToken, deps = {}) {
|
|
20
21
|
const { EventSource } = await import('eventsource');
|
|
21
22
|
if (!globalThis.EventSource) globalThis.EventSource = EventSource; // PocketBase's SDK assumes a browser-style global
|
|
22
|
-
const
|
|
23
|
+
const PocketBase = deps.PocketBase ?? (await import('pocketbase')).default;
|
|
23
24
|
const pb = new PocketBase(cfg.pocketbaseUrl);
|
|
24
25
|
pb.authStore.save(initialToken, null);
|
|
25
26
|
|
|
27
|
+
// subscribe() replaces the previous listener (see realtime-adapter.js).
|
|
28
|
+
let unsubscribe = null;
|
|
29
|
+
|
|
26
30
|
return {
|
|
27
31
|
async subscribe(topic, handlers, onStatus) {
|
|
32
|
+
if (unsubscribe) {
|
|
33
|
+
await unsubscribe().catch(() => {});
|
|
34
|
+
unsubscribe = null;
|
|
35
|
+
}
|
|
28
36
|
for (let attempt = 0; ; attempt++) {
|
|
29
37
|
try {
|
|
30
|
-
await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
|
|
38
|
+
unsubscribe = await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
|
|
31
39
|
if (e.action !== 'create' || e.record.topic !== topic) return;
|
|
32
40
|
handlers[e.record.event]?.(e.record.payload);
|
|
33
41
|
});
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// record-change events for self-hosted/on-prem) and hands back one common interface:
|
|
4
4
|
//
|
|
5
5
|
// subscribe(topic, handlers, onStatus?) — handlers is a { [eventName]: (payload) => void }
|
|
6
|
-
// map; onStatus?.(status) fires once the subscription is live ('SUBSCRIBED').
|
|
6
|
+
// map; onStatus?.(status) fires once the subscription is live ('SUBSCRIBED'). Calling it
|
|
7
|
+
// again replaces the previous subscription — never adds to it — and a replaced one no
|
|
8
|
+
// longer reports status.
|
|
7
9
|
// setAuth(token) — refresh the short-lived realtime token on the live connection.
|
|
8
10
|
//
|
|
9
11
|
// cfg.realtimeProvider absent (older servers, predating PocketBase support) means
|
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
// broadcast channel and dispatches named broadcast events to caller-supplied handlers.
|
|
3
3
|
// See realtime-adapter.js for the interface both this and the PocketBase adapter implement.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// deps.createClient is injectable for tests; by default it is supabase-js's own.
|
|
6
|
+
export async function createSupabaseRealtimeAdapter(cfg, initialToken, deps = {}) {
|
|
7
|
+
const createClient = deps.createClient ?? (await import('@supabase/supabase-js')).createClient;
|
|
7
8
|
const supabase = createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, {
|
|
8
9
|
realtime: { params: { apikey: cfg.supabaseAnonKey } },
|
|
9
10
|
});
|
|
10
11
|
await supabase.realtime.setAuth(initialToken);
|
|
11
12
|
|
|
13
|
+
// supabase.channel(topic) hands back the existing channel for a topic it already knows, so
|
|
14
|
+
// re-subscribing on it would stack another set of handlers — the old channel is removed first.
|
|
15
|
+
let current = null;
|
|
16
|
+
|
|
12
17
|
return {
|
|
13
18
|
async subscribe(topic, handlers, onStatus) {
|
|
19
|
+
// Detach before removing: the removal itself makes the old channel report CLOSED, and a
|
|
20
|
+
// late status from a replaced channel must not count as this subscription's.
|
|
21
|
+
const previous = current;
|
|
22
|
+
current = null;
|
|
23
|
+
if (previous) await supabase.removeChannel(previous).catch(() => {});
|
|
14
24
|
let channel = supabase.channel(topic, { config: { private: true } });
|
|
15
25
|
for (const [event, handler] of Object.entries(handlers)) {
|
|
16
26
|
channel = channel.on('broadcast', { event }, (msg) => handler(msg.payload));
|
|
17
27
|
}
|
|
18
|
-
|
|
28
|
+
current = channel;
|
|
29
|
+
channel.subscribe((status) => { if (channel === current) onStatus?.(status); });
|
|
19
30
|
},
|
|
20
31
|
setAuth(token) {
|
|
21
32
|
return supabase.realtime.setAuth(token);
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
// onPlannedSlice(prompt, slice) — called when .slices/ has a "Planned" entry (omit to skip);
|
|
7
7
|
// `slice` ({sliceId, sliceTitle, context, ticketNumber, boardId, attempt}) lets a runner record what building it cost.
|
|
8
8
|
|
|
9
|
-
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
|
9
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, renameSync } from 'fs';
|
|
10
10
|
import { join, dirname } from 'path';
|
|
11
11
|
import { homedir } from 'os';
|
|
12
12
|
import { randomUUID } from 'crypto';
|
|
13
13
|
import { createRealtimeAdapter } from './adapters/realtime-adapter.js';
|
|
14
14
|
import { backoffMs, sleep } from './backoff.js';
|
|
15
|
+
import { redactConsole } from './redact.js';
|
|
16
|
+
|
|
17
|
+
// Backoff for calls to the platform that failed for a reason on its side (startup, the channel).
|
|
18
|
+
const PLATFORM_BACKOFF = { baseMs: 2_000, capMs: 5 * 60_000 };
|
|
15
19
|
|
|
16
20
|
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
17
21
|
|
|
@@ -22,25 +26,42 @@ class HttpError extends Error {
|
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
// Every fetchJSON call authenticates with the platform API token (x-token), and a 401 for it
|
|
30
|
+
// always means that token is missing, invalid or revoked (see requireApiToken on the
|
|
31
|
+
// platform) — no retry or wait can fix that, so the first one ends the process with a message
|
|
32
|
+
// saying what to fix. (The alive-ping, on the short-lived realtime token, doesn't come
|
|
33
|
+
// through here: a 401 there just means that token needs refreshing.)
|
|
25
34
|
async function fetchJSON(url, options) {
|
|
26
35
|
const res = await fetch(url, options);
|
|
36
|
+
if (res.status === 401) {
|
|
37
|
+
console.error(`[agent] 401 from ${new URL(url).pathname} — the token was rejected: ${await res.text()}`);
|
|
38
|
+
console.error('[agent] Check the token in .eventmodelers/config.json (or EVENTMODELERS_TOKEN) — shutting down');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
27
41
|
if (!res.ok) throw new HttpError(res.status, await res.text());
|
|
28
42
|
return res.json();
|
|
29
43
|
}
|
|
30
44
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
// Startup can't wait for a person to notice a platform blip: a 429, a 5xx or a request that
|
|
46
|
+
// never got an answer is retried with backoff until it goes through. Any other 4xx is a real
|
|
47
|
+
// answer and goes straight back (a 401 has already ended the process in fetchJSON).
|
|
48
|
+
function isTransient(err) {
|
|
49
|
+
if (err instanceof HttpError) return err.status === 429 || err.status >= 500;
|
|
50
|
+
// fetch() rejects with a TypeError when there's no response at all (DNS, refused, reset),
|
|
51
|
+
// and with a TimeoutError/AbortError from an AbortSignal.timeout.
|
|
52
|
+
return err instanceof TypeError || err?.name === 'TimeoutError' || err?.name === 'AbortError';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function retryTransient(label, fn, { sleepFn = sleep } = {}) {
|
|
56
|
+
for (let attempt = 1; ; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
return await fn();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (!isTransient(err)) throw err;
|
|
61
|
+
const delay = backoffMs(attempt, PLATFORM_BACKOFF);
|
|
62
|
+
console.warn(`[agent] ${label} failed (attempt ${attempt}: ${err.message}) — retrying in ${Math.round(delay / 1000)}s`);
|
|
63
|
+
await sleepFn(delay);
|
|
42
64
|
}
|
|
43
|
-
throw err;
|
|
44
65
|
}
|
|
45
66
|
}
|
|
46
67
|
|
|
@@ -259,30 +280,79 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
259
280
|
console.log(`[agent] Persisted ${slices.length} slice(s)`);
|
|
260
281
|
}
|
|
261
282
|
|
|
262
|
-
|
|
283
|
+
// tasks.json has two writers: this process, queueing slice changes, and the agent, removing
|
|
284
|
+
// the task it just handled (as each stack's prompt.md tells it to). An unlocked read-modify-
|
|
285
|
+
// write from both sides at once lost whichever update landed first. So the two never overlap:
|
|
286
|
+
// while an onTask turn runs (see inTurn), a change is held here instead — latest per slice —
|
|
287
|
+
// and written the moment the turn ends. Every write goes through a temp file and a rename,
|
|
288
|
+
// so a reader never sees a half-written file either.
|
|
289
|
+
let heldTasks = null; // sliceId → payload while an onTask turn runs, null otherwise
|
|
290
|
+
|
|
291
|
+
function writeTasksAtomically(tasksPath, tasks) {
|
|
292
|
+
const tmp = `${tasksPath}.${process.pid}.tmp`;
|
|
293
|
+
writeFileSync(tmp, JSON.stringify(tasks, null, 2), 'utf-8');
|
|
294
|
+
renameSync(tmp, tasksPath);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function readTasks(tasksPath) {
|
|
298
|
+
if (!existsSync(tasksPath)) return [];
|
|
299
|
+
try {
|
|
300
|
+
const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
|
|
301
|
+
return Array.isArray(tasks) ? tasks : [];
|
|
302
|
+
} catch {
|
|
303
|
+
return []; // the agent left it unparseable — start over rather than wedge the queue
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function appendTasks(kitDir, payloads) {
|
|
263
308
|
const tasksPath = join(kitDir, 'tasks.json');
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
309
|
+
const replaced = new Set(payloads.map((p) => p.sliceId));
|
|
310
|
+
const tasks = readTasks(tasksPath).filter((t) => !replaced.has(t.payload?.sliceId));
|
|
311
|
+
for (const payload of payloads) tasks.push({ id: randomUUID(), createdAt: new Date().toISOString(), payload });
|
|
312
|
+
writeTasksAtomically(tasksPath, tasks);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function writeTask(payload, kitDir) {
|
|
316
|
+
if (heldTasks) {
|
|
317
|
+
heldTasks.set(payload.sliceId, payload);
|
|
318
|
+
console.log(`[agent] Task held until the current turn ends — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
appendTasks(kitDir, [payload]);
|
|
269
322
|
console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
270
323
|
}
|
|
271
324
|
|
|
325
|
+
// Runs one agent turn that may rewrite tasks.json, keeping this process's own writes out of
|
|
326
|
+
// its way; whatever arrived meanwhile is written as soon as it's over, success or not.
|
|
327
|
+
async function inTurn(kitDir, fn) {
|
|
328
|
+
heldTasks = new Map();
|
|
329
|
+
try {
|
|
330
|
+
return await fn();
|
|
331
|
+
} finally {
|
|
332
|
+
const payloads = [...heldTasks.values()];
|
|
333
|
+
heldTasks = null;
|
|
334
|
+
if (payloads.length > 0) {
|
|
335
|
+
appendTasks(kitDir, payloads);
|
|
336
|
+
console.log(`[agent] Wrote ${payloads.length} task(s) held during the turn`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
272
341
|
async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
|
|
273
342
|
console.log(`[agent] slice:changed — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
274
|
-
await exitOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
275
|
-
console.error('[agent] Slice persist error:', err),
|
|
276
|
-
);
|
|
277
343
|
// This agent's own status writes (claim → InProgress, → Done, → Blocked) come straight back
|
|
278
344
|
// as slice:changed. Queuing them only buys a turn in which the agent reads its own echo and
|
|
279
|
-
// skips it
|
|
280
|
-
//
|
|
281
|
-
// is
|
|
345
|
+
// skips it, and re-reading the slices for them is wasted too: ralphLoop re-reads them right
|
|
346
|
+
// after every turn anyway. Attribution decides, never a guess: only an agentId equal to ours
|
|
347
|
+
// is dropped — a person's edit or another agent's, or an event with no attribution at all,
|
|
348
|
+
// is still handled, so a missing field degrades to queuing rather than losing work.
|
|
282
349
|
if (payload?.agentId && cfg.agentId && payload.agentId === cfg.agentId) {
|
|
283
350
|
console.log(`[agent] Own write — not queued (slice="${payload.sliceTitle}" status="${payload.sliceStatus}")`);
|
|
284
351
|
return;
|
|
285
352
|
}
|
|
353
|
+
await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
354
|
+
console.error('[agent] Slice persist error:', err),
|
|
355
|
+
);
|
|
286
356
|
// Planned slices are handled by onPlannedSlice directly — no task needed.
|
|
287
357
|
// queueAllStatuses opts out of that split entirely (e.g. bridge has no
|
|
288
358
|
// onPlannedSlice consumer, so a lingering Planned slice would otherwise
|
|
@@ -292,15 +362,17 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
|
|
|
292
362
|
}
|
|
293
363
|
}
|
|
294
364
|
|
|
295
|
-
|
|
296
|
-
|
|
365
|
+
// createAdapter is the realtime transport factory — injectable so the channel handling can be
|
|
366
|
+
// tested against a fake transport.
|
|
367
|
+
async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllStatuses = false, createAdapter = createRealtimeAdapter } = {}) {
|
|
368
|
+
let realtimeToken = await retryTransient('getRealtimeToken', () => getRealtimeToken(cfg));
|
|
297
369
|
|
|
298
|
-
await
|
|
370
|
+
await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
299
371
|
console.error('[agent] Initial slice fetch error:', err),
|
|
300
372
|
);
|
|
301
373
|
|
|
302
374
|
const channelName = `board:${cfg.boardId}-slicechanged`;
|
|
303
|
-
const realtime = await
|
|
375
|
+
const realtime = await createAdapter(cfg, realtimeToken);
|
|
304
376
|
|
|
305
377
|
// Shared by the scheduled timer, a CHANNEL_ERROR/TIMED_OUT subscribe status, and a
|
|
306
378
|
// 401 from the alive-ping — whichever notices the token is bad first wins; the rest
|
|
@@ -313,7 +385,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
313
385
|
console.log(`[agent] ${ts()} Refreshing realtime token (reason: ${reason})...`);
|
|
314
386
|
refreshing = (async () => {
|
|
315
387
|
try {
|
|
316
|
-
realtimeToken = await
|
|
388
|
+
realtimeToken = await getRealtimeToken(cfg);
|
|
317
389
|
await realtime.setAuth(realtimeToken);
|
|
318
390
|
console.log(`[agent] ${ts()} Token refreshed (reason: ${reason}, took ${Date.now() - startedAt}ms)`);
|
|
319
391
|
} catch (err) {
|
|
@@ -328,34 +400,33 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
328
400
|
};
|
|
329
401
|
|
|
330
402
|
// A subscribe that errors on a stale token needs a fresh token AND a new join
|
|
331
|
-
// attempt — setAuth alone doesn't re-join a channel that already errored out
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// access from hammering the platform. A revoked token ends the process anyway, via
|
|
336
|
-
// exitOn401 inside refreshToken.
|
|
403
|
+
// attempt — setAuth alone doesn't re-join a channel that already errored out, and nothing
|
|
404
|
+
// else ever re-joins it, so this retries for as long as it takes, with backoff so a
|
|
405
|
+
// lasting failure doesn't hammer the platform. (A revoked token ends the process anyway:
|
|
406
|
+
// refreshToken's fetch gets the 401.)
|
|
337
407
|
let channelErrorStreak = 0;
|
|
338
|
-
let
|
|
408
|
+
let resubscribePending = false;
|
|
339
409
|
const onChannelFailure = (reason) => {
|
|
340
410
|
// One failure can be reported more than once (a channel erroring while its retry is
|
|
341
411
|
// already scheduled); a second resubscribe on top would join the channel twice.
|
|
342
|
-
if (
|
|
412
|
+
if (resubscribePending) return;
|
|
413
|
+
resubscribePending = true;
|
|
343
414
|
channelErrorStreak += 1;
|
|
344
|
-
const delay = backoffMs(channelErrorStreak,
|
|
415
|
+
const delay = backoffMs(channelErrorStreak, PLATFORM_BACKOFF);
|
|
345
416
|
console.warn(`[agent] ${ts()} Channel "${channelName}": ${reason} (attempt ${channelErrorStreak}) — resubscribing in ${Math.round(delay / 1000)}s`);
|
|
346
|
-
|
|
417
|
+
setTimeout(async () => {
|
|
347
418
|
try {
|
|
348
419
|
await refreshToken(`channel ${reason}`);
|
|
349
420
|
} catch {
|
|
350
421
|
// Logged by refreshToken. Rejoining on the old token is still worth a try —
|
|
351
422
|
// if it fails too, that's just the next failure, with a longer delay.
|
|
352
423
|
}
|
|
353
|
-
|
|
424
|
+
resubscribePending = false;
|
|
354
425
|
subscribeChannel();
|
|
355
426
|
}, delay);
|
|
356
427
|
};
|
|
357
428
|
const subscribeChannel = () => {
|
|
358
|
-
|
|
429
|
+
realtime.subscribe(
|
|
359
430
|
channelName,
|
|
360
431
|
{
|
|
361
432
|
// A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
|
|
@@ -381,11 +452,11 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
381
452
|
// Broadcasts sent while the channel was down are gone. Re-read the slices once so a
|
|
382
453
|
// slice set to Planned during the gap still gets built — .slices/ is what ralphLoop
|
|
383
454
|
// picks Planned work from.
|
|
384
|
-
|
|
455
|
+
fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
385
456
|
console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
|
|
386
457
|
);
|
|
387
458
|
},
|
|
388
|
-
)
|
|
459
|
+
).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
|
|
389
460
|
};
|
|
390
461
|
subscribeChannel();
|
|
391
462
|
|
|
@@ -424,14 +495,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
424
495
|
// ── Ralph loop ────────────────────────────────────────────────────────────────
|
|
425
496
|
|
|
426
497
|
function hasPendingTasks(kitDir) {
|
|
427
|
-
|
|
428
|
-
if (!existsSync(tasksPath)) return false;
|
|
429
|
-
try {
|
|
430
|
-
const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
|
|
431
|
-
return Array.isArray(tasks) && tasks.length > 0;
|
|
432
|
-
} catch {
|
|
433
|
-
return false;
|
|
434
|
-
}
|
|
498
|
+
return readTasks(join(kitDir, 'tasks.json')).length > 0;
|
|
435
499
|
}
|
|
436
500
|
|
|
437
501
|
function readCurrentContext(kitDir) {
|
|
@@ -554,9 +618,8 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
|
|
|
554
618
|
// Returns true once fn succeeds; false for a turn that timed out (see lib/turn.js). A timeout
|
|
555
619
|
// is not retried in place: re-running the same hung turn every 60s would spin forever without
|
|
556
620
|
// the stuck-slice guard ever seeing it, so it goes back to ralphLoop, which recounts the slice.
|
|
557
|
-
// Any other failure is retried in place with
|
|
558
|
-
//
|
|
559
|
-
// for as long as it stayed broken.
|
|
621
|
+
// Any other failure is retried in place, with backoff (30s → 10min) so a harness that keeps
|
|
622
|
+
// failing fast (API outage, rate limit, broken install) isn't relaunched in a tight loop.
|
|
560
623
|
async function runWithRetry(label, fn) {
|
|
561
624
|
for (let attempt = 1; ; attempt++) {
|
|
562
625
|
try {
|
|
@@ -592,7 +655,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
592
655
|
|
|
593
656
|
if (credentialed && hasPendingTasks(kitDir)) {
|
|
594
657
|
const prompt = readFileSync(promptFile, 'utf-8');
|
|
595
|
-
await runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
|
|
658
|
+
await runWithRetry('onTask: loading slice from board...', () => inTurn(kitDir, () => onTask(prompt)));
|
|
596
659
|
await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
597
660
|
didWork = true;
|
|
598
661
|
}
|
|
@@ -631,7 +694,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
631
694
|
console.log(`[ralph] No planned slices in current context "${ctx}" — waiting. Switch context on the board to continue.`);
|
|
632
695
|
lastIdleCtx = ctx;
|
|
633
696
|
}
|
|
634
|
-
await
|
|
697
|
+
await sleep(10_000);
|
|
635
698
|
} else {
|
|
636
699
|
lastIdleCtx = undefined;
|
|
637
700
|
}
|
|
@@ -640,7 +703,20 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
640
703
|
|
|
641
704
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
642
705
|
|
|
643
|
-
export { loadLocalConfig, fetchPlatformConfig,
|
|
706
|
+
export { HttpError, loadLocalConfig, fetchPlatformConfig, retryTransient, startRealtimeAgent, writeTask, inTurn, hasPendingTasks };
|
|
707
|
+
|
|
708
|
+
// The connect line a runner puts in front of every prompt. --local must mean zero board
|
|
709
|
+
// contact, so it is empty then — handed live credentials, the agent would treat itself as
|
|
710
|
+
// already connected and go straight to board sync. The token is named, never included: the
|
|
711
|
+
// prompt reaches the harness on its command line (visible to every local user in `ps`) and
|
|
712
|
+
// lands in session transcripts, so it carries $EVENTMODELERS_TOKEN — the runner puts the value
|
|
713
|
+
// in the child's env, connect treats the reference as the inline token, and a curl header
|
|
714
|
+
// written as "x-token: $EVENTMODELERS_TOKEN" expands in the shell.
|
|
715
|
+
export function connectHeader(cfg, localOnly) {
|
|
716
|
+
return !localOnly && cfg.boardId
|
|
717
|
+
? `board=${cfg.boardId} token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
|
|
718
|
+
: '';
|
|
719
|
+
}
|
|
644
720
|
|
|
645
721
|
// Who this process is. RALPH_AGENT_ID/RALPH_AGENT_NAME are `eventmodelers run --id/--name`,
|
|
646
722
|
// passed down as env (see cli.js's run dispatcher): a per-run identity override so a second
|
|
@@ -658,6 +734,7 @@ export function resolveAgentIdentity(kitDir, agentType = 'BUILD', cfg = loadLoca
|
|
|
658
734
|
|
|
659
735
|
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
|
|
660
736
|
const local = loadLocalConfig(kitDir);
|
|
737
|
+
redactConsole([local.token]);
|
|
661
738
|
Object.assign(local, resolveAgentIdentity(kitDir, agentType, local));
|
|
662
739
|
|
|
663
740
|
console.log(`Ralph — kit: ${kitDir}`);
|
|
@@ -674,7 +751,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
|
|
|
674
751
|
return;
|
|
675
752
|
}
|
|
676
753
|
|
|
677
|
-
const cfg = await
|
|
754
|
+
const cfg = await retryTransient('fetchPlatformConfig', () => fetchPlatformConfig(local));
|
|
678
755
|
console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
|
|
679
756
|
|
|
680
757
|
await Promise.all([
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Keeps secrets out of the loop's own log lines. Anything this process prints can end up in a
|
|
2
|
+
// terminal scrollback, a CI log or a `docker logs` dump: an error body that echoes a header,
|
|
3
|
+
// a verbose tool input with a curl command in it, a debug line with a config object. So
|
|
4
|
+
// rather than trusting every call site, console output is masked on the way out:
|
|
5
|
+
// - the platform API token, by value (it's a plain uuid, so there's no pattern for it)
|
|
6
|
+
// - anything JWT-shaped (eyJ….….…) — the realtime token and Supabase keys look like that
|
|
7
|
+
//
|
|
8
|
+
// Output a child process writes straight to the terminal (stdio: 'inherit') never passes
|
|
9
|
+
// through console here, so it isn't covered.
|
|
10
|
+
|
|
11
|
+
import { format } from 'util';
|
|
12
|
+
|
|
13
|
+
const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
|
14
|
+
|
|
15
|
+
// A secret shorter than this would mask ordinary text (an empty or placeholder value most of all).
|
|
16
|
+
const MIN_SECRET_LENGTH = 8;
|
|
17
|
+
|
|
18
|
+
export function redactString(text, secrets = []) {
|
|
19
|
+
let out = String(text).replace(JWT, '***');
|
|
20
|
+
for (const secret of secrets) {
|
|
21
|
+
if (typeof secret === 'string' && secret.length >= MIN_SECRET_LENGTH) out = out.replaceAll(secret, '***');
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Routes console.log/info/warn/error/debug through redactString. Arguments are formatted the
|
|
27
|
+
// way console would format them first, so an Error or an object is masked too. Returns a
|
|
28
|
+
// function that puts the originals back.
|
|
29
|
+
export function redactConsole(secrets) {
|
|
30
|
+
const methods = ['log', 'info', 'warn', 'error', 'debug'];
|
|
31
|
+
const originals = Object.fromEntries(methods.map((m) => [m, console[m]]));
|
|
32
|
+
for (const m of methods) {
|
|
33
|
+
console[m] = (...args) => originals[m].call(console, redactString(format(...args), secrets));
|
|
34
|
+
}
|
|
35
|
+
return () => { for (const m of methods) console[m] = originals[m]; };
|
|
36
|
+
}
|