@naswerks/periscope 1.0.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The discovery door: read-only enumeration and tail-probing of the agent CLI's own transcript
|
|
3
|
+
* directory, `~/.claude/projects`.
|
|
4
|
+
*
|
|
5
|
+
* The posture, stated once because it is the whole design. This host names `~/.claude` in one
|
|
6
|
+
* other place, `host/paths.ts`, to protect it from the sessions it runs. That protection is about
|
|
7
|
+
* the agent's hands; this module is the host's own eyes, a deliberate read-only capability over a
|
|
8
|
+
* directory every Claude session on the machine writes into (a VS Code session and a session this
|
|
9
|
+
* host started land side by side). The two postures do not touch: nothing here weakens the gate,
|
|
10
|
+
* and nothing here can write.
|
|
11
|
+
*
|
|
12
|
+
* Read-only is a property, not a promise. The module's filesystem surface is exactly
|
|
13
|
+
* `createReadStream`, `readdir` and `stat`; no write-capable API is imported, and
|
|
14
|
+
* `src/pins/transcript-readonly.test.ts` holds the import surface to that allowlist.
|
|
15
|
+
*
|
|
16
|
+
* The jail: three layers, all three always, for every caller-supplied name:
|
|
17
|
+
* 1. the strict name allowlist (`NAME_ALLOWLIST`);
|
|
18
|
+
* 2. an explicit `'.'` / `'..'` reject — dot-names PASS the regex, so this layer is load-bearing;
|
|
19
|
+
* 3. resolve-then-containment — the real resolver first, then `isContainedBy` against the root,
|
|
20
|
+
* so a name the first two layers somehow admitted still cannot shape a path outside it.
|
|
21
|
+
* A violation refuses `transcript-path-escape`, naming the layer. An ABSENT transcript is a value,
|
|
22
|
+
* never a refusal: a missing projects directory is a fresh machine and lists as empty.
|
|
23
|
+
*
|
|
24
|
+
* The rewrite hazard: the CLI rewrites a transcript on compaction,
|
|
25
|
+
* so a byte-offset resume across a rewrite is invalid. Every answer that carries an offset also
|
|
26
|
+
* carries the file's current (size, mtime) pair; a caller that sees either move under it re-reads
|
|
27
|
+
* whole rather than resuming. A file SHORTER than the asked offset is treated as rewritten and
|
|
28
|
+
* scanned from 0.
|
|
29
|
+
*
|
|
30
|
+
* Project slugs are OPAQUE NAMES, never decoded: the CLI flattens every non-alphanumeric character
|
|
31
|
+
* of a directory path to `-` (on win32 `C:\src\x\.y` becomes `C--src-x--y`), so the
|
|
32
|
+
* mapping is not invertible and nothing here tries.
|
|
33
|
+
*/
|
|
34
|
+
import { createReadStream } from 'node:fs';
|
|
35
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
36
|
+
import { ok, refuse } from '../core/result.js';
|
|
37
|
+
import { isContainedBy, normalizePath } from '../core/paths.js';
|
|
38
|
+
import { TRANSCRIPT_PAGE_SIZE, TRANSCRIPT_WHAT_PREFIX } from '../control/frames.js';
|
|
39
|
+
import { nodePathResolver } from './paths.js';
|
|
40
|
+
/**
|
|
41
|
+
* The strict name allowlist — drive/path characters only. `'.'` and `'..'` PASS this regex, which
|
|
42
|
+
* is exactly why the dot-name reject below is its own layer rather than a tightening of this one.
|
|
43
|
+
*/
|
|
44
|
+
export const NAME_ALLOWLIST = /^[A-Za-z0-9._-]+$/;
|
|
45
|
+
/**
|
|
46
|
+
* The agent's home when nothing configures one: the folder the agent CLI keeps its state in, under
|
|
47
|
+
* the user's home directory. Null when there is no home to derive it from — a refusable state,
|
|
48
|
+
* never a fallback: inventing one would turn "this machine has no home directory" into a silent
|
|
49
|
+
* empty listing that reads as a fresh machine.
|
|
50
|
+
*/
|
|
51
|
+
export function defaultAgentHome(env = process.env) {
|
|
52
|
+
const home = env['USERPROFILE'] ?? env['HOME'];
|
|
53
|
+
if (typeof home === 'string' && home.trim() !== '') {
|
|
54
|
+
return normalizePath(`${home.replace(/[\\/]+$/, '')}/.claude`);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/** Where the agent CLI keeps transcripts under its home: derived, never configured on its own. */
|
|
59
|
+
export function transcriptsRootUnder(agentHome) {
|
|
60
|
+
return normalizePath(`${agentHome.replace(/[\\/]+$/, '')}/projects`);
|
|
61
|
+
}
|
|
62
|
+
/** The transcripts root under the default agent home, or null when there is no home. */
|
|
63
|
+
export function claudeProjectsRoot(env = process.env) {
|
|
64
|
+
const home = defaultAgentHome(env);
|
|
65
|
+
return home === null ? null : transcriptsRootUnder(home);
|
|
66
|
+
}
|
|
67
|
+
/** How much of a transcript's head is read for its `cwd` — the first entries, never the file. */
|
|
68
|
+
const CWD_HEAD_BYTES = 64 * 1024;
|
|
69
|
+
/**
|
|
70
|
+
* The first `cwd` string on the file's first entries, or null. A summary line carries none; the first
|
|
71
|
+
* user or assistant entry does. Every failure is null — the listing describes files, it does not refuse
|
|
72
|
+
* over one of them.
|
|
73
|
+
*/
|
|
74
|
+
async function readTranscriptCwd(path) {
|
|
75
|
+
let head = '';
|
|
76
|
+
try {
|
|
77
|
+
for await (const chunk of createReadStream(path, {
|
|
78
|
+
start: 0,
|
|
79
|
+
end: CWD_HEAD_BYTES - 1,
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
})) {
|
|
82
|
+
head += chunk;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
for (const line of head.split('\n')) {
|
|
89
|
+
const trimmed = line.trim();
|
|
90
|
+
if (trimmed.length === 0)
|
|
91
|
+
continue;
|
|
92
|
+
try {
|
|
93
|
+
const entry = JSON.parse(trimmed);
|
|
94
|
+
if (typeof entry === 'object' &&
|
|
95
|
+
entry !== null &&
|
|
96
|
+
typeof entry.cwd === 'string') {
|
|
97
|
+
const cwd = entry.cwd;
|
|
98
|
+
if (cwd.length > 0)
|
|
99
|
+
return cwd;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// a partial last line inside the window, or a non-JSON line — keep looking
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* THE JAIL. Resolve `{root}/{projectSlug}/{sessionId}.jsonl` with all three layers applied to both
|
|
110
|
+
* caller-supplied names. The one path-shaping function in this module — list, tail and the bulk
|
|
111
|
+
* resolver all come through here, so there is no second door to keep honest.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveTranscriptPath(root, projectSlug, sessionId) {
|
|
114
|
+
for (const [value, what] of [
|
|
115
|
+
[projectSlug, 'projectSlug'],
|
|
116
|
+
[sessionId, 'sessionId'],
|
|
117
|
+
]) {
|
|
118
|
+
if (value === '.' || value === '..') {
|
|
119
|
+
return refuse('transcript-path-escape', `${what} is a dot-name — refused by the dot-name layer`);
|
|
120
|
+
}
|
|
121
|
+
if (!NAME_ALLOWLIST.test(value)) {
|
|
122
|
+
return refuse('transcript-path-escape', `${what} fails the name allowlist — refused by the allowlist layer`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const resolvedRoot = normalizePath(nodePathResolver(root));
|
|
126
|
+
const candidate = normalizePath(nodePathResolver(`${resolvedRoot}/${projectSlug}/${sessionId}.jsonl`));
|
|
127
|
+
if (!isContainedBy(candidate, resolvedRoot)) {
|
|
128
|
+
return refuse('transcript-path-escape', `the resolved path leaves the projects root — refused by the containment layer`);
|
|
129
|
+
}
|
|
130
|
+
return ok(candidate);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Enumerate every session transcript: direct-child `*.jsonl` per slug directory (nested files are
|
|
134
|
+
* the CLI's subagent transcripts and are not part of this listing). Newest first by mtime, paged.
|
|
135
|
+
* A missing root is a fresh machine and answers an empty listing, never an error; a file deleted
|
|
136
|
+
* between readdir and stat is skipped for the same reason.
|
|
137
|
+
*/
|
|
138
|
+
export async function listTranscripts(root, page = {}) {
|
|
139
|
+
const fromIndex = Math.max(0, page.fromIndex ?? 0);
|
|
140
|
+
const pageSize = Math.max(1, page.pageSize ?? TRANSCRIPT_PAGE_SIZE);
|
|
141
|
+
const resolvedRoot = normalizePath(nodePathResolver(root));
|
|
142
|
+
let slugDirs;
|
|
143
|
+
try {
|
|
144
|
+
slugDirs = (await readdir(resolvedRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && NAME_ALLOWLIST.test(entry.name) && entry.name !== '.' && entry.name !== '..');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { entries: [], totalCount: 0, nextIndex: null };
|
|
148
|
+
}
|
|
149
|
+
const all = [];
|
|
150
|
+
for (const slugDir of slugDirs) {
|
|
151
|
+
let files;
|
|
152
|
+
try {
|
|
153
|
+
files = await readdir(`${resolvedRoot}/${slugDir.name}`, { withFileTypes: true });
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (!file.isFile() || !file.name.endsWith('.jsonl'))
|
|
160
|
+
continue;
|
|
161
|
+
const sessionId = file.name.slice(0, -'.jsonl'.length);
|
|
162
|
+
if (sessionId === '.' || sessionId === '..' || !NAME_ALLOWLIST.test(sessionId))
|
|
163
|
+
continue;
|
|
164
|
+
try {
|
|
165
|
+
const stats = await stat(`${resolvedRoot}/${slugDir.name}/${file.name}`);
|
|
166
|
+
all.push({
|
|
167
|
+
projectSlug: slugDir.name,
|
|
168
|
+
sessionId,
|
|
169
|
+
sizeBytes: stats.size,
|
|
170
|
+
mtimeMs: Math.floor(stats.mtimeMs),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Deleted between readdir and stat — the listing describes what exists, not what did.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
all.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
179
|
+
// The cwd is read for the PAGE only — one head per listed row, never one per file on the machine.
|
|
180
|
+
const entries = await Promise.all(all.slice(fromIndex, fromIndex + pageSize).map(async (entry) => ({
|
|
181
|
+
...entry,
|
|
182
|
+
cwd: await readTranscriptCwd(`${resolvedRoot}/${entry.projectSlug}/${entry.sessionId}.jsonl`),
|
|
183
|
+
})));
|
|
184
|
+
const nextIndex = fromIndex + entries.length;
|
|
185
|
+
return {
|
|
186
|
+
entries,
|
|
187
|
+
totalCount: all.length,
|
|
188
|
+
nextIndex: nextIndex < all.length ? nextIndex : null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Probe one transcript from `fromOffset` for a user entry matching `needle` (null = any user-text
|
|
193
|
+
* entry). Jail violations refuse; an absent file answers `{absent: true}`; a read that FAILS is a
|
|
194
|
+
* refusal (`transcript-read-failed`), never a false negative — to the asker "no new entry" and
|
|
195
|
+
* "could not look" must not be the same answer.
|
|
196
|
+
*/
|
|
197
|
+
export async function tailTranscript(root, projectSlug, sessionId, probe = {}) {
|
|
198
|
+
const resolved = resolveTranscriptPath(root, projectSlug, sessionId);
|
|
199
|
+
if (!resolved.ok)
|
|
200
|
+
return resolved;
|
|
201
|
+
let stats;
|
|
202
|
+
try {
|
|
203
|
+
stats = await stat(resolved.value);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return ok({ absent: true, found: false, newOffset: 0, sizeBytes: null, mtimeMs: null });
|
|
207
|
+
}
|
|
208
|
+
if (!stats.isFile()) {
|
|
209
|
+
return ok({ absent: true, found: false, newOffset: 0, sizeBytes: null, mtimeMs: null });
|
|
210
|
+
}
|
|
211
|
+
const sizeBytes = stats.size;
|
|
212
|
+
const mtimeMs = Math.floor(stats.mtimeMs);
|
|
213
|
+
const asked = Math.max(0, probe.fromOffset ?? 0);
|
|
214
|
+
// A file shorter than the baseline was rewritten (compaction, or a fork replacing it): scan from
|
|
215
|
+
// the start and let the needle reject stale entries — resuming into a rewritten file would read
|
|
216
|
+
// from the middle of a line that no longer exists.
|
|
217
|
+
const start = sizeBytes < asked ? 0 : asked;
|
|
218
|
+
const needle = probe.needle ?? null;
|
|
219
|
+
if (start >= sizeBytes) {
|
|
220
|
+
return ok({ absent: false, found: false, newOffset: sizeBytes, sizeBytes, mtimeMs });
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const found = await scanForUserEntry(resolved.value, start, sizeBytes, needle);
|
|
224
|
+
return ok({ absent: false, found, newOffset: sizeBytes, sizeBytes, mtimeMs });
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
return refuse('transcript-read-failed', `reading ${projectSlug}/${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The bulk-lane resolver for transcript reads: `claude-transcript:{slug}/{sessionId}` to a jailed
|
|
232
|
+
* absolute path. A locator outside the namespace is refused as an invalid bulk target so an
|
|
233
|
+
* embedder composing several resolvers can tell "not mine" from "mine and malformed".
|
|
234
|
+
*/
|
|
235
|
+
export function claudeTranscriptResolver(root) {
|
|
236
|
+
return (what) => {
|
|
237
|
+
if (!what.startsWith(TRANSCRIPT_WHAT_PREFIX)) {
|
|
238
|
+
return refuse('bulk-target-invalid', `this host resolves "${TRANSCRIPT_WHAT_PREFIX}{projectSlug}/{sessionId}" locators; got "${what}"`);
|
|
239
|
+
}
|
|
240
|
+
const rest = what.slice(TRANSCRIPT_WHAT_PREFIX.length);
|
|
241
|
+
const slash = rest.indexOf('/');
|
|
242
|
+
if (slash <= 0 || slash === rest.length - 1) {
|
|
243
|
+
return refuse('bulk-target-invalid', `a transcript locator is "${TRANSCRIPT_WHAT_PREFIX}{projectSlug}/{sessionId}"; got "${what}"`);
|
|
244
|
+
}
|
|
245
|
+
return resolveTranscriptPath(root, rest.slice(0, slash), rest.slice(slash + 1));
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// The user-entry predicate: the rule that decides whether a new user entry landed.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
/** Collapse every whitespace run to one space and trim, so needle matching survives reflowing. */
|
|
252
|
+
const normalizeForMatch = (text) => text.replace(/\s+/g, ' ').trim();
|
|
253
|
+
/** A user entry's text: a flat string content, or its concatenated `text` blocks. */
|
|
254
|
+
function userTextOf(message) {
|
|
255
|
+
const content = message.content;
|
|
256
|
+
if (typeof content === 'string')
|
|
257
|
+
return content;
|
|
258
|
+
if (!Array.isArray(content))
|
|
259
|
+
return '';
|
|
260
|
+
let out = '';
|
|
261
|
+
for (const block of content) {
|
|
262
|
+
if (block !== null &&
|
|
263
|
+
typeof block === 'object' &&
|
|
264
|
+
block.type === 'text' &&
|
|
265
|
+
typeof block.text === 'string') {
|
|
266
|
+
out += block.text;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* One line: does it count as a matching user entry? Unparseable or foreign lines are skipped,
|
|
273
|
+
* never thrown — a truncated LAST line is the normal state of a file being appended to. A
|
|
274
|
+
* tool-result-only user line has no text block and so never matches.
|
|
275
|
+
*/
|
|
276
|
+
export function isMatchingUserEntry(line, needle) {
|
|
277
|
+
let root;
|
|
278
|
+
try {
|
|
279
|
+
root = JSON.parse(line);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root))
|
|
285
|
+
return false;
|
|
286
|
+
if (root.type !== 'user')
|
|
287
|
+
return false;
|
|
288
|
+
const message = root.message;
|
|
289
|
+
if (message === null || typeof message !== 'object' || Array.isArray(message))
|
|
290
|
+
return false;
|
|
291
|
+
const role = message.role;
|
|
292
|
+
if (role !== undefined && role !== 'user')
|
|
293
|
+
return false;
|
|
294
|
+
const text = userTextOf(message);
|
|
295
|
+
if (text.trim().length === 0)
|
|
296
|
+
return false;
|
|
297
|
+
if (needle === null || needle === '')
|
|
298
|
+
return true;
|
|
299
|
+
return normalizeForMatch(text).includes(needle);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Read `[start, end)` in chunks with a line carry, so a large transcript never lands in one buffer
|
|
303
|
+
* and a partial final line is simply the last carry. The final carry is tested too — an
|
|
304
|
+
* unterminated tail is still a line.
|
|
305
|
+
*/
|
|
306
|
+
function scanForUserEntry(file, start, end, needle) {
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
const stream = createReadStream(file, { start, end: end - 1, encoding: 'utf8' });
|
|
309
|
+
let carry = '';
|
|
310
|
+
let found = false;
|
|
311
|
+
stream.on('data', (chunk) => {
|
|
312
|
+
if (found)
|
|
313
|
+
return;
|
|
314
|
+
const text = carry + String(chunk);
|
|
315
|
+
const lines = text.split('\n');
|
|
316
|
+
carry = lines.pop() ?? '';
|
|
317
|
+
for (const raw of lines) {
|
|
318
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
319
|
+
if (line.length === 0)
|
|
320
|
+
continue;
|
|
321
|
+
if (isMatchingUserEntry(line, needle)) {
|
|
322
|
+
found = true;
|
|
323
|
+
stream.destroy();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
stream.on('error', reject);
|
|
329
|
+
stream.on('close', () => {
|
|
330
|
+
if (!found && carry.length > 0) {
|
|
331
|
+
const line = carry.endsWith('\r') ? carry.slice(0, -1) : carry;
|
|
332
|
+
if (line.length > 0 && isMatchingUserEntry(line, needle))
|
|
333
|
+
found = true;
|
|
334
|
+
}
|
|
335
|
+
resolve(found);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
//# sourceMappingURL=claude-transcripts.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every key the config file may carry — the environment variables the composition root reads,
|
|
3
|
+
* minus `PERISCOPE_CONFIG_DIR` (the bootstrap exclusion above).
|
|
4
|
+
*/
|
|
5
|
+
export declare const CONFIG_KEYS: readonly ["PERISCOPE_CONTROLLER_URL", "PERISCOPE_DECISION_URL", "PERISCOPE_HOST_ID", "PERISCOPE_WORKSPACE_ROOT", "PERISCOPE_REPOSITORY_ROOT", "PERISCOPE_BRANCH_SCHEME", "PERISCOPE_WORKSPACE_KEY", "PERISCOPE_AGENT_HOME"];
|
|
6
|
+
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
7
|
+
/**
|
|
8
|
+
* The keys a controller may set over the wire (`host_configure`): where this machine's things are,
|
|
9
|
+
* and where its control plane is. The two URLs are written to the file and never applied to
|
|
10
|
+
* the live link: the host dials what it dialled until its next start, and names the keys as pending
|
|
11
|
+
* in its answer and in every hello until then. Never the host id (a paired credential outranks it
|
|
12
|
+
* anyway) or the default workspace key (a start-up posture, screened at boot).
|
|
13
|
+
*/
|
|
14
|
+
export declare const WIRE_CONFIGURABLE_KEYS: readonly ["PERISCOPE_WORKSPACE_ROOT", "PERISCOPE_REPOSITORY_ROOT", "PERISCOPE_BRANCH_SCHEME", "PERISCOPE_AGENT_HOME", "PERISCOPE_CONTROLLER_URL", "PERISCOPE_DECISION_URL"];
|
|
15
|
+
export type WireConfigurableKey = (typeof WIRE_CONFIGURABLE_KEYS)[number];
|
|
16
|
+
/** The wire-settable keys that take effect only at the next start: the control-plane addresses. */
|
|
17
|
+
export declare const RESTART_KEYS: readonly ["PERISCOPE_CONTROLLER_URL", "PERISCOPE_DECISION_URL"];
|
|
18
|
+
export declare function isWireConfigurableKey(value: string): value is WireConfigurableKey;
|
|
19
|
+
export declare function isConfigKey(value: string): value is ConfigKey;
|
|
20
|
+
/** Where the config file lives, or null when there is nowhere to keep one (no home, no override). */
|
|
21
|
+
export declare function configFilePath(env?: NodeJS.ProcessEnv): string | null;
|
|
22
|
+
/** What a read found: the values, or the named problem that makes them unusable. */
|
|
23
|
+
export interface ConfigFileReading {
|
|
24
|
+
readonly values: Readonly<Record<string, string>>;
|
|
25
|
+
/** Null when the file is usable (a missing file is usable: it reads as empty). */
|
|
26
|
+
readonly problem: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read the config file. A MISSING file is `{}` — the ordinary state of every machine that has
|
|
30
|
+
* never run `periscope config`. A file that exists and cannot be used is a PROBLEM, never a silent
|
|
31
|
+
* empty: falling back would make a corrupt file behave like a deliberate absence, which is the
|
|
32
|
+
* exact ambiguity the paired credential's fatal-on-corrupt posture exists to end.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readConfigFile(env?: NodeJS.ProcessEnv): ConfigFileReading;
|
|
35
|
+
/**
|
|
36
|
+
* Write one entry, creating the directory and file as needed. Returns null, or the named problem.
|
|
37
|
+
* Refuses an unknown key BEFORE touching the disk — see the closed-set rule in the header.
|
|
38
|
+
*/
|
|
39
|
+
export declare function writeConfigEntry(env: NodeJS.ProcessEnv, key: string, value: string): string | null;
|
|
40
|
+
/** One entry of a multi-key write: a value to set, or null to remove the key. */
|
|
41
|
+
export interface ConfigEntryWrite {
|
|
42
|
+
readonly key: string;
|
|
43
|
+
readonly value: string | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Write several entries as one read-modify-write, creating the directory and file as needed.
|
|
47
|
+
* Returns null, or the named problem. Every key is screened BEFORE the disk is touched, so a set
|
|
48
|
+
* with one bad key writes nothing; a null value removes its key. The same corrupt-file posture as
|
|
49
|
+
* the single-entry write: never merged over.
|
|
50
|
+
*/
|
|
51
|
+
export declare function writeConfigEntries(env: NodeJS.ProcessEnv, entries: readonly ConfigEntryWrite[]): string | null;
|
|
52
|
+
/**
|
|
53
|
+
* The environment, with file values FILLING ABSENCES — the one implementation of "env wins".
|
|
54
|
+
*
|
|
55
|
+
* Returns a NEW plain object; the process environment is never mutated. Only the closed key set is
|
|
56
|
+
* consulted, so nothing outside it (identity configuration, the config dir itself) can arrive from
|
|
57
|
+
* a file. Empty-string env values count as absent, matching how the composition root reads them.
|
|
58
|
+
*/
|
|
59
|
+
export declare function withConfigFallback(env: NodeJS.ProcessEnv, values: Readonly<Record<string, string>>): NodeJS.ProcessEnv;
|
|
60
|
+
//# sourceMappingURL=config-file.d.ts.map
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The config file: the CLI as a first-class writer, so a library user with no UI is not env-only.
|
|
3
|
+
*
|
|
4
|
+
* It lives beside the credential files on purpose. `periscopeCredentialDir(env)` is the single
|
|
5
|
+
* source both the token cache's location and the gate's protected set derive from, so a config file
|
|
6
|
+
* written under it is protected from the sessions this host runs by having been put there — not by
|
|
7
|
+
* somebody remembering to add a line. (Nothing in it is secret today; the property costs nothing
|
|
8
|
+
* and the alternative is a second directory with a second protection question.)
|
|
9
|
+
*
|
|
10
|
+
* The environment still wins, per key. The file is a fallback for an absence: a value here is
|
|
11
|
+
* consulted only when the same variable is unset or empty in the process environment, so a
|
|
12
|
+
* deployment that sets env vars is configured by them alone — and there is
|
|
13
|
+
* never a precedence question to look up, because a stated env var makes the file's value
|
|
14
|
+
* unreachable. `withConfigFallback` is the one place that rule is implemented.
|
|
15
|
+
*
|
|
16
|
+
* The key set is closed, on both the write and the read. An unknown key written is refused naming
|
|
17
|
+
* the legal set (a typo'd key that "worked" would configure nothing, silently). An unknown key
|
|
18
|
+
* read is a problem too, for the same reason from the other side: this host wrote none, so one in
|
|
19
|
+
* the file is a hand-edit that does nothing, the misconfiguration nobody finds. And
|
|
20
|
+
* `PERISCOPE_CONFIG_DIR` is deliberately not settable here: it is what says where this file is,
|
|
21
|
+
* and a file that could move itself is a bootstrap circle.
|
|
22
|
+
*
|
|
23
|
+
* Sync I/O throughout, deliberately: `serve()` stays synchronous (its own docblock says why), and a
|
|
24
|
+
* one-shot read at boot plus a one-shot write per `config` invocation need no event loop.
|
|
25
|
+
*/
|
|
26
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { join, periscopeCredentialDir } from './paths.js';
|
|
28
|
+
/**
|
|
29
|
+
* Every key the config file may carry — the environment variables the composition root reads,
|
|
30
|
+
* minus `PERISCOPE_CONFIG_DIR` (the bootstrap exclusion above).
|
|
31
|
+
*/
|
|
32
|
+
export const CONFIG_KEYS = [
|
|
33
|
+
'PERISCOPE_CONTROLLER_URL',
|
|
34
|
+
'PERISCOPE_DECISION_URL',
|
|
35
|
+
'PERISCOPE_HOST_ID',
|
|
36
|
+
'PERISCOPE_WORKSPACE_ROOT',
|
|
37
|
+
'PERISCOPE_REPOSITORY_ROOT',
|
|
38
|
+
'PERISCOPE_BRANCH_SCHEME',
|
|
39
|
+
'PERISCOPE_WORKSPACE_KEY',
|
|
40
|
+
'PERISCOPE_AGENT_HOME',
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* The keys a controller may set over the wire (`host_configure`): where this machine's things are,
|
|
44
|
+
* and where its control plane is. The two URLs are written to the file and never applied to
|
|
45
|
+
* the live link: the host dials what it dialled until its next start, and names the keys as pending
|
|
46
|
+
* in its answer and in every hello until then. Never the host id (a paired credential outranks it
|
|
47
|
+
* anyway) or the default workspace key (a start-up posture, screened at boot).
|
|
48
|
+
*/
|
|
49
|
+
export const WIRE_CONFIGURABLE_KEYS = [
|
|
50
|
+
'PERISCOPE_WORKSPACE_ROOT',
|
|
51
|
+
'PERISCOPE_REPOSITORY_ROOT',
|
|
52
|
+
'PERISCOPE_BRANCH_SCHEME',
|
|
53
|
+
'PERISCOPE_AGENT_HOME',
|
|
54
|
+
'PERISCOPE_CONTROLLER_URL',
|
|
55
|
+
'PERISCOPE_DECISION_URL',
|
|
56
|
+
];
|
|
57
|
+
/** The wire-settable keys that take effect only at the next start: the control-plane addresses. */
|
|
58
|
+
export const RESTART_KEYS = [
|
|
59
|
+
'PERISCOPE_CONTROLLER_URL',
|
|
60
|
+
'PERISCOPE_DECISION_URL',
|
|
61
|
+
];
|
|
62
|
+
export function isWireConfigurableKey(value) {
|
|
63
|
+
return WIRE_CONFIGURABLE_KEYS.includes(value);
|
|
64
|
+
}
|
|
65
|
+
export function isConfigKey(value) {
|
|
66
|
+
return CONFIG_KEYS.includes(value);
|
|
67
|
+
}
|
|
68
|
+
/** Where the config file lives, or null when there is nowhere to keep one (no home, no override). */
|
|
69
|
+
export function configFilePath(env = process.env) {
|
|
70
|
+
const dir = periscopeCredentialDir(env);
|
|
71
|
+
return dir === null ? null : join(dir, 'config.json');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Read the config file. A MISSING file is `{}` — the ordinary state of every machine that has
|
|
75
|
+
* never run `periscope config`. A file that exists and cannot be used is a PROBLEM, never a silent
|
|
76
|
+
* empty: falling back would make a corrupt file behave like a deliberate absence, which is the
|
|
77
|
+
* exact ambiguity the paired credential's fatal-on-corrupt posture exists to end.
|
|
78
|
+
*/
|
|
79
|
+
export function readConfigFile(env = process.env) {
|
|
80
|
+
const path = configFilePath(env);
|
|
81
|
+
if (path === null)
|
|
82
|
+
return { values: {}, problem: null };
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = readFileSync(path, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code === 'ENOENT')
|
|
89
|
+
return { values: {}, problem: null };
|
|
90
|
+
return {
|
|
91
|
+
values: {},
|
|
92
|
+
problem: `the config file at ${path} exists and cannot be read: ${describe(error)}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(raw);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
return {
|
|
101
|
+
values: {},
|
|
102
|
+
problem: `the config file at ${path} is not JSON: ${describe(error)}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
106
|
+
return {
|
|
107
|
+
values: {},
|
|
108
|
+
problem: `the config file at ${path} must hold one JSON object of string values`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const values = {};
|
|
112
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
113
|
+
if (!isConfigKey(key)) {
|
|
114
|
+
return {
|
|
115
|
+
values: {},
|
|
116
|
+
problem: `the config file at ${path} carries an unknown key '${key}' — this host reads only ` +
|
|
117
|
+
`${CONFIG_KEYS.join(', ')}, so the entry would configure nothing`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (typeof value !== 'string') {
|
|
121
|
+
return {
|
|
122
|
+
values: {},
|
|
123
|
+
problem: `the config file at ${path} carries a non-string value for '${key}'`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
values[key] = value;
|
|
127
|
+
}
|
|
128
|
+
return { values, problem: null };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Write one entry, creating the directory and file as needed. Returns null, or the named problem.
|
|
132
|
+
* Refuses an unknown key BEFORE touching the disk — see the closed-set rule in the header.
|
|
133
|
+
*/
|
|
134
|
+
export function writeConfigEntry(env, key, value) {
|
|
135
|
+
if (!isConfigKey(key)) {
|
|
136
|
+
return `'${key}' is not a config key this host reads — the legal keys are ${CONFIG_KEYS.join(', ')}`;
|
|
137
|
+
}
|
|
138
|
+
const path = configFilePath(env);
|
|
139
|
+
if (path === null) {
|
|
140
|
+
return 'there is nowhere to keep a config file — no home directory and no PERISCOPE_CONFIG_DIR';
|
|
141
|
+
}
|
|
142
|
+
const existing = readConfigFile(env);
|
|
143
|
+
if (existing.problem !== null) {
|
|
144
|
+
// Never merged-over: writing "around" a corrupt file destroys whatever the operator hand-wrote.
|
|
145
|
+
return `${existing.problem} — fix or remove it before writing`;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const dir = path.slice(0, path.lastIndexOf('/'));
|
|
149
|
+
mkdirSync(dir, { recursive: true });
|
|
150
|
+
const next = { ...existing.values, [key]: value };
|
|
151
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
return `the config file at ${path} could not be written: ${describe(error)}`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Write several entries as one read-modify-write, creating the directory and file as needed.
|
|
160
|
+
* Returns null, or the named problem. Every key is screened BEFORE the disk is touched, so a set
|
|
161
|
+
* with one bad key writes nothing; a null value removes its key. The same corrupt-file posture as
|
|
162
|
+
* the single-entry write: never merged over.
|
|
163
|
+
*/
|
|
164
|
+
export function writeConfigEntries(env, entries) {
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
if (!isConfigKey(entry.key)) {
|
|
167
|
+
return `'${entry.key}' is not a config key this host reads — the legal keys are ${CONFIG_KEYS.join(', ')}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const path = configFilePath(env);
|
|
171
|
+
if (path === null) {
|
|
172
|
+
return 'there is nowhere to keep a config file — no home directory and no PERISCOPE_CONFIG_DIR';
|
|
173
|
+
}
|
|
174
|
+
const existing = readConfigFile(env);
|
|
175
|
+
if (existing.problem !== null) {
|
|
176
|
+
return `${existing.problem} — fix or remove it before writing`;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const dir = path.slice(0, path.lastIndexOf('/'));
|
|
180
|
+
mkdirSync(dir, { recursive: true });
|
|
181
|
+
const next = { ...existing.values };
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
if (entry.value === null)
|
|
184
|
+
delete next[entry.key];
|
|
185
|
+
else
|
|
186
|
+
next[entry.key] = entry.value;
|
|
187
|
+
}
|
|
188
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
return `the config file at ${path} could not be written: ${describe(error)}`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The environment, with file values FILLING ABSENCES — the one implementation of "env wins".
|
|
197
|
+
*
|
|
198
|
+
* Returns a NEW plain object; the process environment is never mutated. Only the closed key set is
|
|
199
|
+
* consulted, so nothing outside it (identity configuration, the config dir itself) can arrive from
|
|
200
|
+
* a file. Empty-string env values count as absent, matching how the composition root reads them.
|
|
201
|
+
*/
|
|
202
|
+
export function withConfigFallback(env, values) {
|
|
203
|
+
const merged = { ...env };
|
|
204
|
+
for (const key of CONFIG_KEYS) {
|
|
205
|
+
const fromEnv = env[key];
|
|
206
|
+
const fromFile = values[key];
|
|
207
|
+
if ((fromEnv === undefined || fromEnv === '') && fromFile !== undefined && fromFile !== '') {
|
|
208
|
+
merged[key] = fromFile;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return merged;
|
|
212
|
+
}
|
|
213
|
+
function describe(error) {
|
|
214
|
+
return error instanceof Error ? error.message : String(error);
|
|
215
|
+
}
|
|
216
|
+
//# sourceMappingURL=config-file.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TransitionWhere } from '../state/model.js';
|
|
2
|
+
/**
|
|
3
|
+
* The `where` for a directory. Never throws — the caller stamps this onto every transition, so a
|
|
4
|
+
* throw here would take out the state record along with the fact it was decorating.
|
|
5
|
+
*/
|
|
6
|
+
export declare function readWhere(cwd: string): TransitionWhere;
|
|
7
|
+
//# sourceMappingURL=git-facts.d.ts.map
|