@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.
Files changed (108) hide show
  1. package/dist/cli.js +4977 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -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
- })
@@ -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
- })
@@ -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
- }