@openpalm/lib 0.10.1 → 0.11.0-beta.1
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/README.md +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +108 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/audit.ts +3 -2
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -21
- package/src/control-plane/config-persistence.ts +103 -64
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +263 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +182 -244
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +57 -56
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/paths.ts +75 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +102 -25
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -108
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +3 -6
- package/src/control-plane/secrets.ts +83 -47
- package/src/control-plane/setup-config.schema.json +2 -14
- package/src/control-plane/setup-status.ts +4 -29
- package/src/control-plane/setup-validation.ts +21 -21
- package/src/control-plane/setup.test.ts +122 -227
- package/src/control-plane/setup.ts +224 -125
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +39 -140
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +17 -15
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +77 -44
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for detectHostOpenCode() and importHostOpenCode().
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories — no network, no Docker, no akm CLI.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { detectHostOpenCode, importHostOpenCode } from "./host-opencode.js";
|
|
11
|
+
import type { ControlPlaneState } from "./types.js";
|
|
12
|
+
|
|
13
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function makeState(homeDir: string): ControlPlaneState {
|
|
16
|
+
return {
|
|
17
|
+
adminToken: "test-admin",
|
|
18
|
+
assistantToken: "test-assistant",
|
|
19
|
+
homeDir,
|
|
20
|
+
configDir: join(homeDir, "config"),
|
|
21
|
+
stashDir: join(homeDir, "stash"),
|
|
22
|
+
workspaceDir: join(homeDir, "workspace"),
|
|
23
|
+
cacheDir: join(homeDir, "cache"),
|
|
24
|
+
stateDir: join(homeDir, "state"),
|
|
25
|
+
stackDir: join(homeDir, "config/stack"),
|
|
26
|
+
services: {},
|
|
27
|
+
artifacts: { compose: "" },
|
|
28
|
+
artifactMeta: [],
|
|
29
|
+
audit: [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Snapshot of env vars so tests can override XDG paths and restore after. */
|
|
34
|
+
function withXdgEnv(configHome: string, dataHome: string, fn: () => void) {
|
|
35
|
+
const prevConfig = process.env.XDG_CONFIG_HOME;
|
|
36
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
37
|
+
process.env.XDG_CONFIG_HOME = configHome;
|
|
38
|
+
process.env.XDG_DATA_HOME = dataHome;
|
|
39
|
+
try {
|
|
40
|
+
fn();
|
|
41
|
+
} finally {
|
|
42
|
+
if (prevConfig === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
43
|
+
else process.env.XDG_CONFIG_HOME = prevConfig;
|
|
44
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
45
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── detectHostOpenCode ────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe("detectHostOpenCode", () => {
|
|
52
|
+
let xdgRoot: string;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
xdgRoot = mkdtempSync(join(tmpdir(), "op-host-detect-"));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
rmSync(xdgRoot, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns zero counts when no host config exists", () => {
|
|
63
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
64
|
+
const status = detectHostOpenCode();
|
|
65
|
+
expect(status.providerCount).toBe(0);
|
|
66
|
+
expect(status.credentialCount).toBe(0);
|
|
67
|
+
expect(status.configPath).toBeUndefined();
|
|
68
|
+
expect(status.authPath).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("counts providers from opencode.json", () => {
|
|
73
|
+
const configDir = join(xdgRoot, "config", "opencode");
|
|
74
|
+
mkdirSync(configDir, { recursive: true });
|
|
75
|
+
writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
|
|
76
|
+
provider: { anthropic: {}, openai: {}, groq: {} },
|
|
77
|
+
}));
|
|
78
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
79
|
+
const status = detectHostOpenCode();
|
|
80
|
+
expect(status.providerCount).toBe(3);
|
|
81
|
+
expect(status.credentialCount).toBe(0);
|
|
82
|
+
expect(status.configPath).toContain("opencode.json");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("counts credentials from auth.json", () => {
|
|
87
|
+
const dataDir = join(xdgRoot, "data", "opencode");
|
|
88
|
+
mkdirSync(dataDir, { recursive: true });
|
|
89
|
+
writeFileSync(join(dataDir, "auth.json"), JSON.stringify({
|
|
90
|
+
anthropic: { token: "sk-ant" },
|
|
91
|
+
groq: { token: "gsk_" },
|
|
92
|
+
}));
|
|
93
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
94
|
+
const status = detectHostOpenCode();
|
|
95
|
+
expect(status.credentialCount).toBe(2);
|
|
96
|
+
expect(status.providerCount).toBe(0);
|
|
97
|
+
expect(status.authPath).toContain("auth.json");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("handles malformed opencode.json gracefully", () => {
|
|
102
|
+
const configDir = join(xdgRoot, "config", "opencode");
|
|
103
|
+
mkdirSync(configDir, { recursive: true });
|
|
104
|
+
writeFileSync(join(configDir, "opencode.json"), "{ invalid json {{");
|
|
105
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
106
|
+
const status = detectHostOpenCode();
|
|
107
|
+
expect(status.providerCount).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── importHostOpenCode ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe("importHostOpenCode", () => {
|
|
115
|
+
let xdgRoot: string;
|
|
116
|
+
let opHome: string;
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
xdgRoot = mkdtempSync(join(tmpdir(), "op-host-import-xdg-"));
|
|
120
|
+
opHome = mkdtempSync(join(tmpdir(), "op-host-import-home-"));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
rmSync(xdgRoot, { recursive: true, force: true });
|
|
125
|
+
rmSync(opHome, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("imports providers and credentials from a fresh state", () => {
|
|
129
|
+
// Set up host opencode files
|
|
130
|
+
const hostConfigDir = join(xdgRoot, "config", "opencode");
|
|
131
|
+
const hostDataDir = join(xdgRoot, "data", "opencode");
|
|
132
|
+
mkdirSync(hostConfigDir, { recursive: true });
|
|
133
|
+
mkdirSync(hostDataDir, { recursive: true });
|
|
134
|
+
|
|
135
|
+
writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
|
|
136
|
+
provider: { anthropic: { name: "Anthropic" }, groq: {} },
|
|
137
|
+
model: "anthropic/claude-3-5-sonnet",
|
|
138
|
+
// These should be stripped:
|
|
139
|
+
plugin: [{ module: "some-plugin" }],
|
|
140
|
+
mcp: { server: {} },
|
|
141
|
+
}));
|
|
142
|
+
writeFileSync(join(hostDataDir, "auth.json"), JSON.stringify({
|
|
143
|
+
anthropic: { token: "sk-ant-token" },
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
const state = makeState(opHome);
|
|
147
|
+
|
|
148
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
149
|
+
const result = importHostOpenCode(state);
|
|
150
|
+
expect(result.imported.providers).toBe(2);
|
|
151
|
+
expect(result.imported.credentials).toBe(1);
|
|
152
|
+
expect(result.conflicts).toHaveLength(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Verify opencode.json was written and plugin key was stripped
|
|
156
|
+
const destConfig = JSON.parse(readFileSync(join(opHome, "config", "assistant", "opencode.json"), "utf-8"));
|
|
157
|
+
expect(destConfig.provider).toEqual({ anthropic: { name: "Anthropic" }, groq: {} });
|
|
158
|
+
expect(destConfig.model).toBe("anthropic/claude-3-5-sonnet");
|
|
159
|
+
expect(destConfig.plugin).toBeUndefined();
|
|
160
|
+
expect(destConfig.mcp).toBeUndefined();
|
|
161
|
+
|
|
162
|
+
// Verify auth.json was written
|
|
163
|
+
expect(existsSync(join(opHome, "config", "auth.json"))).toBe(true);
|
|
164
|
+
|
|
165
|
+
// Verify auth.json permissions are 0o600
|
|
166
|
+
const authStat = statSync(join(opHome, "config", "auth.json"));
|
|
167
|
+
// On Linux, mode & 0o777 extracts permission bits
|
|
168
|
+
expect(authStat.mode & 0o777).toBe(0o600);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("preserves existing providers on conflict when overwriteConflicts is false", () => {
|
|
172
|
+
const hostConfigDir = join(xdgRoot, "config", "opencode");
|
|
173
|
+
mkdirSync(hostConfigDir, { recursive: true });
|
|
174
|
+
writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
|
|
175
|
+
provider: { anthropic: { name: "Host Anthropic" }, openai: { name: "Host OpenAI" } },
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
const state = makeState(opHome);
|
|
179
|
+
const destDir = join(opHome, "config", "assistant");
|
|
180
|
+
mkdirSync(destDir, { recursive: true });
|
|
181
|
+
// Pre-existing OP_HOME config with anthropic already configured
|
|
182
|
+
writeFileSync(join(destDir, "opencode.json"), JSON.stringify({
|
|
183
|
+
provider: { anthropic: { name: "Existing Anthropic" } },
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
187
|
+
const result = importHostOpenCode(state, { overwriteConflicts: false });
|
|
188
|
+
expect(result.conflicts).toEqual(["anthropic"]);
|
|
189
|
+
expect(result.imported.providers).toBe(1); // only openai imported
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const written = JSON.parse(readFileSync(join(destDir, "opencode.json"), "utf-8"));
|
|
193
|
+
// Existing anthropic is preserved
|
|
194
|
+
expect(written.provider.anthropic.name).toBe("Existing Anthropic");
|
|
195
|
+
// Host openai was merged in
|
|
196
|
+
expect(written.provider.openai.name).toBe("Host OpenAI");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("overwrites existing providers when overwriteConflicts is true", () => {
|
|
200
|
+
const hostConfigDir = join(xdgRoot, "config", "opencode");
|
|
201
|
+
mkdirSync(hostConfigDir, { recursive: true });
|
|
202
|
+
writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
|
|
203
|
+
provider: { anthropic: { name: "Host Anthropic" } },
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
const state = makeState(opHome);
|
|
207
|
+
const destDir = join(opHome, "config", "assistant");
|
|
208
|
+
mkdirSync(destDir, { recursive: true });
|
|
209
|
+
writeFileSync(join(destDir, "opencode.json"), JSON.stringify({
|
|
210
|
+
provider: { anthropic: { name: "Old Anthropic" } },
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
214
|
+
const result = importHostOpenCode(state, { overwriteConflicts: true });
|
|
215
|
+
expect(result.conflicts).toHaveLength(0);
|
|
216
|
+
expect(result.imported.providers).toBe(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const written = JSON.parse(readFileSync(join(opHome, "config", "assistant", "opencode.json"), "utf-8"));
|
|
220
|
+
expect(written.provider.anthropic.name).toBe("Host Anthropic");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns zero counts when no host config is present", () => {
|
|
224
|
+
const state = makeState(opHome);
|
|
225
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
226
|
+
const result = importHostOpenCode(state);
|
|
227
|
+
expect(result.imported.providers).toBe(0);
|
|
228
|
+
expect(result.imported.credentials).toBe(0);
|
|
229
|
+
expect(result.conflicts).toHaveLength(0);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("partial-merge auth: does not overwrite existing credential, adds new one", () => {
|
|
234
|
+
// Pre-seed OP_HOME/config/auth.json with one existing credential
|
|
235
|
+
const opConfigDir = join(opHome, "config");
|
|
236
|
+
mkdirSync(opConfigDir, { recursive: true });
|
|
237
|
+
writeFileSync(join(opConfigDir, "auth.json"), JSON.stringify({
|
|
238
|
+
azure: { type: "api", key: "existing" },
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
// Set up host auth.json with azure (conflict) + groq (new)
|
|
242
|
+
const hostDataDir = join(xdgRoot, "data", "opencode");
|
|
243
|
+
mkdirSync(hostDataDir, { recursive: true });
|
|
244
|
+
writeFileSync(join(hostDataDir, "auth.json"), JSON.stringify({
|
|
245
|
+
azure: { type: "api", key: "host-override" },
|
|
246
|
+
groq: { type: "api", key: "gsk-host" },
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
const state = makeState(opHome);
|
|
250
|
+
|
|
251
|
+
withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
|
|
252
|
+
const result = importHostOpenCode(state, { overwriteConflicts: false });
|
|
253
|
+
// Only groq was new — azure is a conflict and must NOT be overwritten
|
|
254
|
+
expect(result.imported.credentials).toBe(1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Verify azure key was NOT overwritten
|
|
258
|
+
const written = JSON.parse(readFileSync(join(opConfigDir, "auth.json"), "utf-8")) as Record<string, { key: string }>;
|
|
259
|
+
expect(written.azure.key).toBe("existing");
|
|
260
|
+
// Verify groq was added
|
|
261
|
+
expect(written.groq.key).toBe("gsk-host");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host OpenCode detection and import.
|
|
3
|
+
*
|
|
4
|
+
* Reads the host user's existing OpenCode installation (XDG standard paths)
|
|
5
|
+
* and provides a one-shot import into OP_HOME.
|
|
6
|
+
*
|
|
7
|
+
* Linux only — macOS/Windows paths are documented but not implemented here;
|
|
8
|
+
* extend behind the same API contract in a follow-up.
|
|
9
|
+
*
|
|
10
|
+
* Security:
|
|
11
|
+
* - auth.json is copied byte-for-byte and chmodded 0o600. Its contents
|
|
12
|
+
* are never parsed, logged, or returned to callers.
|
|
13
|
+
* - opencode.json is parsed to strip plugin/mcp/permission keys before
|
|
14
|
+
* writing; only provider/model/small_model/disabled_providers are kept.
|
|
15
|
+
* - Conflict detection compares provider IDs; existing credentials are
|
|
16
|
+
* preserved unless overwriteConflicts=true.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import type { ControlPlaneState } from "./types.js";
|
|
21
|
+
import { authJsonPath, assistantConfigDir } from "./paths.js";
|
|
22
|
+
|
|
23
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export type HostOpenCodeStatus = {
|
|
26
|
+
/** Absolute path to opencode.json if found, undefined otherwise */
|
|
27
|
+
configPath?: string;
|
|
28
|
+
/** Absolute path to auth.json if found, undefined otherwise */
|
|
29
|
+
authPath?: string;
|
|
30
|
+
/** Number of provider entries in opencode.json (0 when not found) */
|
|
31
|
+
providerCount: number;
|
|
32
|
+
/** Number of credential entries in auth.json (0 when not found) */
|
|
33
|
+
credentialCount: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type HostImportResult = {
|
|
37
|
+
imported: {
|
|
38
|
+
providers: number;
|
|
39
|
+
credentials: number;
|
|
40
|
+
};
|
|
41
|
+
/** Provider IDs that already existed in OP_HOME and were NOT overwritten */
|
|
42
|
+
conflicts: string[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ── XDG path resolution ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function xdgConfigHome(): string {
|
|
48
|
+
return process.env.XDG_CONFIG_HOME ?? `${homedir()}/.config`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function xdgDataHome(): string {
|
|
52
|
+
return process.env.XDG_DATA_HOME ?? `${homedir()}/.local/share`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** ~/.config/opencode/opencode.json */
|
|
56
|
+
function hostConfigJsonPath(): string {
|
|
57
|
+
return `${xdgConfigHome()}/opencode/opencode.json`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** ~/.local/share/opencode/auth.json */
|
|
61
|
+
function hostAuthJsonPath(): string {
|
|
62
|
+
return `${xdgDataHome()}/opencode/auth.json`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── opencode.json parsing ────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/** Keys that are safe to import from host opencode.json into OP_HOME config */
|
|
68
|
+
const ALLOWED_CONFIG_KEYS = new Set(["$schema", "provider", "model", "small_model", "disabled_providers"]);
|
|
69
|
+
|
|
70
|
+
type OpenCodeJson = Record<string, unknown>;
|
|
71
|
+
|
|
72
|
+
function readJsonFileSafe(path: string): OpenCodeJson | null {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(path, "utf-8")) as OpenCodeJson;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stripDisallowedKeys(obj: OpenCodeJson): OpenCodeJson {
|
|
81
|
+
return Object.fromEntries(
|
|
82
|
+
Object.entries(obj).filter(([k]) => ALLOWED_CONFIG_KEYS.has(k))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function countProviders(obj: OpenCodeJson): number {
|
|
87
|
+
const provider = obj.provider;
|
|
88
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) return 0;
|
|
89
|
+
return Object.keys(provider as Record<string, unknown>).length;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── auth.json credential counting ───────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function countCredentials(path: string): number {
|
|
95
|
+
const raw = readJsonFileSafe(path);
|
|
96
|
+
if (!raw) return 0;
|
|
97
|
+
// auth.json shape: { "providerID": { ... }, ... } — count top-level keys
|
|
98
|
+
return Object.keys(raw).length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Detect whether a host OpenCode installation is present.
|
|
105
|
+
* Never returns credential values — only counts.
|
|
106
|
+
*/
|
|
107
|
+
export function detectHostOpenCode(): HostOpenCodeStatus {
|
|
108
|
+
const configPath = hostConfigJsonPath();
|
|
109
|
+
const authPath = hostAuthJsonPath();
|
|
110
|
+
|
|
111
|
+
const configExists = existsSync(configPath);
|
|
112
|
+
const authExists = existsSync(authPath);
|
|
113
|
+
|
|
114
|
+
if (!configExists && !authExists) {
|
|
115
|
+
return { providerCount: 0, credentialCount: 0 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let providerCount = 0;
|
|
119
|
+
if (configExists) {
|
|
120
|
+
const parsed = readJsonFileSafe(configPath);
|
|
121
|
+
providerCount = parsed ? countProviders(parsed) : 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let credentialCount = 0;
|
|
125
|
+
if (authExists) {
|
|
126
|
+
credentialCount = countCredentials(authPath);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
configPath: configExists ? configPath : undefined,
|
|
131
|
+
authPath: authExists ? authPath : undefined,
|
|
132
|
+
providerCount,
|
|
133
|
+
credentialCount,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Import host OpenCode config + auth into OP_HOME.
|
|
139
|
+
*
|
|
140
|
+
* - Strips plugin/mcp/permission keys from opencode.json before writing.
|
|
141
|
+
* - Copies auth.json byte-for-byte and chmods it to 0o600.
|
|
142
|
+
* - On conflict: existing OP_HOME provider entries are preserved unless
|
|
143
|
+
* overwriteConflicts is true.
|
|
144
|
+
*
|
|
145
|
+
* @param state ControlPlaneState (for OP_HOME path resolution)
|
|
146
|
+
* @param overwriteConflicts When true, host providers replace existing ones
|
|
147
|
+
*/
|
|
148
|
+
export function importHostOpenCode(
|
|
149
|
+
state: ControlPlaneState,
|
|
150
|
+
options: { overwriteConflicts?: boolean } = {}
|
|
151
|
+
): HostImportResult {
|
|
152
|
+
const { overwriteConflicts = false } = options;
|
|
153
|
+
const status = detectHostOpenCode();
|
|
154
|
+
|
|
155
|
+
let importedProviders = 0;
|
|
156
|
+
let importedCredentials = 0;
|
|
157
|
+
const conflicts: string[] = [];
|
|
158
|
+
|
|
159
|
+
// ── opencode.json ──────────────────────────────────────────────────────
|
|
160
|
+
if (status.configPath) {
|
|
161
|
+
const hostConfig = readJsonFileSafe(status.configPath);
|
|
162
|
+
if (hostConfig) {
|
|
163
|
+
const sanitized = stripDisallowedKeys(hostConfig);
|
|
164
|
+
const destDir = assistantConfigDir(state);
|
|
165
|
+
const destPath = `${destDir}/opencode.json`;
|
|
166
|
+
|
|
167
|
+
mkdirSync(destDir, { recursive: true });
|
|
168
|
+
|
|
169
|
+
// Merge with existing OP_HOME config if it exists
|
|
170
|
+
const existing = existsSync(destPath) ? (readJsonFileSafe(destPath) ?? {}) : {};
|
|
171
|
+
const existingProviders = (existing.provider ?? {}) as Record<string, unknown>;
|
|
172
|
+
const hostProviders = (sanitized.provider ?? {}) as Record<string, unknown>;
|
|
173
|
+
|
|
174
|
+
const mergedProviders: Record<string, unknown> = { ...existingProviders };
|
|
175
|
+
for (const [id, entry] of Object.entries(hostProviders)) {
|
|
176
|
+
if (Object.prototype.hasOwnProperty.call(existingProviders, id) && !overwriteConflicts) {
|
|
177
|
+
conflicts.push(id);
|
|
178
|
+
} else {
|
|
179
|
+
mergedProviders[id] = entry;
|
|
180
|
+
importedProviders++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const merged: OpenCodeJson = {
|
|
185
|
+
...existing,
|
|
186
|
+
...sanitized,
|
|
187
|
+
...(Object.keys(mergedProviders).length > 0 ? { provider: mergedProviders } : {}),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
writeFileSync(destPath, JSON.stringify(merged, null, 2) + "\n");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── auth.json ──────────────────────────────────────────────────────────
|
|
195
|
+
if (status.authPath) {
|
|
196
|
+
const destPath = authJsonPath(state);
|
|
197
|
+
const destDir = state.configDir;
|
|
198
|
+
mkdirSync(destDir, { recursive: true });
|
|
199
|
+
|
|
200
|
+
if (existsSync(destPath) && !overwriteConflicts) {
|
|
201
|
+
// Merge: copy only keys that do not already exist in OP_HOME auth.json
|
|
202
|
+
const hostAuth = readJsonFileSafe(status.authPath) ?? {};
|
|
203
|
+
const existingAuth = readJsonFileSafe(destPath) ?? {};
|
|
204
|
+
const merged: Record<string, unknown> = { ...existingAuth };
|
|
205
|
+
for (const [id, value] of Object.entries(hostAuth)) {
|
|
206
|
+
if (!Object.prototype.hasOwnProperty.call(existingAuth, id)) {
|
|
207
|
+
merged[id] = value;
|
|
208
|
+
importedCredentials++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
writeFileSync(destPath, JSON.stringify(merged, null, 2) + "\n");
|
|
212
|
+
} else {
|
|
213
|
+
// No existing file or overwrite requested — byte-copy
|
|
214
|
+
copyFileSync(status.authPath, destPath);
|
|
215
|
+
importedCredentials = status.credentialCount;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
chmodSync(destPath, 0o600);
|
|
220
|
+
} catch {
|
|
221
|
+
// best-effort chmod — may fail on some filesystems
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
imported: { providers: importedProviders, credentials: importedCredentials },
|
|
227
|
+
conflicts,
|
|
228
|
+
};
|
|
229
|
+
}
|