@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.
Files changed (61) hide show
  1. package/dist/cli/openshell.js +4 -0
  2. package/dist/core/audit/log-store.js +1 -1
  3. package/dist/core/orchestrator.d.ts +2 -2
  4. package/dist/core/orchestrator.js +3 -3
  5. package/dist/core/result.d.ts +1 -1
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +3 -3
  8. package/dist/opencode/plugin.d.ts +1 -1
  9. package/dist/opencode/plugin.js +8 -8
  10. package/package.json +6 -1
  11. package/.claude/settings.local.json +0 -15
  12. package/bun.lock +0 -368
  13. package/docs/superpowers/notes/2026-03-25-opencode-remote-tools-handoff.md +0 -81
  14. package/docs/superpowers/notes/2026-03-26-openshell-pre-release-review.md +0 -174
  15. package/docs/superpowers/plans/2026-03-25-opencode-remote-tools.md +0 -1656
  16. package/docs/superpowers/plans/2026-03-25-server-registry-cli.md +0 -54
  17. package/docs/superpowers/plans/2026-03-26-config-backed-credential-registry.md +0 -494
  18. package/docs/superpowers/plans/2026-03-26-openshell-release-prep.md +0 -639
  19. package/docs/superpowers/specs/2026-03-25-opencode-remote-tools-design.md +0 -378
  20. package/docs/superpowers/specs/2026-03-26-config-backed-credential-registry-design.md +0 -272
  21. package/docs/superpowers/specs/2026-03-26-openshell-release-prep-design.md +0 -197
  22. package/examples/opencode-local/opencode.json +0 -19
  23. package/scripts/openshell.ts +0 -3
  24. package/scripts/server-registry.ts +0 -3
  25. package/src/cli/openshell.ts +0 -60
  26. package/src/cli/server-registry.ts +0 -476
  27. package/src/core/audit/git-audit-repo.ts +0 -42
  28. package/src/core/audit/log-store.ts +0 -20
  29. package/src/core/audit/redact.ts +0 -4
  30. package/src/core/contracts.ts +0 -51
  31. package/src/core/orchestrator.ts +0 -1082
  32. package/src/core/patch.ts +0 -11
  33. package/src/core/paths.ts +0 -32
  34. package/src/core/policy.ts +0 -30
  35. package/src/core/registry/server-registry.ts +0 -505
  36. package/src/core/result.ts +0 -16
  37. package/src/core/ssh/ssh-runtime.ts +0 -355
  38. package/src/index.ts +0 -3
  39. package/src/opencode/plugin.ts +0 -242
  40. package/src/product/install.ts +0 -43
  41. package/src/product/opencode-config.ts +0 -118
  42. package/src/product/uninstall.ts +0 -47
  43. package/src/product/workspace-tracker.ts +0 -69
  44. package/tests/integration/fake-ssh-server.ts +0 -97
  45. package/tests/integration/install-lifecycle.test.ts +0 -85
  46. package/tests/integration/orchestrator.test.ts +0 -767
  47. package/tests/integration/ssh-runtime.test.ts +0 -122
  48. package/tests/unit/audit.test.ts +0 -221
  49. package/tests/unit/build-layout.test.ts +0 -28
  50. package/tests/unit/opencode-config.test.ts +0 -100
  51. package/tests/unit/opencode-plugin.test.ts +0 -358
  52. package/tests/unit/openshell-cli.test.ts +0 -60
  53. package/tests/unit/paths.test.ts +0 -64
  54. package/tests/unit/plugin-export.test.ts +0 -10
  55. package/tests/unit/policy.test.ts +0 -53
  56. package/tests/unit/release-docs.test.ts +0 -31
  57. package/tests/unit/result.test.ts +0 -28
  58. package/tests/unit/server-registry-cli.test.ts +0 -673
  59. package/tests/unit/server-registry.test.ts +0 -452
  60. package/tests/unit/workspace-tracker.test.ts +0 -57
  61. package/tsconfig.json +0 -14
