@frontera-sdk/cli 1.46.6 → 1.46.7
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/package.json +6 -3
- package/src/commands/forge/index-commands.ts +4 -0
- package/src/commands/forge/serve.ts +144 -0
- package/src/commands/registry.ts +3 -0
- package/src/flag-help.ts +7 -1
- package/src/forge/adapter.ts +108 -0
- package/src/forge/claude-adapter.ts +205 -0
- package/src/forge/claude-commands.ts +136 -0
- package/src/forge/claude-skills.ts +189 -0
- package/src/forge/claude-translator.ts +361 -0
- package/src/forge/codex-adapter.ts +28 -0
- package/src/forge/env.ts +42 -0
- package/src/forge/hosts.ts +323 -0
- package/src/forge/http.ts +74 -0
- package/src/forge/log.ts +53 -0
- package/src/forge/projects.ts +349 -0
- package/src/forge/protocol.ts +38 -0
- package/src/forge/runs.ts +250 -0
- package/src/forge/server.ts +655 -0
- package/src/forge/sessions.ts +49 -0
- package/src/forge/store.ts +427 -0
- package/src/forge/terminals.ts +220 -0
- package/src/forge/turn-text.ts +35 -0
- package/src/kit.ts +25 -7
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ForgeEffort,
|
|
5
|
+
ForgeHostId,
|
|
6
|
+
ForgeHostInfo,
|
|
7
|
+
ForgeHostMode,
|
|
8
|
+
ForgeHostModel,
|
|
9
|
+
ForgeSessionView,
|
|
10
|
+
} from '@frontera-sdk/forge-contracts'
|
|
11
|
+
|
|
12
|
+
import { forgeEnv } from './env'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What the daemon knows about each coding host without asking it much.
|
|
16
|
+
*
|
|
17
|
+
* Modes are the host's own: Claude Code's `permissionMode` values and Codex's
|
|
18
|
+
* sandbox/approval vocabulary, verbatim. Forge does not define a fourth set
|
|
19
|
+
* and map into it — the developer already knows their harness's modes, and a
|
|
20
|
+
* translation layer is only a place for the two to disagree.
|
|
21
|
+
*
|
|
22
|
+
* Model catalogs are a v1 stub: neither host exposes a cheap "list models"
|
|
23
|
+
* call, so these are the ids the hosts document today. Any id the client
|
|
24
|
+
* passes is accepted as-is (see `modelsFor`), which is how a newer model
|
|
25
|
+
* reaches the picker before this list learns about it.
|
|
26
|
+
*/
|
|
27
|
+
export interface HostDefinition {
|
|
28
|
+
id: ForgeHostId
|
|
29
|
+
label: string
|
|
30
|
+
bin: string
|
|
31
|
+
loginCommand: string
|
|
32
|
+
models: ForgeHostModel[]
|
|
33
|
+
defaultModel: string
|
|
34
|
+
modes: ForgeHostMode[]
|
|
35
|
+
defaultMode: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Claude Code's effort levels, in the ids the platform's picker renders. */
|
|
39
|
+
const CLAUDE_EFFORTS: ForgeEffort[] = ['low', 'medium', 'high', 'xhigh']
|
|
40
|
+
const CLAUDE_DEFAULT_EFFORT: ForgeEffort = 'high'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Claude Code's own mode picker, verbatim.
|
|
44
|
+
*
|
|
45
|
+
* Ids are the Agent SDK's `PermissionMode`; labels, descriptions and order are
|
|
46
|
+
* the ones Claude Code shows in its own menu. Forge writes none of this — a
|
|
47
|
+
* developer who switches between the CLI and this surface should not have to
|
|
48
|
+
* learn a second name for the same setting, and a third vocabulary is how one
|
|
49
|
+
* feature ends up explaining itself differently in every place it appears.
|
|
50
|
+
*
|
|
51
|
+
* `dontAsk` is the one SDK value left out: it denies anything not pre-approved,
|
|
52
|
+
* which needs an allow-list Forge has no UI for yet.
|
|
53
|
+
*/
|
|
54
|
+
const CLAUDE_MODES: ForgeHostMode[] = [
|
|
55
|
+
{
|
|
56
|
+
id: 'auto',
|
|
57
|
+
label: 'Auto',
|
|
58
|
+
description: 'Claude handles permission decisions',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'default',
|
|
62
|
+
label: 'Manual',
|
|
63
|
+
description: 'Always ask before making changes',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'acceptEdits',
|
|
67
|
+
label: 'Accept edits',
|
|
68
|
+
description: 'Automatically accept all file edits',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'plan',
|
|
72
|
+
label: 'Plan',
|
|
73
|
+
description: 'Create a plan before making changes',
|
|
74
|
+
},
|
|
75
|
+
// Claude Code offers this as a separate "Enable" rather than a listed mode,
|
|
76
|
+
// and gives it no subtitle. Neither do we.
|
|
77
|
+
{
|
|
78
|
+
id: 'bypassPermissions',
|
|
79
|
+
label: 'Bypass permissions',
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Codex's own sandbox modes, verbatim.
|
|
85
|
+
*
|
|
86
|
+
* Ids are its `--sandbox` values; labels and descriptions are the strings the
|
|
87
|
+
* Codex binary itself renders. Declarative until its adapter lands.
|
|
88
|
+
*/
|
|
89
|
+
const CODEX_MODES: ForgeHostMode[] = [
|
|
90
|
+
{
|
|
91
|
+
id: 'read-only',
|
|
92
|
+
label: 'Read Only',
|
|
93
|
+
description:
|
|
94
|
+
'Codex can read files in the current workspace. Approval is required to edit files or access the internet.',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'workspace-write',
|
|
98
|
+
label: 'Default',
|
|
99
|
+
description:
|
|
100
|
+
'Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files.',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'danger-full-access',
|
|
104
|
+
label: 'Full Access',
|
|
105
|
+
description:
|
|
106
|
+
'Codex can edit files outside this workspace and access the internet without asking for approval. Exercise caution when using.',
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
export const HOSTS: Record<ForgeHostId, HostDefinition> = {
|
|
111
|
+
claude: {
|
|
112
|
+
id: 'claude',
|
|
113
|
+
label: 'Claude Code',
|
|
114
|
+
bin: 'claude',
|
|
115
|
+
loginCommand: 'claude auth login',
|
|
116
|
+
models: [
|
|
117
|
+
{ id: 'claude-opus-5', label: 'Claude Opus 5', efforts: CLAUDE_EFFORTS, defaultEffort: CLAUDE_DEFAULT_EFFORT },
|
|
118
|
+
{ id: 'claude-fable-5', label: 'Claude Fable 5', efforts: CLAUDE_EFFORTS, defaultEffort: CLAUDE_DEFAULT_EFFORT },
|
|
119
|
+
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', efforts: CLAUDE_EFFORTS, defaultEffort: CLAUDE_DEFAULT_EFFORT },
|
|
120
|
+
{
|
|
121
|
+
id: 'claude-haiku-4-5-20251001',
|
|
122
|
+
label: 'Claude Haiku 4.5',
|
|
123
|
+
efforts: CLAUDE_EFFORTS,
|
|
124
|
+
defaultEffort: CLAUDE_DEFAULT_EFFORT,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
defaultModel: 'claude-sonnet-5',
|
|
128
|
+
modes: CLAUDE_MODES,
|
|
129
|
+
defaultMode: 'auto',
|
|
130
|
+
},
|
|
131
|
+
codex: {
|
|
132
|
+
id: 'codex',
|
|
133
|
+
label: 'Codex',
|
|
134
|
+
bin: 'codex',
|
|
135
|
+
loginCommand: 'codex login',
|
|
136
|
+
models: [{ id: 'gpt-5-codex', label: 'GPT-5 Codex', efforts: ['low', 'medium', 'high'], defaultEffort: 'medium' }],
|
|
137
|
+
defaultModel: 'gpt-5-codex',
|
|
138
|
+
modes: CODEX_MODES,
|
|
139
|
+
defaultMode: 'workspace-write',
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const HOST_IDS = Object.keys(HOSTS) as ForgeHostId[]
|
|
144
|
+
|
|
145
|
+
export function isHostId(value: unknown): value is ForgeHostId {
|
|
146
|
+
return typeof value === 'string' && value in HOSTS
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Mode ids this host declares, for validation and for the 400 that names them. */
|
|
150
|
+
export function modeIdsFor(id: ForgeHostId): string[] {
|
|
151
|
+
return HOSTS[id].modes.map((m) => m.id)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isHostMode(id: ForgeHostId, value: unknown): value is string {
|
|
155
|
+
return typeof value === 'string' && modeIdsFor(id).includes(value)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The catalog entry for a model id, when the host documents it. */
|
|
159
|
+
export function hostModel(id: ForgeHostId, model: string | null): ForgeHostModel | null {
|
|
160
|
+
if (!model) return null
|
|
161
|
+
return HOSTS[id].models.find((m) => m.id === model) ?? null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The effort a freshly chosen model starts on; null when it takes none. */
|
|
165
|
+
export function defaultEffortFor(id: ForgeHostId, model: string | null): string | null {
|
|
166
|
+
return hostModel(id, model)?.defaultEffort ?? null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface HostProbe {
|
|
170
|
+
installed: boolean
|
|
171
|
+
version?: string
|
|
172
|
+
authenticated?: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A host CLI that hangs must not hang the readiness probe with it. */
|
|
176
|
+
const PROBE_TIMEOUT_MS = 4_000
|
|
177
|
+
const CACHE_TTL_MS = 60_000
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* How a host binary is probed. Exported so a caller that must not touch the
|
|
181
|
+
* machine — every test of the daemon — can supply its own.
|
|
182
|
+
*/
|
|
183
|
+
export interface Exec {
|
|
184
|
+
(bin: string, args: string[]): { missing: boolean; ok: boolean; out: string }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const defaultExec: Exec = (bin, args) => {
|
|
188
|
+
const res = spawnSync(bin, args, {
|
|
189
|
+
encoding: 'utf8',
|
|
190
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
191
|
+
env: forgeEnv() as NodeJS.ProcessEnv,
|
|
192
|
+
})
|
|
193
|
+
if (res.error && (res.error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
194
|
+
return { missing: true, ok: false, out: '' }
|
|
195
|
+
}
|
|
196
|
+
return { missing: false, ok: res.status === 0, out: `${res.stdout ?? ''}${res.stderr ?? ''}`.trim() }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseVersion(out: string): string | undefined {
|
|
200
|
+
return out.match(/\b(\d+\.\d+\.\d+)\b/)?.[1]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `claude auth status` prints JSON in recent builds and prose in older ones;
|
|
205
|
+
* either way exit 0 means a session exists. A non-zero exit is "unknown":
|
|
206
|
+
* the subcommand may simply not exist in this version, and reporting a
|
|
207
|
+
* signed-in developer as signed out would send them to a login they already
|
|
208
|
+
* did.
|
|
209
|
+
*/
|
|
210
|
+
function claudeAuth(exec: Exec): boolean | undefined {
|
|
211
|
+
const res = exec('claude', ['auth', 'status'])
|
|
212
|
+
if (res.missing) return undefined
|
|
213
|
+
const fromJson = authFromJson(res.out)
|
|
214
|
+
if (fromJson !== undefined) return fromJson
|
|
215
|
+
return res.ok ? true : undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function authFromJson(out: string): boolean | undefined {
|
|
219
|
+
const start = out.indexOf('{')
|
|
220
|
+
if (start === -1) return undefined
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(out.slice(start)) as Record<string, unknown>
|
|
223
|
+
for (const key of ['loggedIn', 'isLoggedIn', 'authenticated', 'isAuthenticated']) {
|
|
224
|
+
if (typeof parsed[key] === 'boolean') return parsed[key] as boolean
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// Prose output; fall through to the exit code.
|
|
228
|
+
}
|
|
229
|
+
return undefined
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** `codex login status` exits non-zero when nobody is signed in. */
|
|
233
|
+
function codexAuth(exec: Exec): boolean | undefined {
|
|
234
|
+
const res = exec('codex', ['login', 'status'])
|
|
235
|
+
if (res.missing) return undefined
|
|
236
|
+
return res.ok
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function probeHost(id: ForgeHostId, exec: Exec = defaultExec): HostProbe {
|
|
240
|
+
const host = HOSTS[id]
|
|
241
|
+
const version = exec(host.bin, ['--version'])
|
|
242
|
+
if (version.missing) return { installed: false }
|
|
243
|
+
const probe: HostProbe = { installed: true }
|
|
244
|
+
const v = parseVersion(version.out)
|
|
245
|
+
if (v) probe.version = v
|
|
246
|
+
const auth = id === 'claude' ? claudeAuth(exec) : codexAuth(exec)
|
|
247
|
+
if (auth !== undefined) probe.authenticated = auth
|
|
248
|
+
return probe
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const cache = new Map<ForgeHostId, { at: number; probe: HostProbe }>()
|
|
252
|
+
|
|
253
|
+
export function probeHostCached(id: ForgeHostId, exec?: Exec, now = Date.now()): HostProbe {
|
|
254
|
+
// The cache is keyed by host id alone, so it can only ever hold answers from
|
|
255
|
+
// the real machine. An injected `exec` asks a different question and neither
|
|
256
|
+
// reads that cache nor writes to it.
|
|
257
|
+
if (exec) return probeHost(id, exec)
|
|
258
|
+
const hit = cache.get(id)
|
|
259
|
+
if (hit && now - hit.at < CACHE_TTL_MS) return hit.probe
|
|
260
|
+
const probe = probeHost(id, exec)
|
|
261
|
+
cache.set(id, { at: now, probe })
|
|
262
|
+
return probe
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function hostInfo(id: ForgeHostId, exec?: Exec): ForgeHostInfo {
|
|
266
|
+
const host = HOSTS[id]
|
|
267
|
+
const probe = probeHostCached(id, exec)
|
|
268
|
+
return {
|
|
269
|
+
id,
|
|
270
|
+
label: host.label,
|
|
271
|
+
installed: probe.installed,
|
|
272
|
+
...(probe.version ? { version: probe.version } : {}),
|
|
273
|
+
...(probe.authenticated !== undefined ? { authenticated: probe.authenticated } : {}),
|
|
274
|
+
models: host.models,
|
|
275
|
+
defaultModel: host.defaultModel,
|
|
276
|
+
modes: host.modes,
|
|
277
|
+
defaultMode: host.defaultMode,
|
|
278
|
+
loginCommand: host.loginCommand,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Which harness a session opens with when nobody chose one.
|
|
284
|
+
*
|
|
285
|
+
* Claude Code first because its adapter is the one that works; a machine with
|
|
286
|
+
* only Codex still gets a session rather than an error it cannot act on.
|
|
287
|
+
*/
|
|
288
|
+
export function firstInstalledHostId(exec?: Exec): ForgeHostId | null {
|
|
289
|
+
const installed = listHosts(exec).filter((host) => host.installed)
|
|
290
|
+
return installed.find((host) => host.id === 'claude')?.id ?? installed[0]?.id ?? null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function listHosts(exec?: Exec): ForgeHostInfo[] {
|
|
294
|
+
return HOST_IDS.map((id) => hostInfo(id, exec))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The host's catalog keyed by id, in the shape the platform's session payload
|
|
299
|
+
* uses — including the effort block the model picker reads — plus whatever the
|
|
300
|
+
* thread is actually set to, so a model chosen outside the catalog still
|
|
301
|
+
* renders with a name.
|
|
302
|
+
*/
|
|
303
|
+
export function modelsFor(id: ForgeHostId, current: string | null): ForgeSessionView['models'] {
|
|
304
|
+
const out: ForgeSessionView['models'] = {}
|
|
305
|
+
for (const m of HOSTS[id].models) {
|
|
306
|
+
out[m.id] = {
|
|
307
|
+
provider: id,
|
|
308
|
+
model: m.id,
|
|
309
|
+
name: m.label,
|
|
310
|
+
...(m.efforts && m.efforts.length > 0
|
|
311
|
+
? {
|
|
312
|
+
reasoning: {
|
|
313
|
+
style: 'effort' as const,
|
|
314
|
+
supportedEfforts: m.efforts,
|
|
315
|
+
...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
: {}),
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (current && !out[current]) out[current] = { provider: id, model: current, name: current }
|
|
322
|
+
return out
|
|
323
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ForgeEnvelope, ForgeErrorEnvelope } from '@frontera-sdk/forge-contracts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A request the daemon refuses, with the status it answers. Thrown from route
|
|
5
|
+
* handlers so the one place that renders errors is the dispatcher, the same
|
|
6
|
+
* way the CLI's own shell owns exit codes.
|
|
7
|
+
*/
|
|
8
|
+
export class ForgeHttpError extends Error {
|
|
9
|
+
readonly status: number
|
|
10
|
+
readonly code: string
|
|
11
|
+
|
|
12
|
+
constructor(status: number, message: string, code?: string) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = 'ForgeHttpError'
|
|
15
|
+
this.status = status
|
|
16
|
+
this.code = code ?? defaultCode(status)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultCode(status: number): string {
|
|
21
|
+
switch (status) {
|
|
22
|
+
case 400:
|
|
23
|
+
return 'BAD_REQUEST'
|
|
24
|
+
case 401:
|
|
25
|
+
return 'UNAUTHORIZED'
|
|
26
|
+
case 403:
|
|
27
|
+
return 'FORBIDDEN'
|
|
28
|
+
case 404:
|
|
29
|
+
return 'NOT_FOUND'
|
|
30
|
+
case 409:
|
|
31
|
+
return 'CONFLICT'
|
|
32
|
+
case 501:
|
|
33
|
+
return 'NOT_IMPLEMENTED'
|
|
34
|
+
default:
|
|
35
|
+
return 'INTERNAL_ERROR'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function okBody<T>(data: T): ForgeEnvelope<T> {
|
|
40
|
+
return { data, message: 'Success', error: false }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function errorBody(message: string, code: string): ForgeErrorEnvelope {
|
|
44
|
+
return { error: true, message, code }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function json(body: unknown, init: ResponseInit & { headers?: Record<string, string> } = {}): Response {
|
|
48
|
+
return new Response(JSON.stringify(body), {
|
|
49
|
+
...init,
|
|
50
|
+
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function badRequest(message: string): ForgeHttpError {
|
|
55
|
+
return new ForgeHttpError(400, message)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function notFound(what: string): ForgeHttpError {
|
|
59
|
+
return new ForgeHttpError(404, `${what} not found`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read a JSON body, refusing anything that is not an object. */
|
|
63
|
+
export async function readJson<T extends object>(req: Request): Promise<T> {
|
|
64
|
+
let parsed: unknown
|
|
65
|
+
try {
|
|
66
|
+
parsed = await req.json()
|
|
67
|
+
} catch {
|
|
68
|
+
throw badRequest('body must be JSON')
|
|
69
|
+
}
|
|
70
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
71
|
+
throw badRequest('body must be a JSON object')
|
|
72
|
+
}
|
|
73
|
+
return parsed as T
|
|
74
|
+
}
|
package/src/forge/log.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface Logger {
|
|
5
|
+
info(message: string): void
|
|
6
|
+
warn(message: string): void
|
|
7
|
+
error(message: string): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Truncate rather than rotate. The file exists so a "Forge stopped working"
|
|
12
|
+
* report can attach something; nobody needs last month's daemon chatter, and
|
|
13
|
+
* a rotation scheme is more code than the value it protects.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_BYTES = 5 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
/** How often the size check runs — a stat per line would dominate the write. */
|
|
18
|
+
const CHECK_EVERY = 50
|
|
19
|
+
|
|
20
|
+
export function createLogger(
|
|
21
|
+
file: string | null,
|
|
22
|
+
streams: { err(chunk: string): void } = { err: (chunk) => process.stderr.write(chunk) },
|
|
23
|
+
): Logger {
|
|
24
|
+
let writes = 0
|
|
25
|
+
if (file) {
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
28
|
+
} catch {
|
|
29
|
+
file = null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const write = (level: string, message: string): void => {
|
|
34
|
+
streams.err(`[forge] ${message}\n`)
|
|
35
|
+
if (!file) return
|
|
36
|
+
try {
|
|
37
|
+
if (writes++ % CHECK_EVERY === 0 && existsSync(file) && statSync(file).size > MAX_BYTES) {
|
|
38
|
+
writeFileSync(file, '')
|
|
39
|
+
}
|
|
40
|
+
appendFileSync(file, `${new Date().toISOString()} ${level} ${message}\n`)
|
|
41
|
+
} catch {
|
|
42
|
+
// Logging must never take the daemon down.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
info: (m) => write('info', m),
|
|
48
|
+
warn: (m) => write('warn', m),
|
|
49
|
+
error: (m) => write('error', m),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const silentLogger: Logger = { info() {}, warn() {}, error() {} }
|