@marcusrbrown/infra 0.13.0 → 0.13.2
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.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
package/src/lib/action-ctx.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal `ctx` interface every MCP-capturable action accepts.
|
|
3
|
-
*
|
|
4
|
-
* Structural subtype of goke's real `GokeExecutionContext` — actions
|
|
5
|
-
* consume only `console.{log,error}`, `process.{stdout,stderr}.write`,
|
|
6
|
-
* and `process.exit`, never the `fs` surface. Source files import this
|
|
7
|
-
* shape from this module so the action contract is defined in one place.
|
|
8
|
-
*
|
|
9
|
-
* The `write` parameter is `string` (narrower than goke's real
|
|
10
|
-
* `string | Uint8Array`) because every action passes string values;
|
|
11
|
-
* `CapturedCtx.write(string | Uint8Array)` is still assignable to
|
|
12
|
-
* `ActionCtx.write(string)` via contravariance, so tests using
|
|
13
|
-
* `CapturedCtx` satisfy this interface.
|
|
14
|
-
*/
|
|
15
|
-
export interface ActionCtx {
|
|
16
|
-
console: {
|
|
17
|
-
log: (...args: unknown[]) => void
|
|
18
|
-
error: (...args: unknown[]) => void
|
|
19
|
-
}
|
|
20
|
-
process: {
|
|
21
|
-
stdout: {write: (chunk: string) => void}
|
|
22
|
-
stderr: {write: (chunk: string) => void}
|
|
23
|
-
exit: (code: number) => never | void
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import {existsSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'
|
|
2
|
-
import {tmpdir} from 'node:os'
|
|
3
|
-
import {join, resolve} from 'node:path'
|
|
4
|
-
|
|
5
|
-
import {describe, expect, it} from 'bun:test'
|
|
6
|
-
|
|
7
|
-
import {buildKnownHostsArgs, resolveKnownHostsPath, resolveRepoKnownHostsPath} from './known-hosts'
|
|
8
|
-
|
|
9
|
-
// ─── resolveRepoKnownHostsPath ────────────────────────────────────────────────
|
|
10
|
-
|
|
11
|
-
describe('resolveRepoKnownHostsPath', () => {
|
|
12
|
-
it('returns the path to .github/known_hosts when it exists at the repo root', () => {
|
|
13
|
-
// The actual repo has .github/known_hosts — this test runs from the repo root
|
|
14
|
-
const result = resolveRepoKnownHostsPath()
|
|
15
|
-
|
|
16
|
-
expect(result).not.toBeNull()
|
|
17
|
-
expect(result).toMatch(/\.github[/\\]known_hosts$/)
|
|
18
|
-
if (result) expect(existsSync(result)).toBe(true)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('returns null when a custom base path has no .github/known_hosts', () => {
|
|
22
|
-
const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
|
|
23
|
-
mkdirSync(tmpDir, {recursive: true})
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const result = resolveRepoKnownHostsPath(tmpDir)
|
|
27
|
-
expect(result).toBeNull()
|
|
28
|
-
} finally {
|
|
29
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
30
|
-
}
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('returns the path when a custom base path has .github/known_hosts', () => {
|
|
34
|
-
const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
|
|
35
|
-
const githubDir = join(tmpDir, '.github')
|
|
36
|
-
mkdirSync(githubDir, {recursive: true})
|
|
37
|
-
writeFileSync(join(githubDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const result = resolveRepoKnownHostsPath(tmpDir)
|
|
41
|
-
expect(result).not.toBeNull()
|
|
42
|
-
expect(result).toBe(join(tmpDir, '.github', 'known_hosts'))
|
|
43
|
-
} finally {
|
|
44
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
45
|
-
}
|
|
46
|
-
})
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
// ─── resolveKnownHostsPath (new: multi-layout resolution) ────────────────────
|
|
50
|
-
|
|
51
|
-
describe('resolveKnownHostsPath', () => {
|
|
52
|
-
it('returns the packaged resource path when only src/resources/known_hosts exists (installed package layout)', () => {
|
|
53
|
-
// Simulate: node_modules/@marcusrbrown/infra/src/lib/known-hosts.ts
|
|
54
|
-
// Only src/resources/known_hosts exists; no .github/known_hosts at any ancestor.
|
|
55
|
-
const tmpDir = join(tmpdir(), `known-hosts-installed-${Date.now()}`)
|
|
56
|
-
const libDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'lib')
|
|
57
|
-
const resourcesDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'resources')
|
|
58
|
-
mkdirSync(libDir, {recursive: true})
|
|
59
|
-
mkdirSync(resourcesDir, {recursive: true})
|
|
60
|
-
writeFileSync(join(resourcesDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
// Pass the simulated import.meta.dir (the lib dir inside the package)
|
|
64
|
-
const result = resolveKnownHostsPath(libDir)
|
|
65
|
-
expect(result).not.toBeNull()
|
|
66
|
-
expect(result).toBe(join(resourcesDir, 'known_hosts'))
|
|
67
|
-
} finally {
|
|
68
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
69
|
-
}
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('prefers .github/known_hosts over packaged resource when both exist (repo checkout layout)', () => {
|
|
73
|
-
// Mirror the real layout: packages/cli/src/lib is 4 levels deep from repo root.
|
|
74
|
-
// tmpDir is the "repo root"; libDir is tmpDir/packages/cli/src/lib.
|
|
75
|
-
const tmpDir = join(tmpdir(), `known-hosts-both-${Date.now()}`)
|
|
76
|
-
const libDir = join(tmpDir, 'packages', 'cli', 'src', 'lib')
|
|
77
|
-
const githubDir = join(tmpDir, '.github')
|
|
78
|
-
const resourcesDir = join(tmpDir, 'packages', 'cli', 'src', 'resources')
|
|
79
|
-
mkdirSync(libDir, {recursive: true})
|
|
80
|
-
mkdirSync(githubDir, {recursive: true})
|
|
81
|
-
mkdirSync(resourcesDir, {recursive: true})
|
|
82
|
-
writeFileSync(join(githubDir, 'known_hosts'), 'repo.example.com ssh-ed25519 AAAA...\n')
|
|
83
|
-
writeFileSync(join(resourcesDir, 'known_hosts'), 'packaged.example.com ssh-ed25519 AAAA...\n')
|
|
84
|
-
|
|
85
|
-
try {
|
|
86
|
-
// lib is at tmpDir/packages/cli/src/lib — 4 levels up is tmpDir (the "repo root")
|
|
87
|
-
const result = resolveKnownHostsPath(libDir)
|
|
88
|
-
expect(result).not.toBeNull()
|
|
89
|
-
// Should prefer .github/known_hosts (repo checkout)
|
|
90
|
-
expect(result).toBe(join(githubDir, 'known_hosts'))
|
|
91
|
-
} finally {
|
|
92
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
93
|
-
}
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
it('throws a clear error when neither .github/known_hosts nor packaged resource exists', () => {
|
|
97
|
-
const tmpDir = join(tmpdir(), `known-hosts-none-${Date.now()}`)
|
|
98
|
-
const libDir = join(tmpDir, 'src', 'lib')
|
|
99
|
-
mkdirSync(libDir, {recursive: true})
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
expect(() => resolveKnownHostsPath(libDir)).toThrow(
|
|
103
|
-
'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout',
|
|
104
|
-
)
|
|
105
|
-
} finally {
|
|
106
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
107
|
-
}
|
|
108
|
-
})
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
// ─── buildKnownHostsArgs ──────────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
describe('buildKnownHostsArgs', () => {
|
|
114
|
-
it('returns UserKnownHostsFile args when .github/known_hosts exists at repo root', () => {
|
|
115
|
-
const args = buildKnownHostsArgs()
|
|
116
|
-
|
|
117
|
-
// The actual repo has .github/known_hosts
|
|
118
|
-
expect(args).toHaveLength(2)
|
|
119
|
-
expect(args[0]).toBe('-o')
|
|
120
|
-
expect(args[1]).toMatch(/^UserKnownHostsFile=/)
|
|
121
|
-
expect(args[1]).toMatch(/\.github[/\\]known_hosts$/)
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('throws when no known_hosts file exists (fail-closed behavior)', () => {
|
|
125
|
-
const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
|
|
126
|
-
const libDir = join(tmpDir, 'src', 'lib')
|
|
127
|
-
mkdirSync(libDir, {recursive: true})
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
expect(() => buildKnownHostsArgs(libDir)).toThrow(
|
|
131
|
-
'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout',
|
|
132
|
-
)
|
|
133
|
-
} finally {
|
|
134
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
135
|
-
}
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it('returns UserKnownHostsFile args pointing to the packaged resource in installed layout', () => {
|
|
139
|
-
const tmpDir = join(tmpdir(), `known-hosts-installed-${Date.now()}`)
|
|
140
|
-
const libDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'lib')
|
|
141
|
-
const resourcesDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'resources')
|
|
142
|
-
mkdirSync(libDir, {recursive: true})
|
|
143
|
-
mkdirSync(resourcesDir, {recursive: true})
|
|
144
|
-
writeFileSync(join(resourcesDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
const args = buildKnownHostsArgs(libDir)
|
|
148
|
-
expect(args).toHaveLength(2)
|
|
149
|
-
expect(args[0]).toBe('-o')
|
|
150
|
-
expect(args[1]).toBe(`UserKnownHostsFile=${join(resourcesDir, 'known_hosts')}`)
|
|
151
|
-
} finally {
|
|
152
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
153
|
-
}
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('returns UserKnownHostsFile args pointing to the custom base path (repo checkout)', () => {
|
|
157
|
-
// Mirror the real layout: packages/cli/src/lib is 4 levels deep from repo root.
|
|
158
|
-
const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
|
|
159
|
-
const libDir = join(tmpDir, 'packages', 'cli', 'src', 'lib')
|
|
160
|
-
const githubDir = join(tmpDir, '.github')
|
|
161
|
-
mkdirSync(libDir, {recursive: true})
|
|
162
|
-
mkdirSync(githubDir, {recursive: true})
|
|
163
|
-
writeFileSync(join(githubDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
const args = buildKnownHostsArgs(libDir)
|
|
167
|
-
expect(args).toHaveLength(2)
|
|
168
|
-
expect(args[0]).toBe('-o')
|
|
169
|
-
expect(args[1]).toBe(`UserKnownHostsFile=${join(githubDir, 'known_hosts')}`)
|
|
170
|
-
} finally {
|
|
171
|
-
rmSync(tmpDir, {recursive: true, force: true})
|
|
172
|
-
}
|
|
173
|
-
})
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
// ─── Drift guard: packaged resource matches .github/known_hosts ───────────────
|
|
177
|
-
|
|
178
|
-
describe('packaged known_hosts resource drift guard', () => {
|
|
179
|
-
const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..', '..')
|
|
180
|
-
const REPO_KNOWN_HOSTS = join(REPO_ROOT, '.github', 'known_hosts')
|
|
181
|
-
const PACKAGED_RESOURCE = resolve(import.meta.dir, '..', 'resources', 'known_hosts')
|
|
182
|
-
|
|
183
|
-
it('src/resources/known_hosts exists in the package source tree', () => {
|
|
184
|
-
expect(existsSync(PACKAGED_RESOURCE)).toBe(true)
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
it('src/resources/known_hosts matches .github/known_hosts (no drift)', async () => {
|
|
188
|
-
const repoContent = await Bun.file(REPO_KNOWN_HOSTS).text()
|
|
189
|
-
const packagedContent = await Bun.file(PACKAGED_RESOURCE).text()
|
|
190
|
-
expect(packagedContent).toBe(repoContent)
|
|
191
|
-
})
|
|
192
|
-
})
|
package/src/lib/known-hosts.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import {existsSync} from 'node:fs'
|
|
2
|
-
import {join, resolve} from 'node:path'
|
|
3
|
-
|
|
4
|
-
const FAIL_CLOSED_ERROR =
|
|
5
|
-
'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Resolve the path to the repo-pinned `.github/known_hosts` file.
|
|
9
|
-
*
|
|
10
|
-
* SSH commands in this CLI use `StrictHostKeyChecking=yes` but the invoking
|
|
11
|
-
* user's `~/.ssh/known_hosts` may not contain the pinned host keys for
|
|
12
|
-
* `metrics.fro.bot`, `gateway.fro.bot`, etc. The repo ships authoritative
|
|
13
|
-
* host keys in `.github/known_hosts`. When that file exists, SSH commands
|
|
14
|
-
* should use it via `UserKnownHostsFile=<path>` so host verification succeeds
|
|
15
|
-
* without weakening `StrictHostKeyChecking`.
|
|
16
|
-
*
|
|
17
|
-
* @param repoRoot - Optional override for the repo root directory. Defaults to
|
|
18
|
-
* the actual repo root resolved relative to this file's location. Pass a
|
|
19
|
-
* custom path in tests to avoid touching the real filesystem.
|
|
20
|
-
* @returns Absolute path to `.github/known_hosts` if it exists, otherwise null.
|
|
21
|
-
*/
|
|
22
|
-
export function resolveRepoKnownHostsPath(repoRoot?: string): string | null {
|
|
23
|
-
// This file lives at packages/cli/src/lib/known-hosts.ts
|
|
24
|
-
// Repo root is 4 levels up: lib → src → cli → packages → repo root
|
|
25
|
-
const base = repoRoot ?? resolve(import.meta.dir, '..', '..', '..', '..')
|
|
26
|
-
const candidate = join(base, '.github', 'known_hosts')
|
|
27
|
-
return existsSync(candidate) ? candidate : null
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Resolve the pinned known_hosts file from either layout:
|
|
32
|
-
*
|
|
33
|
-
* 1. Repo checkout: `.github/known_hosts` at the monorepo root (4 levels up
|
|
34
|
-
* from `src/lib/`).
|
|
35
|
-
* 2. Installed package: `src/resources/known_hosts` bundled alongside the
|
|
36
|
-
* source (1 level up from `src/lib/`, then into `resources/`).
|
|
37
|
-
*
|
|
38
|
-
* Prefers the repo checkout layout when both exist. Throws a clear error if
|
|
39
|
-
* neither is found — fail-closed, never silently falls back to user
|
|
40
|
-
* `~/.ssh/known_hosts`.
|
|
41
|
-
*
|
|
42
|
-
* @param libDir - Override for the directory containing this file (for tests).
|
|
43
|
-
* Defaults to `import.meta.dir`.
|
|
44
|
-
* @returns Absolute path to the pinned known_hosts file.
|
|
45
|
-
* @throws Error if no pinned known_hosts file is found.
|
|
46
|
-
*/
|
|
47
|
-
export function resolveKnownHostsPath(libDir?: string): string {
|
|
48
|
-
const dir = libDir ?? import.meta.dir
|
|
49
|
-
|
|
50
|
-
// Layout 1: repo checkout — walk up 4 levels from src/lib to repo root
|
|
51
|
-
const repoRoot = resolve(dir, '..', '..', '..', '..')
|
|
52
|
-
const repoCandidate = join(repoRoot, '.github', 'known_hosts')
|
|
53
|
-
if (existsSync(repoCandidate)) return repoCandidate
|
|
54
|
-
|
|
55
|
-
// Layout 2: installed package — src/resources/known_hosts is 1 level up from src/lib
|
|
56
|
-
const packagedCandidate = resolve(dir, '..', 'resources', 'known_hosts')
|
|
57
|
-
if (existsSync(packagedCandidate)) return packagedCandidate
|
|
58
|
-
|
|
59
|
-
throw new Error(FAIL_CLOSED_ERROR)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Build the SSH `-o UserKnownHostsFile=<path>` argument pair for the
|
|
64
|
-
* repo-pinned known_hosts file.
|
|
65
|
-
*
|
|
66
|
-
* Returns `['-o', 'UserKnownHostsFile=<path>']` when the file is found.
|
|
67
|
-
* Throws a clear error if no pinned known_hosts file is available — fail-closed,
|
|
68
|
-
* never silently omits `UserKnownHostsFile` for SSH commands that rely on
|
|
69
|
-
* pinned verification.
|
|
70
|
-
*
|
|
71
|
-
* @param libDir - Override for the directory containing this file (for tests).
|
|
72
|
-
*/
|
|
73
|
-
export function buildKnownHostsArgs(libDir?: string): string[] {
|
|
74
|
-
const path = resolveKnownHostsPath(libDir)
|
|
75
|
-
return ['-o', `UserKnownHostsFile=${path}`]
|
|
76
|
-
}
|
package/src/lib/redact.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Redact every occurrence of a host value from text, case-insensitively.
|
|
3
|
-
*
|
|
4
|
-
* Used to scrub a resolved SSH host out of error output before it surfaces to an
|
|
5
|
-
* MCP consumer. The host can be a misdirected secret (an agent may point the
|
|
6
|
-
* `--key` env-var selector at, e.g., `AWS_SECRET_ACCESS_KEY`), so its value must
|
|
7
|
-
* never appear in returned errors.
|
|
8
|
-
*
|
|
9
|
-
* Two subtleties this handles that a plain `replaceAll(host, ...)` does not:
|
|
10
|
-
* 1. OpenSSH lowercases the hostname in "Could not resolve hostname <name>"
|
|
11
|
-
* stderr, so the match must be case-insensitive.
|
|
12
|
-
* 2. Validated hosts contain `.` and `-`, which are regex-significant, so the
|
|
13
|
-
* host is escaped before building the matcher (no over-redaction).
|
|
14
|
-
*
|
|
15
|
-
* An empty/falsy host is a no-op (an empty matcher would match everywhere).
|
|
16
|
-
*/
|
|
17
|
-
export function redactHost(text: string, host: string): string {
|
|
18
|
-
if (!host) return text
|
|
19
|
-
const escaped = host.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
20
|
-
return text.replaceAll(new RegExp(escaped, 'gi'), '<host>')
|
|
21
|
-
}
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import {readFileSync, statSync} from 'node:fs'
|
|
2
|
-
import {describe, expect, it} from 'bun:test'
|
|
3
|
-
|
|
4
|
-
import {buildIdentityArgs, materializeIdentityFile} from './ssh-identity'
|
|
5
|
-
|
|
6
|
-
// ─── materializeIdentityFile ──────────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
describe('materializeIdentityFile', () => {
|
|
9
|
-
it('writes the key to a temp file and returns a path', () => {
|
|
10
|
-
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
11
|
-
const {path, cleanup} = materializeIdentityFile(key)
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
expect(path).toBeTruthy()
|
|
15
|
-
const stat = statSync(path)
|
|
16
|
-
expect(stat.isFile()).toBe(true)
|
|
17
|
-
} finally {
|
|
18
|
-
cleanup()
|
|
19
|
-
}
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('writes the file with mode 0600', () => {
|
|
23
|
-
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
24
|
-
const {path, cleanup} = materializeIdentityFile(key)
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
const stat = statSync(path)
|
|
28
|
-
const mode = stat.mode & 0o777
|
|
29
|
-
expect(mode).toBe(0o600)
|
|
30
|
-
} finally {
|
|
31
|
-
cleanup()
|
|
32
|
-
}
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('ensures the key file ends with a newline', () => {
|
|
36
|
-
const keyWithoutNewline = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----'
|
|
37
|
-
const {path, cleanup} = materializeIdentityFile(keyWithoutNewline)
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const text = readFileSync(path, 'utf8')
|
|
41
|
-
expect(text.endsWith('\n')).toBe(true)
|
|
42
|
-
} finally {
|
|
43
|
-
cleanup()
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('does not double-add a newline when key already ends with one', () => {
|
|
48
|
-
const keyWithNewline = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
49
|
-
const {path, cleanup} = materializeIdentityFile(keyWithNewline)
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const text = readFileSync(path, 'utf8')
|
|
53
|
-
expect(text.endsWith('\n\n')).toBe(false)
|
|
54
|
-
expect(text.endsWith('\n')).toBe(true)
|
|
55
|
-
} finally {
|
|
56
|
-
cleanup()
|
|
57
|
-
}
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('cleanup removes the temp file and directory', () => {
|
|
61
|
-
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
62
|
-
const {path, cleanup} = materializeIdentityFile(key)
|
|
63
|
-
const dir = path.slice(0, path.lastIndexOf('/'))
|
|
64
|
-
|
|
65
|
-
cleanup()
|
|
66
|
-
|
|
67
|
-
// Both the key file and its containing temp dir must be gone.
|
|
68
|
-
expect(() => statSync(path)).toThrow()
|
|
69
|
-
expect(() => statSync(dir)).toThrow()
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('cleanup is idempotent (calling twice does not throw)', () => {
|
|
73
|
-
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
74
|
-
const {cleanup} = materializeIdentityFile(key)
|
|
75
|
-
|
|
76
|
-
cleanup()
|
|
77
|
-
expect(() => cleanup()).not.toThrow()
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
// ─── buildIdentityArgs ────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
describe('buildIdentityArgs', () => {
|
|
84
|
-
it('returns [-i, <path>, -o, IdentitiesOnly=yes] when a key is provided', () => {
|
|
85
|
-
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
86
|
-
const {args, cleanup} = buildIdentityArgs(key)
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
expect(args).toHaveLength(4)
|
|
90
|
-
expect(args[0]).toBe('-i')
|
|
91
|
-
expect(args[1]).toBeTruthy() // path to temp file
|
|
92
|
-
expect(args[2]).toBe('-o')
|
|
93
|
-
expect(args[3]).toBe('IdentitiesOnly=yes')
|
|
94
|
-
} finally {
|
|
95
|
-
cleanup()
|
|
96
|
-
}
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('returns empty args and a no-op cleanup when key is empty string', () => {
|
|
100
|
-
const {args, cleanup} = buildIdentityArgs('')
|
|
101
|
-
|
|
102
|
-
expect(args).toHaveLength(0)
|
|
103
|
-
expect(() => cleanup()).not.toThrow()
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('returns empty args and a no-op cleanup when key is whitespace-only', () => {
|
|
107
|
-
const {args, cleanup} = buildIdentityArgs(' \n ')
|
|
108
|
-
|
|
109
|
-
expect(args).toHaveLength(0)
|
|
110
|
-
expect(() => cleanup()).not.toThrow()
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
it('returns empty args and a no-op cleanup when key is undefined', () => {
|
|
114
|
-
const {args, cleanup} = buildIdentityArgs(undefined)
|
|
115
|
-
|
|
116
|
-
expect(args).toHaveLength(0)
|
|
117
|
-
expect(() => cleanup()).not.toThrow()
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('the -i path points to a real 0600 file', () => {
|
|
121
|
-
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
122
|
-
const {args, cleanup} = buildIdentityArgs(key)
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
const keyPath = args[1]
|
|
126
|
-
expect(keyPath).toBeTruthy()
|
|
127
|
-
const stat = statSync(keyPath as string)
|
|
128
|
-
const mode = stat.mode & 0o777
|
|
129
|
-
expect(mode).toBe(0o600)
|
|
130
|
-
} finally {
|
|
131
|
-
cleanup()
|
|
132
|
-
}
|
|
133
|
-
})
|
|
134
|
-
})
|
package/src/lib/ssh-identity.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import {chmodSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
|
|
2
|
-
import {tmpdir} from 'node:os'
|
|
3
|
-
import {join} from 'node:path'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A materialized private-key file plus a best-effort cleanup callback.
|
|
7
|
-
* The cleanup removes the temp directory containing the key file.
|
|
8
|
-
*/
|
|
9
|
-
export interface MaterializedIdentity {
|
|
10
|
-
path: string
|
|
11
|
-
cleanup: () => void
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// NOTE: This intentionally duplicates the key-materialization logic in
|
|
15
|
-
// packages/shared/server/droplet-helpers.ts. The published CLI must not depend
|
|
16
|
-
// on @marcusrbrown/infra-shared (that dep breaks npm install for external users).
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Writes a private key to a 0600 temp file (with a guaranteed single trailing
|
|
20
|
-
* newline) and returns its path plus an idempotent best-effort cleanup callback.
|
|
21
|
-
*
|
|
22
|
-
* The trailing newline guards against env/secret injection stripping it
|
|
23
|
-
* (OpenSSH rejects keys without it). Empty/whitespace-key guards live in
|
|
24
|
-
* `buildIdentityArgs`; this function materializes whatever key bytes it receives.
|
|
25
|
-
*/
|
|
26
|
-
export function materializeIdentityFile(privateKey: string): MaterializedIdentity {
|
|
27
|
-
const dir = mkdtempSync(join(tmpdir(), 'infra-ssh-key-'))
|
|
28
|
-
const path = join(dir, 'id')
|
|
29
|
-
|
|
30
|
-
const cleanup = (): void => {
|
|
31
|
-
try {
|
|
32
|
-
rmSync(dir, {recursive: true, force: true})
|
|
33
|
-
} catch {
|
|
34
|
-
// Best-effort: a missing temp dir (already cleaned) is fine.
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const contents = privateKey.endsWith('\n') ? privateKey : `${privateKey}\n`
|
|
40
|
-
writeFileSync(path, contents, {mode: 0o600})
|
|
41
|
-
chmodSync(path, 0o600)
|
|
42
|
-
} catch (error) {
|
|
43
|
-
// Don't leave a partial 0600 key (or its temp dir) behind on write failure.
|
|
44
|
-
cleanup()
|
|
45
|
-
throw error
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return {path, cleanup}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Result of `buildIdentityArgs` — the SSH argv fragment plus a cleanup callback.
|
|
53
|
-
* When the key is absent/empty, `args` is empty and `cleanup` is a no-op.
|
|
54
|
-
*/
|
|
55
|
-
export interface IdentityArgs {
|
|
56
|
-
/** SSH argv fragment: `['-i', '<path>', '-o', 'IdentitiesOnly=yes']` or `[]`. */
|
|
57
|
-
args: string[]
|
|
58
|
-
/** Removes the temp key file. Idempotent and safe to call multiple times. */
|
|
59
|
-
cleanup: () => void
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Builds the `-i <keyfile> -o IdentitiesOnly=yes` SSH argv fragment from an
|
|
64
|
-
* optional private key string.
|
|
65
|
-
*
|
|
66
|
-
* When `privateKey` is absent, empty, or whitespace-only, returns empty args
|
|
67
|
-
* and a no-op cleanup so callers fall back to ssh-agent behaviour unchanged.
|
|
68
|
-
*/
|
|
69
|
-
export function buildIdentityArgs(privateKey: string | undefined): IdentityArgs {
|
|
70
|
-
if (!privateKey?.trim()) {
|
|
71
|
-
return {args: [], cleanup: () => undefined}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const {path, cleanup} = materializeIdentityFile(privateKey)
|
|
75
|
-
return {args: ['-i', path, '-o', 'IdentitiesOnly=yes'], cleanup}
|
|
76
|
-
}
|
|
File without changes
|