@junwu168/openshell 0.1.2 → 0.1.4
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/dist/cli/openshell.js +4 -0
- package/dist/core/audit/log-store.js +1 -1
- package/dist/core/orchestrator.d.ts +2 -2
- package/dist/core/orchestrator.js +3 -3
- package/dist/core/result.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/opencode/plugin.d.ts +1 -1
- package/dist/opencode/plugin.js +8 -8
- package/package.json +6 -1
- package/.claude/settings.local.json +0 -15
- package/bun.lock +0 -368
- package/docs/superpowers/notes/2026-03-25-opencode-remote-tools-handoff.md +0 -81
- package/docs/superpowers/notes/2026-03-26-openshell-pre-release-review.md +0 -174
- package/docs/superpowers/plans/2026-03-25-opencode-remote-tools.md +0 -1656
- package/docs/superpowers/plans/2026-03-25-server-registry-cli.md +0 -54
- package/docs/superpowers/plans/2026-03-26-config-backed-credential-registry.md +0 -494
- package/docs/superpowers/plans/2026-03-26-openshell-release-prep.md +0 -639
- package/docs/superpowers/specs/2026-03-25-opencode-remote-tools-design.md +0 -378
- package/docs/superpowers/specs/2026-03-26-config-backed-credential-registry-design.md +0 -272
- package/docs/superpowers/specs/2026-03-26-openshell-release-prep-design.md +0 -197
- package/examples/opencode-local/opencode.json +0 -19
- package/scripts/openshell.ts +0 -3
- package/scripts/server-registry.ts +0 -3
- package/src/cli/openshell.ts +0 -60
- package/src/cli/server-registry.ts +0 -476
- package/src/core/audit/git-audit-repo.ts +0 -42
- package/src/core/audit/log-store.ts +0 -20
- package/src/core/audit/redact.ts +0 -4
- package/src/core/contracts.ts +0 -51
- package/src/core/orchestrator.ts +0 -1082
- package/src/core/patch.ts +0 -11
- package/src/core/paths.ts +0 -32
- package/src/core/policy.ts +0 -30
- package/src/core/registry/server-registry.ts +0 -505
- package/src/core/result.ts +0 -16
- package/src/core/ssh/ssh-runtime.ts +0 -355
- package/src/index.ts +0 -3
- package/src/opencode/plugin.ts +0 -242
- package/src/product/install.ts +0 -43
- package/src/product/opencode-config.ts +0 -118
- package/src/product/uninstall.ts +0 -47
- package/src/product/workspace-tracker.ts +0 -69
- package/tests/integration/fake-ssh-server.ts +0 -97
- package/tests/integration/install-lifecycle.test.ts +0 -85
- package/tests/integration/orchestrator.test.ts +0 -767
- package/tests/integration/ssh-runtime.test.ts +0 -122
- package/tests/unit/audit.test.ts +0 -221
- package/tests/unit/build-layout.test.ts +0 -28
- package/tests/unit/opencode-config.test.ts +0 -100
- package/tests/unit/opencode-plugin.test.ts +0 -358
- package/tests/unit/openshell-cli.test.ts +0 -60
- package/tests/unit/paths.test.ts +0 -64
- package/tests/unit/plugin-export.test.ts +0 -10
- package/tests/unit/policy.test.ts +0 -53
- package/tests/unit/release-docs.test.ts +0 -31
- package/tests/unit/result.test.ts +0 -28
- package/tests/unit/server-registry-cli.test.ts +0 -673
- package/tests/unit/server-registry.test.ts +0 -452
- package/tests/unit/workspace-tracker.test.ts +0 -57
- package/tsconfig.json +0 -14
package/src/core/patch.ts
DELETED
package/src/core/paths.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import envPaths from "env-paths"
|
|
2
|
-
import { mkdir } from "node:fs/promises"
|
|
3
|
-
import { cwd } from "node:process"
|
|
4
|
-
|
|
5
|
-
export const createRuntimePaths = (workspaceRoot: string) => {
|
|
6
|
-
const openshellPaths = envPaths("openshell", { suffix: "" })
|
|
7
|
-
const opencodePaths = envPaths("opencode", { suffix: "" })
|
|
8
|
-
|
|
9
|
-
return {
|
|
10
|
-
configDir: openshellPaths.config,
|
|
11
|
-
dataDir: openshellPaths.data,
|
|
12
|
-
globalRegistryFile: `${openshellPaths.config}/servers.json`,
|
|
13
|
-
workspaceTrackerFile: `${openshellPaths.data}/workspaces.json`,
|
|
14
|
-
opencodeConfigDir: opencodePaths.config,
|
|
15
|
-
opencodeConfigFile: `${opencodePaths.config}/opencode.json`,
|
|
16
|
-
workspaceRegistryDir: `${workspaceRoot}/.open-code`,
|
|
17
|
-
workspaceRegistryFile: `${workspaceRoot}/.open-code/servers.json`,
|
|
18
|
-
auditLogFile: `${openshellPaths.data}/audit/actions.jsonl`,
|
|
19
|
-
auditRepoDir: `${openshellPaths.data}/audit/repo`,
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export const runtimePaths = createRuntimePaths(cwd())
|
|
24
|
-
|
|
25
|
-
export const workspaceRegistryFile = (workspaceRoot: string) =>
|
|
26
|
-
`${workspaceRoot}/.open-code/servers.json`
|
|
27
|
-
|
|
28
|
-
export const ensureRuntimeDirs = async () => {
|
|
29
|
-
await mkdir(`${runtimePaths.dataDir}/audit`, { recursive: true })
|
|
30
|
-
await mkdir(runtimePaths.auditRepoDir, { recursive: true })
|
|
31
|
-
await mkdir(runtimePaths.configDir, { recursive: true })
|
|
32
|
-
}
|
package/src/core/policy.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
const SAFE_COMMANDS = new Set(["cat", "grep", "find", "ls", "pwd", "uname", "df", "free", "ps"])
|
|
2
|
-
const MIDDLEWARE_COMMANDS = new Set([
|
|
3
|
-
"psql",
|
|
4
|
-
"mysql",
|
|
5
|
-
"redis-cli",
|
|
6
|
-
"kubectl",
|
|
7
|
-
"docker",
|
|
8
|
-
"helm",
|
|
9
|
-
"aws",
|
|
10
|
-
"gcloud",
|
|
11
|
-
"az",
|
|
12
|
-
])
|
|
13
|
-
const SHELL_META = ["|", ">", "<", ";", "&&", "||", "$(", "`"]
|
|
14
|
-
|
|
15
|
-
export const classifyRemoteExec = (command: string) => {
|
|
16
|
-
const trimmed = command.trim()
|
|
17
|
-
if (!trimmed) return { decision: "reject", reason: "empty command" } as const
|
|
18
|
-
if (SHELL_META.some((token) => trimmed.includes(token))) {
|
|
19
|
-
return { decision: "approval-required", reason: "shell composition" } as const
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const [binary, subcommand] = trimmed.split(/\s+/)
|
|
23
|
-
if (MIDDLEWARE_COMMANDS.has(binary)) {
|
|
24
|
-
return { decision: "approval-required", reason: "middleware command" } as const
|
|
25
|
-
}
|
|
26
|
-
if (SAFE_COMMANDS.has(binary) || (binary === "systemctl" && subcommand === "status")) {
|
|
27
|
-
return { decision: "auto-allow", reason: "safe inspection command" } as const
|
|
28
|
-
}
|
|
29
|
-
return { decision: "approval-required", reason: "unknown command" } as const
|
|
30
|
-
}
|
|
@@ -1,505 +0,0 @@
|
|
|
1
|
-
import { link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
2
|
-
import { execFile } from "node:child_process"
|
|
3
|
-
import { randomUUID } from "node:crypto"
|
|
4
|
-
import { dirname } from "node:path"
|
|
5
|
-
import { promisify } from "node:util"
|
|
6
|
-
|
|
7
|
-
export type ServerMetadataValue = string | number | boolean | null
|
|
8
|
-
|
|
9
|
-
export interface PasswordAuthRecord {
|
|
10
|
-
kind: "password"
|
|
11
|
-
secret: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface PrivateKeyAuthRecord {
|
|
15
|
-
kind: "privateKey"
|
|
16
|
-
privateKeyPath: string
|
|
17
|
-
passphrase?: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface CertificateAuthRecord {
|
|
21
|
-
kind: "certificate"
|
|
22
|
-
certificatePath: string
|
|
23
|
-
privateKeyPath: string
|
|
24
|
-
passphrase?: string
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type ServerAuthRecord =
|
|
28
|
-
| PasswordAuthRecord
|
|
29
|
-
| PrivateKeyAuthRecord
|
|
30
|
-
| CertificateAuthRecord
|
|
31
|
-
|
|
32
|
-
export interface ServerRecord {
|
|
33
|
-
id: string
|
|
34
|
-
host: string
|
|
35
|
-
port: number
|
|
36
|
-
username: string
|
|
37
|
-
labels?: string[]
|
|
38
|
-
groups?: string[]
|
|
39
|
-
metadata?: Record<string, ServerMetadataValue>
|
|
40
|
-
auth: ServerAuthRecord
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type RegistryScope = "global" | "workspace"
|
|
44
|
-
|
|
45
|
-
export type ResolvedServerRecord = ServerRecord & {
|
|
46
|
-
scope: RegistryScope
|
|
47
|
-
shadowingGlobal?: boolean
|
|
48
|
-
workspaceRoot?: string
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export class RegistryRecordValidationError extends Error {
|
|
52
|
-
readonly code = "REGISTRY_RECORD_INVALID" as const
|
|
53
|
-
|
|
54
|
-
constructor(
|
|
55
|
-
readonly file: string,
|
|
56
|
-
readonly index: number,
|
|
57
|
-
message: string,
|
|
58
|
-
) {
|
|
59
|
-
super(`Invalid registry record in ${file} at index ${index}: ${message}`)
|
|
60
|
-
this.name = "RegistryRecordValidationError"
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface ServerRegistry {
|
|
65
|
-
list(): Promise<ResolvedServerRecord[]>
|
|
66
|
-
resolve(id: string): Promise<ResolvedServerRecord | null>
|
|
67
|
-
upsert(scope: RegistryScope, record: ServerRecord): Promise<void>
|
|
68
|
-
remove(scope: RegistryScope, id: string): Promise<boolean>
|
|
69
|
-
listRaw(scope: RegistryScope): Promise<ServerRecord[]>
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
type FileLockOptions = {
|
|
73
|
-
getProcessStartTime?: (pid: number) => Promise<number | null>
|
|
74
|
-
retryMs?: number
|
|
75
|
-
timeoutMs?: number
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface CreateServerRegistryOptions {
|
|
79
|
-
globalRegistryFile: string
|
|
80
|
-
workspaceRegistryFile: string
|
|
81
|
-
workspaceRoot: string
|
|
82
|
-
lockOptions?: FileLockOptions
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const isRecordObject = (value: unknown): value is Record<string, unknown> =>
|
|
86
|
-
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
87
|
-
|
|
88
|
-
const validateString = (value: unknown, field: string, file: string, index: number) => {
|
|
89
|
-
if (typeof value !== "string" || value.trim() === "") {
|
|
90
|
-
throw new RegistryRecordValidationError(file, index, `${field} must be a non-empty string`)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return value
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const validateStringArray = (
|
|
97
|
-
value: unknown,
|
|
98
|
-
field: string,
|
|
99
|
-
file: string,
|
|
100
|
-
index: number,
|
|
101
|
-
): string[] | undefined => {
|
|
102
|
-
if (value === undefined) {
|
|
103
|
-
return undefined
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) {
|
|
107
|
-
throw new RegistryRecordValidationError(file, index, `${field} must be an array of non-empty strings`)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return value
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const validateMetadata = (
|
|
114
|
-
value: unknown,
|
|
115
|
-
file: string,
|
|
116
|
-
index: number,
|
|
117
|
-
): Record<string, ServerMetadataValue> | undefined => {
|
|
118
|
-
if (value === undefined) {
|
|
119
|
-
return undefined
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (!isRecordObject(value)) {
|
|
123
|
-
throw new RegistryRecordValidationError(file, index, "metadata must be an object")
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
127
|
-
if (typeof key !== "string" || key.trim() === "") {
|
|
128
|
-
throw new RegistryRecordValidationError(file, index, "metadata keys must be non-empty strings")
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
!(
|
|
133
|
-
typeof entry === "string" ||
|
|
134
|
-
typeof entry === "number" ||
|
|
135
|
-
typeof entry === "boolean" ||
|
|
136
|
-
entry === null
|
|
137
|
-
)
|
|
138
|
-
) {
|
|
139
|
-
throw new RegistryRecordValidationError(file, index, "metadata values must be strings, numbers, booleans, or null")
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return value as Record<string, ServerMetadataValue>
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const validateAuth = (auth: unknown, file: string, index: number): ServerAuthRecord => {
|
|
147
|
-
if (!isRecordObject(auth)) {
|
|
148
|
-
throw new RegistryRecordValidationError(file, index, "auth must be an object")
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const kind = validateString(auth.kind, "auth.kind", file, index)
|
|
152
|
-
switch (kind) {
|
|
153
|
-
case "password":
|
|
154
|
-
return {
|
|
155
|
-
kind,
|
|
156
|
-
secret: validateString(auth.secret, "auth.secret", file, index),
|
|
157
|
-
}
|
|
158
|
-
case "privateKey":
|
|
159
|
-
return {
|
|
160
|
-
kind,
|
|
161
|
-
privateKeyPath: validateString(auth.privateKeyPath, "auth.privateKeyPath", file, index),
|
|
162
|
-
...(typeof auth.passphrase === "string" ? { passphrase: auth.passphrase } : {}),
|
|
163
|
-
}
|
|
164
|
-
case "certificate":
|
|
165
|
-
return {
|
|
166
|
-
kind,
|
|
167
|
-
certificatePath: validateString(auth.certificatePath, "auth.certificatePath", file, index),
|
|
168
|
-
privateKeyPath: validateString(auth.privateKeyPath, "auth.privateKeyPath", file, index),
|
|
169
|
-
...(typeof auth.passphrase === "string" ? { passphrase: auth.passphrase } : {}),
|
|
170
|
-
}
|
|
171
|
-
default:
|
|
172
|
-
throw new RegistryRecordValidationError(file, index, `unsupported auth kind: ${kind}`)
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const validateRecord = (record: unknown, file: string, index: number): ServerRecord => {
|
|
177
|
-
if (!isRecordObject(record)) {
|
|
178
|
-
throw new RegistryRecordValidationError(file, index, "record must be an object")
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const labels = validateStringArray(record.labels, "labels", file, index)
|
|
182
|
-
const groups = validateStringArray(record.groups, "groups", file, index)
|
|
183
|
-
const metadata = validateMetadata(record.metadata, file, index)
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
id: validateString(record.id, "id", file, index),
|
|
187
|
-
host: validateString(record.host, "host", file, index),
|
|
188
|
-
port: (() => {
|
|
189
|
-
const port = record.port
|
|
190
|
-
if (typeof port !== "number" || !Number.isInteger(port) || port <= 0) {
|
|
191
|
-
throw new RegistryRecordValidationError(file, index, "port must be a positive integer")
|
|
192
|
-
}
|
|
193
|
-
return port
|
|
194
|
-
})(),
|
|
195
|
-
username: validateString(record.username, "username", file, index),
|
|
196
|
-
...(labels ? { labels } : {}),
|
|
197
|
-
...(groups ? { groups } : {}),
|
|
198
|
-
...(metadata ? { metadata } : {}),
|
|
199
|
-
auth: validateAuth(record.auth, file, index),
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const parseRecords = (raw: string, file: string) => {
|
|
204
|
-
const parsed = JSON.parse(raw) as unknown
|
|
205
|
-
if (!Array.isArray(parsed)) {
|
|
206
|
-
throw new Error(`Invalid registry file: ${file}`)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return parsed.map((record, index) => validateRecord(record, file, index))
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const buildResolvedRecord = (
|
|
213
|
-
record: ServerRecord,
|
|
214
|
-
scope: RegistryScope,
|
|
215
|
-
workspaceRoot: string,
|
|
216
|
-
shadowingGlobal = false,
|
|
217
|
-
): ResolvedServerRecord => ({
|
|
218
|
-
...record,
|
|
219
|
-
scope,
|
|
220
|
-
...(scope === "workspace" ? { workspaceRoot } : {}),
|
|
221
|
-
...(shadowingGlobal ? { shadowingGlobal: true } : {}),
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
export const createServerRegistry = ({
|
|
225
|
-
globalRegistryFile,
|
|
226
|
-
workspaceRegistryFile,
|
|
227
|
-
workspaceRoot,
|
|
228
|
-
lockOptions,
|
|
229
|
-
}: CreateServerRegistryOptions): ServerRegistry => {
|
|
230
|
-
const execFileAsync = promisify(execFile)
|
|
231
|
-
const resolveProcessStartTime = lockOptions?.getProcessStartTime
|
|
232
|
-
const lockRetryMs = lockOptions?.retryMs ?? 10
|
|
233
|
-
const lockTimeoutMs = lockOptions?.timeoutMs ?? 5_000
|
|
234
|
-
const fileQueues = new Map<string, Promise<void>>()
|
|
235
|
-
|
|
236
|
-
const scopeFile = (scope: RegistryScope) =>
|
|
237
|
-
scope === "global" ? globalRegistryFile : workspaceRegistryFile
|
|
238
|
-
|
|
239
|
-
const getFileQueue = (file: string) => fileQueues.get(file) ?? Promise.resolve()
|
|
240
|
-
|
|
241
|
-
const enqueueWrite = <T>(file: string, operation: () => Promise<T>) => {
|
|
242
|
-
const next = getFileQueue(file).then(operation)
|
|
243
|
-
fileQueues.set(
|
|
244
|
-
file,
|
|
245
|
-
next.then(
|
|
246
|
-
() => undefined,
|
|
247
|
-
() => undefined,
|
|
248
|
-
),
|
|
249
|
-
)
|
|
250
|
-
|
|
251
|
-
return next
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const waitForWrites = async (...files: string[]) => {
|
|
255
|
-
await Promise.all(files.map((file) => getFileQueue(file)))
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
259
|
-
|
|
260
|
-
const isProcessAlive = (pid: number) => {
|
|
261
|
-
try {
|
|
262
|
-
process.kill(pid, 0)
|
|
263
|
-
return true
|
|
264
|
-
} catch (error: unknown) {
|
|
265
|
-
const code = (error as NodeJS.ErrnoException).code
|
|
266
|
-
if (code === "ESRCH") {
|
|
267
|
-
return false
|
|
268
|
-
}
|
|
269
|
-
if (code === "EPERM") {
|
|
270
|
-
return true
|
|
271
|
-
}
|
|
272
|
-
throw error
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const getProcessStartTime = async (pid: number) => {
|
|
277
|
-
if (resolveProcessStartTime) {
|
|
278
|
-
return resolveProcessStartTime(pid)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
const { stdout } = await execFileAsync("ps", ["-o", "etimes=", "-p", String(pid)])
|
|
283
|
-
const elapsedSecondsText = stdout.trim()
|
|
284
|
-
if (!/^\d+$/.test(elapsedSecondsText)) {
|
|
285
|
-
return null
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const elapsedSeconds = Number.parseInt(elapsedSecondsText, 10)
|
|
289
|
-
if (!Number.isFinite(elapsedSeconds)) {
|
|
290
|
-
return null
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return Date.now() - elapsedSeconds * 1_000
|
|
294
|
-
} catch (error: unknown) {
|
|
295
|
-
const exitCode = (error as { code?: unknown }).code
|
|
296
|
-
if (exitCode === 1 || exitCode === "EPERM") {
|
|
297
|
-
return null
|
|
298
|
-
}
|
|
299
|
-
throw error
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const tryReclaimAbandonedLock = async (lockFile: string) => {
|
|
304
|
-
let raw: string
|
|
305
|
-
try {
|
|
306
|
-
raw = await readFile(lockFile, "utf8")
|
|
307
|
-
} catch (error: unknown) {
|
|
308
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
309
|
-
return false
|
|
310
|
-
}
|
|
311
|
-
throw error
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
let ownerPid: number | null = null
|
|
315
|
-
let lockCreatedAt: number | null = null
|
|
316
|
-
try {
|
|
317
|
-
const parsed = JSON.parse(raw) as { pid?: unknown; createdAt?: unknown }
|
|
318
|
-
ownerPid = typeof parsed.pid === "number" ? parsed.pid : null
|
|
319
|
-
if (typeof parsed.createdAt === "string") {
|
|
320
|
-
const parsedCreatedAt = Date.parse(parsed.createdAt)
|
|
321
|
-
lockCreatedAt = Number.isNaN(parsedCreatedAt) ? null : parsedCreatedAt
|
|
322
|
-
}
|
|
323
|
-
} catch {
|
|
324
|
-
return false
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (ownerPid === null) {
|
|
328
|
-
return false
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (isProcessAlive(ownerPid)) {
|
|
332
|
-
if (lockCreatedAt === null) {
|
|
333
|
-
return false
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const ownerStartedAt = await getProcessStartTime(ownerPid)
|
|
337
|
-
if (ownerStartedAt === null || ownerStartedAt <= lockCreatedAt) {
|
|
338
|
-
return false
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const reclaimedLockFile = `${lockFile}.reclaimed.${randomUUID()}`
|
|
343
|
-
try {
|
|
344
|
-
await rename(lockFile, reclaimedLockFile)
|
|
345
|
-
} catch (error: unknown) {
|
|
346
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
347
|
-
return false
|
|
348
|
-
}
|
|
349
|
-
throw error
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
await rm(reclaimedLockFile, { force: true })
|
|
353
|
-
return true
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const withFileLock = async <T>(file: string, operation: () => Promise<T>): Promise<T> => {
|
|
357
|
-
const lockFile = `${file}.lock`
|
|
358
|
-
const startedAt = Date.now()
|
|
359
|
-
await mkdir(dirname(file), { recursive: true })
|
|
360
|
-
|
|
361
|
-
while (true) {
|
|
362
|
-
const pendingLockFile = `${lockFile}.${process.pid}.${randomUUID()}.pending`
|
|
363
|
-
try {
|
|
364
|
-
await writeFile(
|
|
365
|
-
pendingLockFile,
|
|
366
|
-
JSON.stringify({
|
|
367
|
-
pid: process.pid,
|
|
368
|
-
createdAt: new Date().toISOString(),
|
|
369
|
-
}),
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
await link(pendingLockFile, lockFile)
|
|
373
|
-
|
|
374
|
-
try {
|
|
375
|
-
return await operation()
|
|
376
|
-
} finally {
|
|
377
|
-
await rm(lockFile, { force: true })
|
|
378
|
-
}
|
|
379
|
-
} catch (error: unknown) {
|
|
380
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
381
|
-
throw error
|
|
382
|
-
}
|
|
383
|
-
} finally {
|
|
384
|
-
await rm(pendingLockFile, { force: true })
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (await tryReclaimAbandonedLock(lockFile)) {
|
|
388
|
-
continue
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (Date.now() - startedAt > lockTimeoutMs) {
|
|
392
|
-
throw new Error(`Timed out waiting for registry lock: ${lockFile}`)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
await sleep(lockRetryMs)
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const loadRaw = async (scope: RegistryScope): Promise<ServerRecord[]> => {
|
|
400
|
-
const file = scopeFile(scope)
|
|
401
|
-
|
|
402
|
-
try {
|
|
403
|
-
const raw = await readFile(file, "utf8")
|
|
404
|
-
return parseRecords(raw, file)
|
|
405
|
-
} catch (error: unknown) {
|
|
406
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
407
|
-
return []
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
throw error
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const saveRaw = async (scope: RegistryScope, records: ServerRecord[]) => {
|
|
415
|
-
const file = scopeFile(scope)
|
|
416
|
-
await mkdir(dirname(file), { recursive: true })
|
|
417
|
-
const tempFile = `${file}.${process.pid}.${randomUUID()}.tmp`
|
|
418
|
-
|
|
419
|
-
try {
|
|
420
|
-
await writeFile(tempFile, JSON.stringify(records, null, 2))
|
|
421
|
-
await rename(tempFile, file)
|
|
422
|
-
} catch (error) {
|
|
423
|
-
await rm(tempFile, { force: true })
|
|
424
|
-
throw error
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const mergeRecords = (
|
|
429
|
-
globalRecords: ServerRecord[],
|
|
430
|
-
workspaceRecords: ServerRecord[],
|
|
431
|
-
): ResolvedServerRecord[] => {
|
|
432
|
-
const merged = new Map<string, ResolvedServerRecord>()
|
|
433
|
-
const order: string[] = []
|
|
434
|
-
const globalIds = new Set<string>()
|
|
435
|
-
|
|
436
|
-
for (const record of globalRecords) {
|
|
437
|
-
globalIds.add(record.id)
|
|
438
|
-
if (!merged.has(record.id)) {
|
|
439
|
-
order.push(record.id)
|
|
440
|
-
}
|
|
441
|
-
merged.set(record.id, buildResolvedRecord(record, "global", workspaceRoot))
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
for (const record of workspaceRecords) {
|
|
445
|
-
const shadowingGlobal = globalIds.has(record.id)
|
|
446
|
-
if (!merged.has(record.id)) {
|
|
447
|
-
order.push(record.id)
|
|
448
|
-
}
|
|
449
|
-
merged.set(record.id, buildResolvedRecord(record, "workspace", workspaceRoot, shadowingGlobal))
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
return order.map((id) => merged.get(id)!).filter(Boolean)
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
return {
|
|
456
|
-
async list() {
|
|
457
|
-
await waitForWrites(globalRegistryFile, workspaceRegistryFile)
|
|
458
|
-
const [globalRecords, workspaceRecords] = await Promise.all([
|
|
459
|
-
loadRaw("global"),
|
|
460
|
-
loadRaw("workspace"),
|
|
461
|
-
])
|
|
462
|
-
return mergeRecords(globalRecords, workspaceRecords)
|
|
463
|
-
},
|
|
464
|
-
async resolve(id) {
|
|
465
|
-
await waitForWrites(globalRegistryFile, workspaceRegistryFile)
|
|
466
|
-
const [globalRecords, workspaceRecords] = await Promise.all([
|
|
467
|
-
loadRaw("global"),
|
|
468
|
-
loadRaw("workspace"),
|
|
469
|
-
])
|
|
470
|
-
return mergeRecords(globalRecords, workspaceRecords).find((record) => record.id === id) ?? null
|
|
471
|
-
},
|
|
472
|
-
async upsert(scope, record) {
|
|
473
|
-
const file = scopeFile(scope)
|
|
474
|
-
return enqueueWrite(file, async () =>
|
|
475
|
-
withFileLock(file, async () => {
|
|
476
|
-
const records = await loadRaw(scope)
|
|
477
|
-
const next = records.filter((item) => item.id !== record.id)
|
|
478
|
-
next.push(record)
|
|
479
|
-
await saveRaw(scope, next)
|
|
480
|
-
}),
|
|
481
|
-
)
|
|
482
|
-
},
|
|
483
|
-
async remove(scope, id) {
|
|
484
|
-
const file = scopeFile(scope)
|
|
485
|
-
return enqueueWrite(file, async () =>
|
|
486
|
-
withFileLock(file, async () => {
|
|
487
|
-
const records = await loadRaw(scope)
|
|
488
|
-
const next = records.filter((item) => item.id !== id)
|
|
489
|
-
|
|
490
|
-
if (next.length === records.length) {
|
|
491
|
-
return false
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
await saveRaw(scope, next)
|
|
495
|
-
return true
|
|
496
|
-
}),
|
|
497
|
-
)
|
|
498
|
-
},
|
|
499
|
-
async listRaw(scope) {
|
|
500
|
-
const file = scopeFile(scope)
|
|
501
|
-
await waitForWrites(file)
|
|
502
|
-
return loadRaw(scope)
|
|
503
|
-
},
|
|
504
|
-
}
|
|
505
|
-
}
|
package/src/core/result.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { ToolPayload, ToolResult } from "./contracts"
|
|
2
|
-
|
|
3
|
-
export const okResult = <T>(payload: ToolPayload<T>): ToolResult<T> => ({
|
|
4
|
-
...payload,
|
|
5
|
-
status: "ok",
|
|
6
|
-
})
|
|
7
|
-
|
|
8
|
-
export const partialFailureResult = <T>(payload: ToolPayload<T>): ToolResult<T> => ({
|
|
9
|
-
...payload,
|
|
10
|
-
status: "partial_failure",
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
export const errorResult = <T>(payload: ToolPayload<T>): ToolResult<T> => ({
|
|
14
|
-
...payload,
|
|
15
|
-
status: "error",
|
|
16
|
-
})
|