@@ -1,673 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test"
2
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
3
- import { tmpdir } from "node:os"
4
- import { join } from "node:path"
5
- import type { ServerRecord } from "../../src/core/registry/server-registry"
6
-
7
- type Scope = "global" | "workspace"
8
-
9
- type PromptCall = {
10
- kind: "text" | "password" | "confirm"
11
- message: string
12
- defaultValue?: string | boolean
13
- }
14
-
15
- const tempDirs: string[] = []
16
-
17
- const cleanupTempDirs = async () => {
18
- await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
19
- }
20
-
21
- afterEach(async () => {
22
- await cleanupTempDirs()
23
- })
24
-
25
- const createWorkspaceRoot = async (withWorkspaceConfig: boolean) => {
26
- const tempDir = await mkdtemp(join(tmpdir(), "open-code-cli-"))
27
- tempDirs.push(tempDir)
28
-
29
- const workspaceRoot = join(tempDir, "repo")
30
- await mkdir(join(workspaceRoot, ".open-code"), { recursive: true })
31
- if (withWorkspaceConfig) {
32
- await writeFile(join(workspaceRoot, ".open-code", "servers.json"), "[]")
33
- }
34
-
35
- return workspaceRoot
36
- }
37
-
38
- const mergeRecords = (records: Record<Scope, ServerRecord[]>) => {
39
- const resolved = new Map<string, ServerRecord & { scope: Scope; shadowingGlobal?: boolean }>()
40
- const order: string[] = []
41
-
42
- for (const record of records.global) {
43
- if (!resolved.has(record.id)) {
44
- order.push(record.id)
45
- }
46
- resolved.set(record.id, { ...record, scope: "global" })
47
- }
48
-
49
- for (const record of records.workspace) {
50
- if (!resolved.has(record.id)) {
51
- order.push(record.id)
52
- }
53
- resolved.set(record.id, {
54
- ...record,
55
- scope: "workspace",
56
- ...(records.global.some((item) => item.id === record.id) ? { shadowingGlobal: true } : {}),
57
- })
58
- }
59
-
60
- return order.map((id) => resolved.get(id)!).filter(Boolean)
61
- }
62
-
63
- const createInMemoryRegistry = (initial: Partial<Record<Scope, ServerRecord[]>> = {}) => {
64
- const state: Record<Scope, Map<string, ServerRecord>> = {
65
- global: new Map((initial.global ?? []).map((record) => [record.id, record] as const)),
66
- workspace: new Map((initial.workspace ?? []).map((record) => [record.id, record] as const)),
67
- }
68
-
69
- const resolvedList = () =>
70
- mergeRecords({
71
- global: [...state.global.values()],
72
- workspace: [...state.workspace.values()],
73
- })
74
-
75
- return {
76
- async list() {
77
- return resolvedList()
78
- },
79
- async resolve(id: string) {
80
- return resolvedList().find((record) => record.id === id) ?? null
81
- },
82
- async upsert(scopeOrRecord: Scope | ServerRecord, maybeRecord?: ServerRecord) {
83
- if (maybeRecord === undefined) {
84
- state.global.set(scopeOrRecord.id, scopeOrRecord)
85
- return
86
- }
87
-
88
- state[scopeOrRecord].set(maybeRecord.id, maybeRecord)
89
- },
90
- async remove(scopeOrId: Scope | string, maybeId?: string) {
91
- if (maybeId === undefined) {
92
- return state.global.delete(scopeOrId)
93
- }
94
-
95
- return state[scopeOrId].delete(maybeId)
96
- },
97
- async listRaw(scope: Scope) {
98
- return [...state[scope].values()]
99
- },
100
- }
101
- }
102
-
103
- const createPrompt = (resolve: (call: PromptCall) => string | boolean) => {
104
- const calls: PromptCall[] = []
105
-
106
- return {
107
- calls,
108
- async text(message: string, defaultValue?: string) {
109
- const call: PromptCall = { kind: "text", message, defaultValue }
110
- calls.push(call)
111
- const answer = resolve(call)
112
- return typeof answer === "string" ? answer : String(answer)
113
- },
114
- async password(message: string) {
115
- const call: PromptCall = { kind: "password", message }
116
- calls.push(call)
117
- const answer = resolve(call)
118
- return typeof answer === "string" ? answer : String(answer)
119
- },
120
- async confirm(message: string, defaultValue = false) {
121
- const call: PromptCall = { kind: "confirm", message, defaultValue }
122
- calls.push(call)
123
- const answer = resolve(call)
124
- return typeof answer === "boolean" ? answer : Boolean(answer)
125
- },
126
- }
127
- }
128
-
129
- const createWritable = () => {
130
- let buffer = ""
131
-
132
- return {
133
- write(chunk: string) {
134
- buffer += chunk
135
- },
136
- toString() {
137
- return buffer
138
- },
139
- }
140
- }
141
-
142
- const runCli = async (argv: string[], deps: Record<string, unknown>) => {
143
- const { runServerRegistryCli } = await import("../../src/cli/server-registry")
144
- return runServerRegistryCli(argv, deps as never)
145
- }
146
-
147
- describe("server registry cli", () => {
148
- test("defaults add to workspace scope when a workspace config exists", async () => {
149
- const workspaceRoot = await createWorkspaceRoot(true)
150
- const registry = createInMemoryRegistry()
151
- const prompt = createPrompt((call) => {
152
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
153
- if (call.kind === "text" && call.message.includes("Scope")) return ""
154
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.10"
155
- if (call.kind === "text" && call.message.includes("Port")) return "22"
156
- if (call.kind === "text" && call.message.includes("Username")) return "root"
157
- if (call.kind === "text" && call.message.includes("Labels")) return ""
158
- if (call.kind === "text" && call.message.includes("Groups")) return ""
159
- if (call.kind === "text" && call.message.includes("Auth kind")) return "password"
160
- if (call.kind === "password") return "super-secret"
161
- return ""
162
- })
163
-
164
- await expect(
165
- runCli(["add"], {
166
- registry,
167
- prompt,
168
- stdout: createWritable(),
169
- stderr: createWritable(),
170
- workspaceRoot,
171
- }),
172
- ).resolves.toBe(0)
173
-
174
- expect(prompt.calls).toContainEqual(
175
- expect.objectContaining({
176
- kind: "text",
177
- message: expect.stringContaining("Server scope"),
178
- defaultValue: "workspace",
179
- }),
180
- )
181
- expect(await registry.listRaw("workspace")).toHaveLength(1)
182
- expect(await registry.listRaw("global")).toEqual([])
183
- })
184
-
185
- test("defaults add to global scope when no workspace config exists", async () => {
186
- const workspaceRoot = await createWorkspaceRoot(false)
187
- const registry = createInMemoryRegistry()
188
- const prompt = createPrompt((call) => {
189
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
190
- if (call.kind === "text" && call.message.includes("Scope")) return ""
191
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.10"
192
- if (call.kind === "text" && call.message.includes("Port")) return "22"
193
- if (call.kind === "text" && call.message.includes("Username")) return "root"
194
- if (call.kind === "text" && call.message.includes("Labels")) return ""
195
- if (call.kind === "text" && call.message.includes("Groups")) return ""
196
- if (call.kind === "text" && call.message.includes("Auth kind")) return "password"
197
- if (call.kind === "password") return "super-secret"
198
- return ""
199
- })
200
-
201
- await expect(
202
- runCli(["add"], {
203
- registry,
204
- prompt,
205
- stdout: createWritable(),
206
- stderr: createWritable(),
207
- workspaceRoot,
208
- }),
209
- ).resolves.toBe(0)
210
-
211
- expect(prompt.calls).toContainEqual(
212
- expect.objectContaining({
213
- kind: "text",
214
- message: expect.stringContaining("Server scope"),
215
- defaultValue: "global",
216
- }),
217
- )
218
- })
219
-
220
- test("re-prompts for scope until a valid value is entered", async () => {
221
- const workspaceRoot = await createWorkspaceRoot(true)
222
- const registry = createInMemoryRegistry()
223
- const prompt = createPrompt((call) => {
224
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
225
- if (call.kind === "text" && call.message.includes("Server scope")) {
226
- const scopePrompts = prompt.calls.filter(
227
- (entry) => entry.kind === "text" && entry.message.includes("Server scope"),
228
- )
229
- return scopePrompts.length === 1 ? "wrong-scope" : "workspace"
230
- }
231
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.10"
232
- if (call.kind === "text" && call.message.includes("Port")) return "22"
233
- if (call.kind === "text" && call.message.includes("Username")) return "root"
234
- if (call.kind === "text" && call.message.includes("Labels")) return ""
235
- if (call.kind === "text" && call.message.includes("Groups")) return ""
236
- if (call.kind === "text" && call.message.includes("Auth kind")) return "password"
237
- if (call.kind === "password") return "super-secret"
238
- return ""
239
- })
240
- const stdout = createWritable()
241
- const stderr = createWritable()
242
-
243
- await expect(
244
- runCli(["add"], {
245
- registry,
246
- prompt,
247
- stdout,
248
- stderr,
249
- workspaceRoot,
250
- }),
251
- ).resolves.toBe(0)
252
-
253
- expect(prompt.calls.filter((entry) => entry.kind === "text" && entry.message.includes("Server scope"))).toHaveLength(2)
254
- expect(stderr.toString()).toContain("Invalid scope")
255
- expect(await registry.listRaw("workspace")).toHaveLength(1)
256
- })
257
-
258
- test("prompts for password auth kind and warns before storing a plain-text password", async () => {
259
- const workspaceRoot = await createWorkspaceRoot(true)
260
- const registry = createInMemoryRegistry()
261
- const stdout = createWritable()
262
- const stderr = createWritable()
263
- const prompt = createPrompt((call) => {
264
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
265
- if (call.kind === "text" && call.message.includes("Scope")) return ""
266
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.10"
267
- if (call.kind === "text" && call.message.includes("Port")) return "22"
268
- if (call.kind === "text" && call.message.includes("Username")) return "root"
269
- if (call.kind === "text" && call.message.includes("Labels")) return ""
270
- if (call.kind === "text" && call.message.includes("Groups")) return ""
271
- if (call.kind === "text" && call.message.includes("Auth kind")) return "password"
272
- if (call.kind === "password") return "super-secret"
273
- return ""
274
- })
275
-
276
- await expect(
277
- runCli(["add"], {
278
- registry,
279
- prompt,
280
- stdout,
281
- stderr,
282
- workspaceRoot,
283
- }),
284
- ).resolves.toBe(0)
285
-
286
- expect(prompt.calls).toContainEqual(
287
- expect.objectContaining({
288
- kind: "text",
289
- message: expect.stringContaining("Auth kind"),
290
- }),
291
- )
292
- expect(stdout.toString()).toContain("plain-text password")
293
- expect(await registry.listRaw("workspace")).toEqual([
294
- {
295
- id: "prod-a",
296
- host: "10.0.0.10",
297
- port: 22,
298
- username: "root",
299
- auth: { kind: "password", secret: "super-secret" },
300
- },
301
- ])
302
- expect(stderr.toString()).toBe("")
303
- })
304
-
305
- test("re-prompts for auth kind until a valid value is entered", async () => {
306
- const workspaceRoot = await createWorkspaceRoot(true)
307
- const registry = createInMemoryRegistry()
308
- const prompt = createPrompt((call) => {
309
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
310
- if (call.kind === "text" && call.message.includes("Server scope")) return "workspace"
311
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.10"
312
- if (call.kind === "text" && call.message.includes("Port")) return "22"
313
- if (call.kind === "text" && call.message.includes("Username")) return "root"
314
- if (call.kind === "text" && call.message.includes("Labels")) return ""
315
- if (call.kind === "text" && call.message.includes("Groups")) return ""
316
- if (call.kind === "text" && call.message.includes("Auth kind")) {
317
- const authPrompts = prompt.calls.filter(
318
- (entry) => entry.kind === "text" && entry.message.includes("Auth kind"),
319
- )
320
- return authPrompts.length === 1 ? "wrong-auth" : "privateKey"
321
- }
322
- if (call.kind === "text" && call.message.includes("Private key path")) return "./keys/id_rsa"
323
- if (call.kind === "text" && call.message.includes("Passphrase")) return ""
324
- return ""
325
- })
326
- const stderr = createWritable()
327
-
328
- await expect(
329
- runCli(["add"], {
330
- registry,
331
- prompt,
332
- stdout: createWritable(),
333
- stderr,
334
- workspaceRoot,
335
- }),
336
- ).resolves.toBe(0)
337
-
338
- expect(prompt.calls.filter((entry) => entry.kind === "text" && entry.message.includes("Auth kind"))).toHaveLength(2)
339
- expect(stderr.toString()).toContain("Invalid auth kind")
340
- expect(await registry.listRaw("workspace")).toEqual([
341
- {
342
- id: "prod-a",
343
- host: "10.0.0.10",
344
- port: 22,
345
- username: "root",
346
- auth: { kind: "privateKey", privateKeyPath: "./keys/id_rsa" },
347
- },
348
- ])
349
- })
350
-
351
- test("prompts for privateKey auth kind and stores the key path", async () => {
352
- const workspaceRoot = await createWorkspaceRoot(true)
353
- const registry = createInMemoryRegistry()
354
- const prompt = createPrompt((call) => {
355
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
356
- if (call.kind === "text" && call.message.includes("Scope")) return ""
357
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.10"
358
- if (call.kind === "text" && call.message.includes("Port")) return "22"
359
- if (call.kind === "text" && call.message.includes("Username")) return "root"
360
- if (call.kind === "text" && call.message.includes("Labels")) return ""
361
- if (call.kind === "text" && call.message.includes("Groups")) return ""
362
- if (call.kind === "text" && call.message.includes("Auth kind")) return "privateKey"
363
- if (call.kind === "text" && call.message.includes("Private key path")) return "./keys/id_rsa"
364
- if (call.kind === "text" && call.message.includes("Passphrase")) return ""
365
- return ""
366
- })
367
-
368
- await expect(
369
- runCli(["add"], {
370
- registry,
371
- prompt,
372
- stdout: createWritable(),
373
- stderr: createWritable(),
374
- workspaceRoot,
375
- }),
376
- ).resolves.toBe(0)
377
-
378
- expect(prompt.calls).toContainEqual(
379
- expect.objectContaining({
380
- kind: "text",
381
- message: expect.stringContaining("Auth kind"),
382
- }),
383
- )
384
- expect(await registry.listRaw("workspace")).toEqual([
385
- {
386
- id: "prod-a",
387
- host: "10.0.0.10",
388
- port: 22,
389
- username: "root",
390
- auth: {
391
- kind: "privateKey",
392
- privateKeyPath: "./keys/id_rsa",
393
- },
394
- },
395
- ])
396
- })
397
-
398
- test("prompts for certificate auth kind and stores both key paths", async () => {
399
- const workspaceRoot = await createWorkspaceRoot(true)
400
- const registry = createInMemoryRegistry()
401
- const prompt = createPrompt((call) => {
402
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
403
- if (call.kind === "text" && call.message.includes("Scope")) return ""
404
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.10"
405
- if (call.kind === "text" && call.message.includes("Port")) return "22"
406
- if (call.kind === "text" && call.message.includes("Username")) return "root"
407
- if (call.kind === "text" && call.message.includes("Labels")) return ""
408
- if (call.kind === "text" && call.message.includes("Groups")) return ""
409
- if (call.kind === "text" && call.message.includes("Auth kind")) return "certificate"
410
- if (call.kind === "text" && call.message.includes("Certificate path")) return "./keys/client.pem"
411
- if (call.kind === "text" && call.message.includes("Private key path")) return "./keys/client-key.pem"
412
- if (call.kind === "text" && call.message.includes("Passphrase")) return "top-secret"
413
- return ""
414
- })
415
-
416
- await expect(
417
- runCli(["add"], {
418
- registry,
419
- prompt,
420
- stdout: createWritable(),
421
- stderr: createWritable(),
422
- workspaceRoot,
423
- }),
424
- ).resolves.toBe(0)
425
-
426
- expect(await registry.listRaw("workspace")).toEqual([
427
- {
428
- id: "prod-a",
429
- host: "10.0.0.10",
430
- port: 22,
431
- username: "root",
432
- auth: {
433
- kind: "certificate",
434
- certificatePath: "./keys/client.pem",
435
- privateKeyPath: "./keys/client-key.pem",
436
- passphrase: "top-secret",
437
- },
438
- },
439
- ])
440
- })
441
-
442
- test("warns when a workspace id overrides a global id", async () => {
443
- const workspaceRoot = await createWorkspaceRoot(true)
444
- const registry = createInMemoryRegistry({
445
- global: [
446
- {
447
- id: "prod-a",
448
- host: "10.0.0.10",
449
- port: 22,
450
- username: "root",
451
- auth: { kind: "password", secret: "global-secret" },
452
- },
453
- ],
454
- })
455
- const stdout = createWritable()
456
- const prompt = createPrompt((call) => {
457
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
458
- if (call.kind === "confirm") return true
459
- if (call.kind === "text" && call.message.includes("Scope")) return "workspace"
460
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.20"
461
- if (call.kind === "text" && call.message.includes("Port")) return "2222"
462
- if (call.kind === "text" && call.message.includes("Username")) return "deploy"
463
- if (call.kind === "text" && call.message.includes("Labels")) return ""
464
- if (call.kind === "text" && call.message.includes("Groups")) return ""
465
- if (call.kind === "text" && call.message.includes("Auth kind")) return "password"
466
- if (call.kind === "password") return "workspace-secret"
467
- return ""
468
- })
469
-
470
- await expect(
471
- runCli(["add"], {
472
- registry,
473
- prompt,
474
- stdout,
475
- stderr: createWritable(),
476
- workspaceRoot,
477
- }),
478
- ).resolves.toBe(0)
479
-
480
- expect(stdout.toString()).toContain("will override global entry")
481
- expect(await registry.listRaw("global")).toEqual([
482
- {
483
- id: "prod-a",
484
- host: "10.0.0.10",
485
- port: 22,
486
- username: "root",
487
- auth: { kind: "password", secret: "global-secret" },
488
- },
489
- ])
490
- expect(await registry.listRaw("workspace")).toEqual([
491
- {
492
- id: "prod-a",
493
- host: "10.0.0.20",
494
- port: 2222,
495
- username: "deploy",
496
- auth: { kind: "password", secret: "workspace-secret" },
497
- },
498
- ])
499
- })
500
-
501
- test("adding a workspace-scoped server records the managed workspace path", async () => {
502
- const workspaceRoot = await createWorkspaceRoot(true)
503
- const registry = createInMemoryRegistry()
504
- const trackerCalls: Array<{ workspaceRoot: string; managedPath: string }> = []
505
- const prompt = createPrompt((call) => {
506
- if (call.kind === "text" && call.message.includes("Server id")) return "prod-a"
507
- if (call.kind === "text" && call.message.includes("Scope")) return "workspace"
508
- if (call.kind === "text" && call.message.includes("Host")) return "10.0.0.10"
509
- if (call.kind === "text" && call.message.includes("Port")) return "22"
510
- if (call.kind === "text" && call.message.includes("Username")) return "root"
511
- if (call.kind === "text" && call.message.includes("Labels")) return ""
512
- if (call.kind === "text" && call.message.includes("Groups")) return ""
513
- if (call.kind === "text" && call.message.includes("Auth kind")) return "password"
514
- if (call.kind === "password") return "super-secret"
515
- return ""
516
- })
517
-
518
- await expect(
519
- runCli(["add"], {
520
- registry,
521
- prompt,
522
- stdout: createWritable(),
523
- stderr: createWritable(),
524
- workspaceRoot,
525
- workspaceTracker: {
526
- record: async (entry: { workspaceRoot: string; managedPath: string }) => {
527
- trackerCalls.push(entry)
528
- },
529
- },
530
- }),
531
- ).resolves.toBe(0)
532
-
533
- expect(trackerCalls).toEqual([
534
- {
535
- workspaceRoot,
536
- managedPath: `${workspaceRoot}/.open-code`,
537
- },
538
- ])
539
- })
540
-
541
- test("prompts which scope to remove from when the same id exists in both configs", async () => {
542
- const workspaceRoot = await createWorkspaceRoot(true)
543
- const registry = createInMemoryRegistry({
544
- global: [
545
- {
546
- id: "prod-a",
547
- host: "10.0.0.10",
548
- port: 22,
549
- username: "root",
550
- auth: { kind: "password", secret: "global-secret" },
551
- },
552
- ],
553
- workspace: [
554
- {
555
- id: "prod-a",
556
- host: "10.0.0.20",
557
- port: 2222,
558
- username: "deploy",
559
- auth: { kind: "password", secret: "workspace-secret" },
560
- },
561
- ],
562
- })
563
- const prompt = createPrompt((call) => {
564
- if (call.kind === "confirm") return true
565
- if (call.kind === "text" && call.message.includes("Remove from which scope")) return "global"
566
- return ""
567
- })
568
-
569
- await expect(
570
- runCli(["remove", "prod-a"], {
571
- registry,
572
- prompt,
573
- stdout: createWritable(),
574
- stderr: createWritable(),
575
- workspaceRoot,
576
- }),
577
- ).resolves.toBe(0)
578
-
579
- expect(prompt.calls).toContainEqual(
580
- expect.objectContaining({
581
- kind: "text",
582
- message: expect.stringContaining("Remove from which scope"),
583
- }),
584
- )
585
- expect(await registry.listRaw("global")).toEqual([])
586
- expect(await registry.listRaw("workspace")).toEqual([
587
- {
588
- id: "prod-a",
589
- host: "10.0.0.20",
590
- port: 2222,
591
- username: "deploy",
592
- auth: { kind: "password", secret: "workspace-secret" },
593
- },
594
- ])
595
- })
596
-
597
- test("lists the source scope and shadowing status", async () => {
598
- const workspaceRoot = await createWorkspaceRoot(true)
599
- const registry = createInMemoryRegistry({
600
- global: [
601
- {
602
- id: "prod-b",
603
- host: "10.0.0.11",
604
- port: 22,
605
- username: "ops",
606
- auth: { kind: "privateKey", privateKeyPath: "/keys/prod-b" },
607
- },
608
- {
609
- id: "prod-a",
610
- host: "10.0.0.10",
611
- port: 22,
612
- username: "root",
613
- auth: { kind: "password", secret: "global-secret" },
614
- },
615
- ],
616
- workspace: [
617
- {
618
- id: "prod-a",
619
- host: "10.0.0.99",
620
- port: 2222,
621
- username: "deploy",
622
- auth: {
623
- kind: "certificate",
624
- certificatePath: "/certs/prod-a.crt",
625
- privateKeyPath: "/keys/prod-a",
626
- },
627
- },
628
- ],
629
- })
630
- const stdout = createWritable()
631
-
632
- await expect(
633
- runCli(["list"], {
634
- registry,
635
- prompt: createPrompt(() => ""),
636
- stdout,
637
- stderr: createWritable(),
638
- workspaceRoot,
639
- }),
640
- ).resolves.toBe(0)
641
-
642
- expect(stdout.toString().split("\n").filter((line) => line.length > 0)).toEqual([
643
- "ID\tSCOPE\tSTATUS\tHOST\tPORT\tUSERNAME\tLABELS\tGROUPS",
644
- "prod-b\tglobal\t\t10.0.0.11\t22\tops\t\t",
645
- "prod-a\tworkspace\tshadowing global\t10.0.0.99\t2222\tdeploy\t\t",
646
- ])
647
- expect(stdout.toString()).not.toContain("global-secret")
648
- expect(stdout.toString()).not.toContain("/keys/prod-b")
649
- expect(stdout.toString()).not.toContain("/certs/prod-a.crt")
650
- })
651
-
652
- test("remove preserves the empty-registry fast path", async () => {
653
- const workspaceRoot = await createWorkspaceRoot(true)
654
- const registry = createInMemoryRegistry()
655
- const stdout = createWritable()
656
- const stderr = createWritable()
657
- const prompt = createPrompt(() => "prod-a")
658
-
659
- await expect(
660
- runCli(["remove"], {
661
- registry,
662
- prompt,
663
- stdout,
664
- stderr,
665
- workspaceRoot,
666
- }),
667
- ).resolves.toBe(0)
668
-
669
- expect(stdout.toString()).toContain("No servers configured.")
670
- expect(stderr.toString()).toBe("")
671
- expect(prompt.calls).toHaveLength(0)
672
- })
673
- })