forgedock 1.0.8 → 1.0.9
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/bin/forge-utils.mjs +164 -0
- package/bin/forgedock.mjs +1914 -394
- package/bin/hooks/session-start.mjs +317 -0
- package/bin/init-detect.mjs +325 -0
- package/bin/init-enrich-api.mjs +203 -0
- package/bin/registry.mjs +358 -0
- package/bin/tui.mjs +453 -2
- package/commands/forgedock-init.md +305 -384
- package/docs/CONFIG.md +167 -0
- package/docs/superpowers/specs/2026-06-09-intelligent-onboarding-design.md +172 -0
- package/package.json +5 -1
- package/scripts/verify-route-registration.sh +23 -17
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init-enrich-api.mjs — Anthropic API enrichment backend for ForgeDock init.
|
|
3
|
+
*
|
|
4
|
+
* Implements the same enrich(ConfigDraft) contract as the skill backend so the
|
|
5
|
+
* selection ladder in forgedock.mjs can treat both backends interchangeably.
|
|
6
|
+
*
|
|
7
|
+
* Uses Node.js built-in fetch (Node 18+) — no SDK dependency required.
|
|
8
|
+
* Reads ANTHROPIC_API_KEY from the environment; cleanly skips enrichment with
|
|
9
|
+
* a helpful message when the key is absent, returning the original draft.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { dim, yellow, RESET } from "./tui.mjs";
|
|
13
|
+
|
|
14
|
+
/** Anthropic API endpoint for Messages. */
|
|
15
|
+
const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
|
|
16
|
+
|
|
17
|
+
/** Model used for config enrichment — cost-effective, fast, fits the task. */
|
|
18
|
+
const ENRICH_MODEL = "claude-haiku-4-5";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* System prompt defining the enrich(ConfigDraft) contract for the API backend.
|
|
22
|
+
*
|
|
23
|
+
* IMPORTANT: This prompt is for a stateless Anthropic Messages API call.
|
|
24
|
+
* The model has NO filesystem access, NO gh CLI, NO shell, and NO external
|
|
25
|
+
* tool calls of any kind. Enrichment is limited strictly to what can be
|
|
26
|
+
* reasoned from the ConfigDraft JSON provided in the user message.
|
|
27
|
+
*/
|
|
28
|
+
const SYSTEM_PROMPT =
|
|
29
|
+
"You are the init-enrich API backend for ForgeDock. " +
|
|
30
|
+
"You receive a ConfigDraft JSON object and return an enriched version. " +
|
|
31
|
+
"\n\n" +
|
|
32
|
+
"EXECUTION ENVIRONMENT CONSTRAINTS:\n" +
|
|
33
|
+
"- You have NO filesystem access and cannot scan codebases.\n" +
|
|
34
|
+
"- You have NO gh CLI and cannot query GitHub.\n" +
|
|
35
|
+
"- You have NO external tool calls of any kind.\n" +
|
|
36
|
+
"- You may ONLY reason from the ConfigDraft JSON provided.\n" +
|
|
37
|
+
"\n" +
|
|
38
|
+
"ENRICHMENT SCOPE (what you MAY improve):\n" +
|
|
39
|
+
"- review.tech_stack: infer from project.description and paths if recognizable patterns appear.\n" +
|
|
40
|
+
"- review.context: improve wording or add standard context based on the project description.\n" +
|
|
41
|
+
"- verification.health_patterns: suggest standard patterns for known frameworks.\n" +
|
|
42
|
+
"\n" +
|
|
43
|
+
"STRICTLY PROHIBITED — set confidence to 'low' and do NOT invent values for:\n" +
|
|
44
|
+
"- project_board.project_id (must match ^PVT_[A-Za-z0-9_=-]+$ — you cannot verify these).\n" +
|
|
45
|
+
"- project_board.field_ids.* (PVTSSF_ strings — you cannot verify these).\n" +
|
|
46
|
+
"- project_board.project_number (you cannot query the GitHub Projects API).\n" +
|
|
47
|
+
"- repos.satellites (you cannot list or verify satellite repositories).\n" +
|
|
48
|
+
"Never invent a PVT_ or PVTSSF_ string. If you cannot verify a field, set confidence to 'low'.\n" +
|
|
49
|
+
"\n" +
|
|
50
|
+
"OUTPUT RULES:\n" +
|
|
51
|
+
"- Return ONLY the enriched ConfigDraft as a valid JSON object.\n" +
|
|
52
|
+
"- Every leaf must have shape { value, confidence, source, why }.\n" +
|
|
53
|
+
"- Do not modify project, paths, branches, or meta sections.\n" +
|
|
54
|
+
"- Output the JSON object alone with no surrounding prose.";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract and parse the enriched ConfigDraft JSON from a backend response string.
|
|
58
|
+
*
|
|
59
|
+
* Backends may emit human-readable prose before and/or after the JSON blob.
|
|
60
|
+
* This function finds the outermost JSON object in the output using a
|
|
61
|
+
* string-aware balanced-brace scanner: it tracks brace depth while skipping
|
|
62
|
+
* over string literal contents (including backslash-escaped characters), so
|
|
63
|
+
* braces inside JSON string values do not affect depth tracking and trailing
|
|
64
|
+
* prose with braces does not extend the match past the correct closing '}'.
|
|
65
|
+
* Falls back to the original draft if extraction or parsing fails.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} output - Raw text from the API response content block
|
|
68
|
+
* @param {object} draft - Original ConfigDraft (returned on failure)
|
|
69
|
+
* @returns {object} Enriched ConfigDraft, or original draft on failure
|
|
70
|
+
*/
|
|
71
|
+
export function parseEnrichedDraft(output, draft) {
|
|
72
|
+
if (!output || typeof output !== "string") return draft;
|
|
73
|
+
|
|
74
|
+
// Find the first '{' then walk forward tracking brace depth, skipping over
|
|
75
|
+
// string literal contents (with backslash-escape handling) so that:
|
|
76
|
+
// - Braces inside JSON string values (e.g. Go template syntax in
|
|
77
|
+
// project.description, Windows paths) do not affect depth.
|
|
78
|
+
// - Trailing prose containing '{' or '}' after the JSON blob does not
|
|
79
|
+
// extend the extraction past the correct top-level closing '}'.
|
|
80
|
+
const start = output.indexOf("{");
|
|
81
|
+
if (start === -1) return draft;
|
|
82
|
+
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let inString = false;
|
|
85
|
+
let escape = false;
|
|
86
|
+
|
|
87
|
+
for (let i = start; i < output.length; i++) {
|
|
88
|
+
const ch = output[i];
|
|
89
|
+
if (escape) {
|
|
90
|
+
escape = false;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === "\\" && inString) {
|
|
94
|
+
escape = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === '"') {
|
|
98
|
+
inString = !inString;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (inString) continue;
|
|
102
|
+
if (ch === "{") {
|
|
103
|
+
depth++;
|
|
104
|
+
} else if (ch === "}") {
|
|
105
|
+
depth--;
|
|
106
|
+
if (depth === 0) {
|
|
107
|
+
// Found the closing brace of the top-level object — parse the candidate.
|
|
108
|
+
try {
|
|
109
|
+
const enriched = JSON.parse(output.slice(start, i + 1));
|
|
110
|
+
// Basic sanity check: must have the required top-level sections.
|
|
111
|
+
if (!enriched.project || !enriched.paths || !enriched.branches) {
|
|
112
|
+
return draft;
|
|
113
|
+
}
|
|
114
|
+
return enriched;
|
|
115
|
+
} catch {
|
|
116
|
+
return draft;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// No complete top-level JSON object found.
|
|
123
|
+
return draft;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Enrich a ConfigDraft by calling the Anthropic Messages API directly.
|
|
128
|
+
*
|
|
129
|
+
* This is the api backend for the init-enrich interface. It implements the same
|
|
130
|
+
* enrich(ConfigDraft) contract as the skill backend so the selection ladder can
|
|
131
|
+
* treat both interchangeably.
|
|
132
|
+
*
|
|
133
|
+
* When ANTHROPIC_API_KEY is absent, logs a helpful message and returns the
|
|
134
|
+
* original draft unchanged so the caller can fall through to the deterministic
|
|
135
|
+
* baseline.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} draft - ConfigDraft from detectConfig()
|
|
138
|
+
* @returns {Promise<object>} Enriched ConfigDraft, or the original draft on failure
|
|
139
|
+
*/
|
|
140
|
+
export async function enrich(draft) {
|
|
141
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
142
|
+
|
|
143
|
+
if (!apiKey) {
|
|
144
|
+
console.error(
|
|
145
|
+
` ${yellow("[!]")} No ANTHROPIC_API_KEY set — skipping API enrichment.` +
|
|
146
|
+
` ${dim("Set ANTHROPIC_API_KEY to enable BYO-key enrichment.")}${RESET}`,
|
|
147
|
+
);
|
|
148
|
+
return draft;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const draftJson = JSON.stringify(draft, null, 2);
|
|
153
|
+
|
|
154
|
+
const response = await fetch(ANTHROPIC_MESSAGES_URL, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
"x-api-key": apiKey,
|
|
159
|
+
"anthropic-version": "2023-06-01",
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
model: ENRICH_MODEL,
|
|
163
|
+
max_tokens: 4096,
|
|
164
|
+
system: SYSTEM_PROMPT,
|
|
165
|
+
messages: [
|
|
166
|
+
{
|
|
167
|
+
role: "user",
|
|
168
|
+
content: draftJson,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
throw new Error(`Anthropic API returned ${response.status}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const data = await response.json();
|
|
179
|
+
if (data?.stop_reason === "max_tokens" && process.env.FORGEDOCK_DEBUG) {
|
|
180
|
+
console.error(
|
|
181
|
+
` ${dim("[debug]")} api enrichment: response truncated by max_tokens — JSON parse may fail, falling back to baseline draft.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const text =
|
|
185
|
+
data?.content?.[0]?.type === "text" ? data.content[0].text : "";
|
|
186
|
+
return parseEnrichedDraft(text, draft);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const errCategory = err.status
|
|
189
|
+
? `HTTP ${err.status}`
|
|
190
|
+
: err.code
|
|
191
|
+
? err.code
|
|
192
|
+
: err.message;
|
|
193
|
+
console.error(
|
|
194
|
+
` ${yellow("[!]")} API enrichment unavailable: ${errCategory} — falling back to baseline draft.${RESET}`,
|
|
195
|
+
);
|
|
196
|
+
if (process.env.FORGEDOCK_DEBUG) {
|
|
197
|
+
console.error(
|
|
198
|
+
` ${dim("[debug]")} api enrichment failed: ${err.message}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return draft;
|
|
202
|
+
}
|
|
203
|
+
}
|
package/bin/registry.mjs
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* registry.mjs — Per-directory ForgeDock state registry.
|
|
4
|
+
*
|
|
5
|
+
* Resolves, for any directory, whether ForgeDock is *managed* there
|
|
6
|
+
* (a forge.yaml or .forgedock marker is present) and whether the user has
|
|
7
|
+
* explicitly *opted out* via the central registry file.
|
|
8
|
+
*
|
|
9
|
+
* Exports:
|
|
10
|
+
* resolveState(dir) → 'managed-active' | 'managed-optedout' | 'unmanaged'
|
|
11
|
+
* setOptOut(dir, optedOut) → Promise<void> (adds/removes dir from opt-out set)
|
|
12
|
+
* nudgeSeen(dir) → boolean (true if nudge was already shown for dir)
|
|
13
|
+
* markNudgeSeen(dir) → Promise<void> (records that nudge was shown for dir)
|
|
14
|
+
*
|
|
15
|
+
* Registry file: ~/.claude/forgedock/registry.json
|
|
16
|
+
* Registry schema:
|
|
17
|
+
* {
|
|
18
|
+
* "version": 1,
|
|
19
|
+
* "optedOut": {
|
|
20
|
+
* "/absolute/path/to/dir": { "at": "<ISO-8601 timestamp>" }
|
|
21
|
+
* },
|
|
22
|
+
* "nudgeSeen": {
|
|
23
|
+
* "/absolute/path/to/dir": { "at": "<ISO-8601 timestamp>" }
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* State model:
|
|
28
|
+
* - A directory is **managed** iff it contains `forge.yaml` OR a `.forgedock` marker file.
|
|
29
|
+
* - A directory is **opted-out** iff its resolved absolute path appears in the
|
|
30
|
+
* `optedOut` map of the registry.
|
|
31
|
+
* - Opt-out wins over managed: a managed+opted-out directory is silenced.
|
|
32
|
+
* - Missing or corrupt registry.json is treated as an empty opt-out set — the
|
|
33
|
+
* registry always fails open (never blocks a Claude Code session).
|
|
34
|
+
* - A nudge is shown at most once per unmanaged directory; `nudgeSeen` tracks
|
|
35
|
+
* which directories have already received the nudge.
|
|
36
|
+
*
|
|
37
|
+
* Contract guarantees:
|
|
38
|
+
* - Safe: every try/catch degrades gracefully; resolveState never throws
|
|
39
|
+
* - Isolated: imports only Node builtins (os, path, fs)
|
|
40
|
+
* - Atomic writes: registry.json is written via a .tmp file + renameSync
|
|
41
|
+
* - Testable: inject `dir` argument to point at fixture directories
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import os from "os";
|
|
45
|
+
import { resolve, join } from "path";
|
|
46
|
+
import {
|
|
47
|
+
existsSync,
|
|
48
|
+
readFileSync,
|
|
49
|
+
writeFileSync,
|
|
50
|
+
renameSync,
|
|
51
|
+
realpathSync,
|
|
52
|
+
} from "fs";
|
|
53
|
+
import { mkdir } from "fs/promises";
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Constants
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Cross-platform user home directory.
|
|
61
|
+
* Mirrors the HOME resolution pattern used in forgedock.mjs.
|
|
62
|
+
*/
|
|
63
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Directory that holds ForgeDock's per-user runtime state files.
|
|
67
|
+
* Created on first registry write if absent (mode 0o700).
|
|
68
|
+
*/
|
|
69
|
+
const REGISTRY_DIR = join(HOME, ".claude", "forgedock");
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Absolute path to the registry JSON file.
|
|
73
|
+
*/
|
|
74
|
+
const REGISTRY_PATH = join(REGISTRY_DIR, "registry.json");
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Empty registry structure — returned whenever the file is missing or corrupt.
|
|
78
|
+
* Using a factory function avoids shared mutable state across calls.
|
|
79
|
+
*
|
|
80
|
+
* @returns {{ version: number, optedOut: Record<string, { at: string }>, nudgeSeen: Record<string, { at: string }> }}
|
|
81
|
+
*/
|
|
82
|
+
function emptyRegistry() {
|
|
83
|
+
return { version: 1, optedOut: {}, nudgeSeen: {} };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Internal helpers
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve and normalize a directory path for use as a registry key.
|
|
92
|
+
*
|
|
93
|
+
* First resolves the path to an absolute path with `resolve()`, then
|
|
94
|
+
* dereferences any symbolic links with `realpathSync.native()` so that a
|
|
95
|
+
* project entered via a symlinked path and the same project entered via its
|
|
96
|
+
* real path produce the same registry key. A try/catch fallback to the
|
|
97
|
+
* `resolve()` result ensures fail-open behaviour for non-existent or
|
|
98
|
+
* inaccessible paths (ENOENT, EACCES, EPERM).
|
|
99
|
+
*
|
|
100
|
+
* On Windows only, the drive letter of the resulting canonical path is
|
|
101
|
+
* lowercased (the character before the first `:`). This ensures that
|
|
102
|
+
* `C:\Users\foo` and `c:\Users\foo` hash to the same key, since NTFS is
|
|
103
|
+
* case-insensitive but JavaScript string comparison is not.
|
|
104
|
+
*
|
|
105
|
+
* On POSIX systems the canonical path is returned as-is; POSIX filesystems
|
|
106
|
+
* are case-sensitive by convention and no normalization is needed.
|
|
107
|
+
*
|
|
108
|
+
* Only the drive letter is lowercased — the rest of the path is preserved
|
|
109
|
+
* verbatim so that intentional casing in directory names is not altered.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} dir - Directory path (absolute or relative).
|
|
112
|
+
* @returns {string} Normalized canonical absolute path suitable for use as a registry key.
|
|
113
|
+
*/
|
|
114
|
+
function normalizeDir(dir) {
|
|
115
|
+
const abs = resolve(dir);
|
|
116
|
+
// Dereference symbolic links so that a symlinked project path and its
|
|
117
|
+
// real path produce the same registry key. Fall back to the resolve()
|
|
118
|
+
// result for paths that do not exist yet (e.g. ENOENT) or are not
|
|
119
|
+
// accessible (EACCES/EPERM) — preserves the fail-open contract.
|
|
120
|
+
// realpathSync.native uses the OS-native implementation (available since
|
|
121
|
+
// Node 9.2) and avoids extra JS stat syscalls vs the JS fallback.
|
|
122
|
+
let canonical;
|
|
123
|
+
try {
|
|
124
|
+
canonical = realpathSync.native(abs);
|
|
125
|
+
} catch {
|
|
126
|
+
canonical = abs;
|
|
127
|
+
}
|
|
128
|
+
// On Windows, drive letters vary in casing (C:\ vs c:\). Normalize to
|
|
129
|
+
// lowercase so registry lookups are case-insensitive for drive letters.
|
|
130
|
+
// Detect: canonical[1] === ':' is the Windows drive-letter pattern (e.g. C:\).
|
|
131
|
+
if (
|
|
132
|
+
process.platform === "win32" &&
|
|
133
|
+
canonical.length >= 2 &&
|
|
134
|
+
canonical[1] === ":"
|
|
135
|
+
) {
|
|
136
|
+
return canonical[0].toLowerCase() + canonical.slice(1);
|
|
137
|
+
}
|
|
138
|
+
return canonical;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read and parse the registry file.
|
|
143
|
+
*
|
|
144
|
+
* Fail-open: any read or parse error returns an empty registry rather than
|
|
145
|
+
* throwing. This ensures that a corrupt or missing file never blocks a
|
|
146
|
+
* Claude Code session.
|
|
147
|
+
*
|
|
148
|
+
* @returns {{ version: number, optedOut: Record<string, { at: string }>, nudgeSeen: Record<string, { at: string }> }}
|
|
149
|
+
*/
|
|
150
|
+
function readRegistry() {
|
|
151
|
+
try {
|
|
152
|
+
const raw = readFileSync(REGISTRY_PATH, "utf-8");
|
|
153
|
+
const parsed = JSON.parse(raw);
|
|
154
|
+
// Basic schema validation — must be an object with an optedOut map
|
|
155
|
+
if (
|
|
156
|
+
parsed &&
|
|
157
|
+
typeof parsed === "object" &&
|
|
158
|
+
parsed.optedOut &&
|
|
159
|
+
typeof parsed.optedOut === "object"
|
|
160
|
+
) {
|
|
161
|
+
// Ensure nudgeSeen is present (may be absent in older registry files)
|
|
162
|
+
if (!parsed.nudgeSeen || typeof parsed.nudgeSeen !== "object") {
|
|
163
|
+
parsed.nudgeSeen = {};
|
|
164
|
+
}
|
|
165
|
+
return parsed;
|
|
166
|
+
}
|
|
167
|
+
// Schema mismatch — treat as empty (fail open)
|
|
168
|
+
return emptyRegistry();
|
|
169
|
+
} catch {
|
|
170
|
+
// File missing, unreadable, or invalid JSON — all treated as empty
|
|
171
|
+
return emptyRegistry();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Module-level write queue — serializes concurrent registry mutations so that
|
|
177
|
+
* two callers (e.g. setOptOut + markNudgeSeen in the same tick) never race and
|
|
178
|
+
* produce a last-write-wins corruption. Each enqueued mutation waits for the
|
|
179
|
+
* previous one to resolve before executing. Errors are swallowed per the
|
|
180
|
+
* existing best-effort contract so a failed write never blocks the next caller.
|
|
181
|
+
*
|
|
182
|
+
* The queue serializes the FULL read-modify-write cycle, not just the disk
|
|
183
|
+
* write. Each task reads the freshest on-disk state inside the critical section,
|
|
184
|
+
* applies the caller-supplied mutation, then writes atomically. This prevents
|
|
185
|
+
* the snapshot-before-enqueue race where two concurrent callers both read stale
|
|
186
|
+
* state and the second enqueued write silently stomps the first one's change.
|
|
187
|
+
* <!-- fix: forge#438 -->
|
|
188
|
+
*/
|
|
189
|
+
let _writeQueue = Promise.resolve();
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Enqueue a read-modify-write mutation against the registry.
|
|
193
|
+
*
|
|
194
|
+
* Accepts a `mutate` function rather than a pre-read data snapshot. The
|
|
195
|
+
* mutation is deferred until it reaches the head of the serial queue, at
|
|
196
|
+
* which point it reads the freshest on-disk registry state, applies the
|
|
197
|
+
* mutation, and writes the result atomically. This guarantees that concurrent
|
|
198
|
+
* callers each see the previous caller's changes rather than racing on a
|
|
199
|
+
* shared stale snapshot.
|
|
200
|
+
*
|
|
201
|
+
* Best-effort: errors inside the mutation or the disk write are silently
|
|
202
|
+
* suppressed so callers are never blocked.
|
|
203
|
+
*
|
|
204
|
+
* @param {(registry: { version: number, optedOut: Record<string, { at: string }>, nudgeSeen: Record<string, { at: string }> }) => void} mutate
|
|
205
|
+
* Mutation callback. Called with the current registry object; should
|
|
206
|
+
* modify it in place. Return value is ignored. Callbacks may have side
|
|
207
|
+
* effects (e.g. reading the clock) — they are not required to be pure.
|
|
208
|
+
* @returns {Promise<void>}
|
|
209
|
+
*/
|
|
210
|
+
async function writeRegistry(mutate) {
|
|
211
|
+
// Chain onto the existing queue; swallow errors so callers are never blocked
|
|
212
|
+
_writeQueue = _writeQueue
|
|
213
|
+
.then(() => _doWriteRegistryWith(mutate))
|
|
214
|
+
.catch(() => {});
|
|
215
|
+
return _writeQueue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Inner implementation — performs the atomic read-modify-write cycle.
|
|
220
|
+
* Called exclusively through writeRegistry's queue chain.
|
|
221
|
+
*
|
|
222
|
+
* Reads the current registry from disk (fail-open), applies the caller's
|
|
223
|
+
* mutation function, then writes the result via a .tmp file + renameSync.
|
|
224
|
+
*
|
|
225
|
+
* @param {(registry: { version: number, optedOut: Record<string, { at: string }>, nudgeSeen: Record<string, { at: string }> }) => void} mutate
|
|
226
|
+
* Mutation callback — same contract as `writeRegistry`. Modifies the
|
|
227
|
+
* registry object in place; return value is ignored.
|
|
228
|
+
* @returns {Promise<void>}
|
|
229
|
+
*/
|
|
230
|
+
async function _doWriteRegistryWith(mutate) {
|
|
231
|
+
try {
|
|
232
|
+
await mkdir(REGISTRY_DIR, { recursive: true, mode: 0o700 });
|
|
233
|
+
// Read the freshest on-disk state inside the critical section so that
|
|
234
|
+
// concurrent callers each build on the previous caller's committed write.
|
|
235
|
+
const registry = readRegistry();
|
|
236
|
+
mutate(registry);
|
|
237
|
+
const tmp = REGISTRY_PATH + ".tmp";
|
|
238
|
+
writeFileSync(tmp, JSON.stringify(registry, null, 2) + "\n", {
|
|
239
|
+
encoding: "utf-8",
|
|
240
|
+
});
|
|
241
|
+
renameSync(tmp, REGISTRY_PATH);
|
|
242
|
+
} catch {
|
|
243
|
+
// Best-effort — a failed write is non-fatal; the registry remains as-is
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Public API
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolve the ForgeDock state for a given directory.
|
|
253
|
+
*
|
|
254
|
+
* State matrix:
|
|
255
|
+
*
|
|
256
|
+
* | forge.yaml | .forgedock | in optedOut | Result |
|
|
257
|
+
* |:----------:|:----------:|:-----------:|:-------------------|
|
|
258
|
+
* | true | any | false | managed-active |
|
|
259
|
+
* | true | any | true | managed-optedout |
|
|
260
|
+
* | false | true | false | managed-active |
|
|
261
|
+
* | false | true | true | managed-optedout |
|
|
262
|
+
* | false | false | any | unmanaged |
|
|
263
|
+
*
|
|
264
|
+
* Opt-out wins over managed: a directory that has a marker but is also listed
|
|
265
|
+
* in the registry's optedOut map is reported as managed-optedout.
|
|
266
|
+
*
|
|
267
|
+
* Missing or corrupt registry.json is treated as an empty opt-out set —
|
|
268
|
+
* resolveState never throws.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} dir - Absolute path to the directory to resolve.
|
|
271
|
+
* @returns {'managed-active' | 'managed-optedout' | 'unmanaged'}
|
|
272
|
+
*/
|
|
273
|
+
export function resolveState(dir) {
|
|
274
|
+
const absDir = normalizeDir(dir);
|
|
275
|
+
|
|
276
|
+
// Check for managed markers (forge.yaml or .forgedock)
|
|
277
|
+
const hasForgeYaml = existsSync(join(absDir, "forge.yaml"));
|
|
278
|
+
const hasMarker = existsSync(join(absDir, ".forgedock"));
|
|
279
|
+
const isManaged = hasForgeYaml || hasMarker;
|
|
280
|
+
|
|
281
|
+
if (!isManaged) {
|
|
282
|
+
return "unmanaged";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Directory is managed — check if opted out
|
|
286
|
+
const registry = readRegistry();
|
|
287
|
+
const isOptedOut = Object.prototype.hasOwnProperty.call(
|
|
288
|
+
registry.optedOut,
|
|
289
|
+
absDir,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
return isOptedOut ? "managed-optedout" : "managed-active";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Add or remove a directory from the opt-out set in the registry.
|
|
297
|
+
*
|
|
298
|
+
* The directory path is normalized with normalizeDir() before being stored,
|
|
299
|
+
* ensuring consistent key lookup regardless of trailing slashes, symlinks, or
|
|
300
|
+
* drive-letter casing on Windows.
|
|
301
|
+
*
|
|
302
|
+
* @param {string} dir - Absolute path to the directory.
|
|
303
|
+
* @param {boolean} optedOut - true to opt out, false to remove from opt-out set.
|
|
304
|
+
* @returns {Promise<void>}
|
|
305
|
+
*/
|
|
306
|
+
export async function setOptOut(dir, optedOut) {
|
|
307
|
+
const absDir = normalizeDir(dir);
|
|
308
|
+
// Pass a mutation closure rather than a pre-read snapshot. The actual
|
|
309
|
+
// readRegistry() call is deferred to inside the serial queue so that
|
|
310
|
+
// concurrent mutations each see the previous caller's committed write.
|
|
311
|
+
await writeRegistry((registry) => {
|
|
312
|
+
if (optedOut) {
|
|
313
|
+
registry.optedOut[absDir] = { at: new Date().toISOString() };
|
|
314
|
+
} else {
|
|
315
|
+
delete registry.optedOut[absDir];
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check whether the one-time "Enable ForgeDock here?" nudge has already been
|
|
322
|
+
* shown for a given directory.
|
|
323
|
+
*
|
|
324
|
+
* Fail-open: returns false on any registry read error, so the nudge fires
|
|
325
|
+
* once more on the next session rather than never.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} dir - Absolute path to the directory.
|
|
328
|
+
* @returns {boolean} true if the nudge has already been shown for this directory.
|
|
329
|
+
*/
|
|
330
|
+
export function nudgeSeen(dir) {
|
|
331
|
+
try {
|
|
332
|
+
const absDir = normalizeDir(dir);
|
|
333
|
+
const registry = readRegistry();
|
|
334
|
+
return Object.prototype.hasOwnProperty.call(registry.nudgeSeen, absDir);
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Record that the one-time nudge has been shown for a directory so it is not
|
|
342
|
+
* shown again in future sessions.
|
|
343
|
+
*
|
|
344
|
+
* Best-effort: a failed write means the nudge may appear one extra time, which
|
|
345
|
+
* is acceptable — it never blocks a Claude Code session.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} dir - Absolute path to the directory.
|
|
348
|
+
* @returns {Promise<void>}
|
|
349
|
+
*/
|
|
350
|
+
export async function markNudgeSeen(dir) {
|
|
351
|
+
const absDir = normalizeDir(dir);
|
|
352
|
+
// Pass a mutation closure rather than a pre-read snapshot. The actual
|
|
353
|
+
// readRegistry() call is deferred to inside the serial queue so that
|
|
354
|
+
// concurrent mutations each see the previous caller's committed write.
|
|
355
|
+
await writeRegistry((registry) => {
|
|
356
|
+
registry.nudgeSeen[absDir] = { at: new Date().toISOString() };
|
|
357
|
+
});
|
|
358
|
+
}
|