opencode-wyvern 0.1.0

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.
@@ -0,0 +1,160 @@
1
+ import { c } from "./prompts.js"
2
+ import { renderClient, installClient } from "./client.js"
3
+ import { buildRemoteScript, runRemote, verifyConnection, chooseDefaultModels } from "./server.js"
4
+
5
+ /**
6
+ * Catalogo delle sezioni del setup. Ogni sezione è un modulo indipendente:
7
+ * l'utente installa tutto (i dati vengono raccolti una volta) e attiva
8
+ * solo le sezioni che vuole. Nessuna sezione contiene dati sensibili.
9
+ * host/utente/chiavi sono richiesti al setup, mai presenti qui.
10
+ */
11
+ export const SECTIONS = [
12
+ {
13
+ id: "ssh",
14
+ kind: "local",
15
+ group: "core",
16
+ label: "SSH — chiave, alias, install sul server",
17
+ desc: "Genera/riusa ~/.ssh/id_ed25519 (senza passphrase), aggiunge l'alias a ~/.ssh/config e installa la chiave sul server (password una volta).",
18
+ },
19
+ {
20
+ id: "client-pwsh",
21
+ kind: "local",
22
+ group: "client",
23
+ label: "Client PowerShell — comandi oc-*",
24
+ desc: "Blocco oc-*, oc-go, oc-resume, oc-recap nel profilo PowerShell.",
25
+ },
26
+ {
27
+ id: "client-bash",
28
+ kind: "local",
29
+ group: "client",
30
+ label: "Client bash — comandi oc-*",
31
+ desc: "Stesso blocco in ~/.bashrc.",
32
+ },
33
+ {
34
+ id: "server",
35
+ kind: "remote",
36
+ group: "server",
37
+ label: "Server — bootstrap opencode",
38
+ desc: "Controlla node/npm, installa opencode se manca, crea ~/.config/opencode e AGENTS.md.",
39
+ },
40
+ {
41
+ id: "commands",
42
+ kind: "remote",
43
+ group: "server",
44
+ label: "Server — comando /baseline-ui",
45
+ desc: "Scrive command/baseline-ui.md (baseline di interfaccia).",
46
+ },
47
+ {
48
+ id: "providers",
49
+ kind: "remote",
50
+ group: "server",
51
+ label: "Server — provider (Copilot, Gemini, Zen, Anthropic, OpenAI, OmniRoute)",
52
+ desc: "SDK + blocco provider in opencode.json; API key in .env sul server (chmod 600).",
53
+ },
54
+ {
55
+ id: "plugins",
56
+ kind: "remote",
57
+ group: "server",
58
+ label: "Server — plugin (copilot-auth, claude-auth, kimi, omniroute)",
59
+ desc: "Plugin npm installati in ~/.config/opencode e listati in opencode.json.",
60
+ },
61
+ {
62
+ id: "claude-mem",
63
+ kind: "remote",
64
+ group: "server",
65
+ label: "Server — claude-mem (memoria)",
66
+ desc: "Installa opcode-mem + wrapper plugins/claude-mem-plugin.js.",
67
+ },
68
+ ]
69
+
70
+ export const PLUGIN_CHOICES = [
71
+ { label: "copilot-auth (auth GitHub Copilot)", value: "copilot-auth" },
72
+ { label: "claude-auth (auth Claude)", value: "claude-auth" },
73
+ { label: "kimi-subscription", value: "kimi" },
74
+ { label: "omniroute (gateway multi-modello)", value: "omniroute" },
75
+ ]
76
+
77
+ export function getSection(id) {
78
+ return SECTIONS.find((s) => s.id === id)
79
+ }
80
+
81
+ /** Set degli id di sezione attivi da config. */
82
+ export function activeSections(cfg) {
83
+ const map = cfg.sections || {}
84
+ return new Set(Object.entries(map).filter(([, v]) => v).map(([k]) => k))
85
+ }
86
+
87
+ export function defaultSections() {
88
+ return Object.fromEntries(SECTIONS.map((s) => [s.id, true]))
89
+ }
90
+
91
+ /** Provider attivi da config (chiavi booleane dell'oggetto cfg.providers). */
92
+ export function activeProviders(cfg) {
93
+ const p = cfg.providers || {}
94
+ return new Set(Object.entries(p).filter(([, v]) => v).map(([k]) => k))
95
+ }
96
+
97
+ /** Stato server derivato dalla config (provider/plugin/claude-mem/estensioni). */
98
+ export function serverState(cfg) {
99
+ const sections = activeSections(cfg)
100
+ const providers = activeProviders(cfg)
101
+ const plugins = cfg.plugins || []
102
+ const models = cfg.models || {}
103
+ const omnirouteUrl = cfg.omnirouteUrl || ""
104
+ const { defaultModel, smallModel } = chooseDefaultModels(providers, models, omnirouteUrl)
105
+ return {
106
+ sections,
107
+ providers,
108
+ plugins,
109
+ claudeMem: sections.has("claude-mem"),
110
+ models,
111
+ omnirouteUrl,
112
+ defaultModel,
113
+ smallModel,
114
+ tuning: cfg.tuning,
115
+ envKeys: cfg.envKeys || {},
116
+ }
117
+ }
118
+
119
+ /** Renderizza l'artefatto di una sezione locale o lo script remoto. */
120
+ export function renderSection(id, cfg) {
121
+ if (id === "client-pwsh") return { content: renderClient("pwsh", cfg.entry || cfg) }
122
+ if (id === "client-bash") return { content: renderClient("bash", cfg.entry || cfg) }
123
+ const remoteIds = new Set(["server", "commands", "providers", "plugins", "claude-mem"])
124
+ if (remoteIds.has(id)) {
125
+ const state = serverState(cfg)
126
+ const only = new Set([id])
127
+ return { content: buildRemoteScript({ ...state, sections: only }), remote: true, script: true }
128
+ }
129
+ return null
130
+ }
131
+
132
+ /** Applica una sezione locale (ssh, client) alla macchina corrente. */
133
+ export async function applyLocalSection(id, cfg) {
134
+ if (id === "client-pwsh" || id === "client-bash") {
135
+ await installClient(cfg.entry || cfg, { targets: [id === "client-bash" ? "bash" : "pwsh"] })
136
+ return
137
+ }
138
+ if (id === "ssh") {
139
+ console.log(c.cyan("[..] Chiave SSH + alias (l'installazione sul server richiede la password una volta)..."))
140
+ // nulla da fare qui: la chiave/alias vengono gestiti dal wizard
141
+ }
142
+ }
143
+
144
+ /** Applica le sezioni remoto: compone lo script e lo esegue via SSH. */
145
+ export function applyRemoteSections(cfg, { dryRun = false, envKeys } = {}) {
146
+ const state = { ...serverState(cfg), envKeys: envKeys || cfg.envKeys || {} }
147
+ const script = buildRemoteScript(state)
148
+ const remoteActive = [...state.sections].filter((id) => getSection(id)?.kind === "remote")
149
+ if (!remoteActive.length) return { applied: false, script }
150
+
151
+ const ok = verifyConnection(cfg.entry || cfg)
152
+ if (!ok) {
153
+ // NB: non stampiamo lo script: con le API key dentro sarebbe un leak a video.
154
+ console.log(c.yellow(` server non raggiungibile: script pronto (${script.length} byte). Esegui di nuovo \`oc-setup\` o \`oc-setup generate\` quando torna.`))
155
+ console.log(c.dim(` sezioni remoto da applicare: ${remoteActive.join(", ")}`))
156
+ return { applied: false, script }
157
+ }
158
+ runRemote(cfg.entry || cfg, script)
159
+ return { applied: true, script }
160
+ }
package/src/server.js ADDED
@@ -0,0 +1,343 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { spawnSync } from "node:child_process"
4
+ import { keyPaths, sshConfigPath, run } from "./shell.js"
5
+ import { readTemplate, ensureDir } from "./config.js"
6
+ import { c } from "./prompts.js"
7
+
8
+ /**
9
+ * Catalogo dei provider opencode supportati dal wizard.
10
+ * Nuova struttura basata sulla config "golden" (nessun dato specifico:
11
+ * le chiavi e gli endpoint sono richiesti all'utente al setup).
12
+ */
13
+ export const PROVIDER_META = {
14
+ copilot: {
15
+ npm: "@ai-sdk/openai-compatible",
16
+ name: "GitHub Copilot",
17
+ key: null,
18
+ defModels: ["gpt-4o", "gpt-4o-mini"],
19
+ },
20
+ gemini: {
21
+ npm: "@ai-sdk/google",
22
+ name: "Google Gemini",
23
+ key: "GOOGLE_GENERATIVE_AI_API_KEY",
24
+ defModels: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash", "gemini-2.0-flash-lite"],
25
+ },
26
+ zen: {
27
+ npm: "@ai-sdk/openai-compatible",
28
+ name: "OpenCode Zen",
29
+ key: null,
30
+ baseURL: "https://opencode.ai/zen/v1",
31
+ defModels: ["big-pickle"],
32
+ fixedModels: { "big-pickle": { name: "Big Pickle", reasoning: true } },
33
+ },
34
+ anthropic: {
35
+ npm: "@ai-sdk/anthropic",
36
+ name: "Anthropic Claude",
37
+ key: "ANTHROPIC_API_KEY",
38
+ defModels: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"],
39
+ },
40
+ openai: {
41
+ npm: "@ai-sdk/openai",
42
+ name: "OpenAI / Codex",
43
+ key: "OPENAI_API_KEY",
44
+ defModels: ["gpt-5.1-codex", "gpt-5.1", "gpt-5-mini"],
45
+ },
46
+ omniroute: {
47
+ npm: "@ai-sdk/openai-compatible",
48
+ name: "OmniRoute (aggregator)",
49
+ key: null,
50
+ baseURLVar: true,
51
+ defModels: [
52
+ "auto/best-coding", "auto/best-reasoning", "auto/best-fast",
53
+ "auto/cheap", "auto/best-free", "felo/felo-search", "felo/felo-scholar",
54
+ ],
55
+ },
56
+ }
57
+
58
+ export const PROVIDER_ORDER = ["copilot", "gemini", "zen", "anthropic", "openai", "omniroute"]
59
+
60
+ const MODEL_LABELS = {
61
+ "gpt-4o": "GPT-4o", "gpt-4o-mini": "GPT-4o mini",
62
+ "gemini-2.5-pro": "Gemini 2.5 Pro", "gemini-2.5-flash": "Gemini 2.5 Flash",
63
+ "gemini-2.0-flash": "Gemini 2.0 Flash", "gemini-2.0-flash-lite": "Gemini 2.0 Flash Lite",
64
+ "claude-opus-4-5": "Claude Opus 4.5", "claude-sonnet-4-5": "Claude Sonnet 4.5", "claude-haiku-4-5": "Claude Haiku 4.5",
65
+ "gpt-5.1-codex": "GPT-5.1 Codex", "gpt-5.1": "GPT-5.1", "gpt-5-mini": "GPT-5 mini",
66
+ "auto/best-coding": "Auto: best coding", "auto/best-reasoning": "Auto: best reasoning",
67
+ "auto/best-fast": "Auto: best fast", "auto/cheap": "Auto: cheap", "auto/best-free": "Auto: best free",
68
+ "felo/felo-search": "Felo: web search", "felo/felo-scholar": "Felo: academic search",
69
+ }
70
+
71
+ /** Nome del provider nel JSON opencode (la chiave "opencode" è per OpenCode Zen). */
72
+ export function providerJsonKey(id) {
73
+ return id === "zen" ? "opencode" : id
74
+ }
75
+
76
+ export function defaultOmniRouteBase() {
77
+ return "http://127.0.0.1:20128"
78
+ }
79
+
80
+ /** Base baseURL del gateway per il blocco provider (append /v1 se manca). */
81
+ export function omnirouteProviderUrl(u) {
82
+ const base = (u && u.trim()) || defaultOmniRouteBase()
83
+ return base.endsWith("/v1") ? base : `${base}/v1`
84
+ }
85
+
86
+ /**
87
+ * Calcola i modelli default: se omniroute è attivo usa i suoi alias "auto/*",
88
+ * altrimenti il primo modello del primo provider attivo (ordine PROVIDER_ORDER).
89
+ */
90
+ export function chooseDefaultModels(providers = new Set(), models = {}, omnirouteUrl = "") {
91
+ if (providers.has("omniroute")) {
92
+ return {
93
+ defaultModel: "omniroute/auto/best-coding",
94
+ smallModel: "omniroute/auto/best-fast",
95
+ omnirouteUrl: omnirouteUrl || defaultOmniRouteBase(),
96
+ }
97
+ }
98
+ const firstId = PROVIDER_ORDER.find((id) => providers.has(id))
99
+ if (!firstId) return {}
100
+ const list = ((models[firstId] || PROVIDER_META[firstId].defModels.join(", ")).split(",").map((s) => s.trim()).filter(Boolean))
101
+ const key = providerJsonKey(firstId)
102
+ return {
103
+ defaultModel: `${key}/${list[0]}`,
104
+ smallModel: list.length > 1 ? `${key}/${list[1]}` : `${key}/${list[0]}`,
105
+ }
106
+ }
107
+
108
+ /** Mappa plugin wyvern -> nome pacchetto npm (claude-mem è gestito a parte). */
109
+ export const PLUGIN_PKG = {
110
+ "claude-auth": "opencode-claude-auth@latest",
111
+ "kimi": "opencode-kimi-subscription",
112
+ "copilot-auth": "opencode-copilot-auth@latest",
113
+ "omniroute": "@omniroute/opencode-plugin@latest",
114
+ }
115
+
116
+ function merchantEntry(p, omnirouteUrl) {
117
+ if (p === "omniroute") {
118
+ return [PLUGIN_PKG[p], {
119
+ providerId: "omniroute",
120
+ baseURL: (omnirouteUrl && omnirouteUrl.trim()) || defaultOmniRouteBase(),
121
+ features: { combos: false, autoCombos: false, compressionMetadata: true, debugLog: false, logLevel: "error" },
122
+ autoSyncIntervalMs: 0,
123
+ }]
124
+ }
125
+ return PLUGIN_PKG[p]
126
+ }
127
+
128
+ function providerBlock(id, models, omnirouteUrl) {
129
+ const meta = PROVIDER_META[id]
130
+ const block = { npm: meta.npm, name: meta.name }
131
+ if (meta.key) block.env = [meta.key]
132
+ if (meta.baseURL) block.options = { baseURL: meta.baseURL }
133
+ if (meta.baseURLVar) block.options = { baseURL: omnirouteProviderUrl(omnirouteUrl) }
134
+ block.models = meta.fixedModels
135
+ || Object.fromEntries(models.map((m) => [m, { name: MODEL_LABELS[m] || m }]))
136
+ return block
137
+ }
138
+
139
+ /**
140
+ * Costruisce opencode.json dal server in modo programmatico (solo sezioni attive).
141
+ * Nessun segreto nel JSON: le chiavi restano in .env (dichiarate con "env": [...]).
142
+ */
143
+ export function buildServerConfig({ providers = new Set(), models = {}, omnirouteUrl = "", defaultModel, smallModel, tuning = false, plugins = [], claudeMem = false } = {}) {
144
+ const provider = {}
145
+ for (const id of PROVIDER_ORDER) {
146
+ if (providers.has(id)) {
147
+ const list = (models[id] || PROVIDER_META[id].defModels.join(", ")).split(",").map((s) => s.trim()).filter(Boolean)
148
+ provider[providerJsonKey(id)] = providerBlock(id, list, omnirouteUrl)
149
+ }
150
+ }
151
+
152
+ const plugin = [...(plugins || [])].map((p) => merchantEntry(p, omnirouteUrl))
153
+ if (claudeMem) plugin.push("./plugins/claude-mem-plugin.js")
154
+
155
+ const out = {
156
+ $schema: "https://opencode.ai/config.json",
157
+ provider,
158
+ plugin,
159
+ }
160
+ if (defaultModel) out.model = defaultModel
161
+ if (smallModel) out.small_model = smallModel
162
+ if (tuning) {
163
+ out.tool_output = { max_lines: 200, max_bytes: 8192 }
164
+ out.compaction = { auto: true, prune: true, tail_turns: 5, preserve_recent_tokens: 12000, reserved: 525000 }
165
+ }
166
+ return out
167
+ }
168
+
169
+ /** Genera la chiave SSH ed25519 locale se non esiste (mai passphrase). */
170
+ export function ensureLocalKey() {
171
+ const { key, pub } = keyPaths()
172
+ ensureDir(path.dirname(key)) // ~/.ssh
173
+ if (fs.existsSync(key) && fs.existsSync(pub)) {
174
+ return { key, pub, created: false }
175
+ }
176
+ console.log(c.cyan("[..] Genero la SSH key ed25519 (senza passphrase)..."))
177
+ const gen = spawnSync("ssh-keygen", ["-t", "ed25519", "-C", "", "-f", key, "-N", ""], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
178
+ if (gen.status !== 0) {
179
+ throw new Error(`ssh-keygen fallito: ${(gen.stderr || "").toString().trim()}`)
180
+ }
181
+ return { key, pub, created: true }
182
+ }
183
+
184
+ /**
185
+ * Installa la chiave pubblica sul server. Chiede la password via TTY
186
+ * (stdio ereditato); il contenuto della chiave passa dallo stdin del child.
187
+ */
188
+ export function installKeyOnServer(cfg) {
189
+ const { pub } = keyPaths()
190
+ const pubContent = fs.readFileSync(pub, "utf8").trim()
191
+ const target = sshTargetArgs(cfg)
192
+ const remoteCmd =
193
+ "umask 077; mkdir -p ~/.ssh && grep -qF '" +
194
+ pubContent.split(" ")[0] +
195
+ "' ~/.ssh/authorized_keys 2>/dev/null || (cat >> ~/.ssh/authorized_keys); " +
196
+ "chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys; echo OK"
197
+
198
+ console.log(c.cyan(`[..] Installo la chiave su ${c.bold(target.join(" "))} (password una volta)...`))
199
+ const res = spawnSync("ssh", [...target, remoteCmd], {
200
+ encoding: "utf8",
201
+ stdio: ["pipe", "inherit", "inherit"],
202
+ input: pubContent + "\n",
203
+ timeout: 60000,
204
+ })
205
+ if (res.status !== 0) {
206
+ throw new Error(`installazione chiave fallita (exit ${res.status})`)
207
+ }
208
+ console.log(c.green(" chiave installata."))
209
+ }
210
+
211
+ export function sshTargetArgs({ user, host, port }) {
212
+ const target = user ? `${user}@${host}` : host
213
+ return port && port !== "" && String(port) !== "22" ? ["-p", String(port), target] : [target]
214
+ }
215
+
216
+ /** Riga Host alias per ~/.ssh/config. */
217
+ export function hostAliasBlock(cfg) {
218
+ const parts = [`Host ${cfg.server}`]
219
+ if (cfg.host) parts.push(` HostName ${cfg.host}`)
220
+ if (cfg.user) parts.push(` User ${cfg.user}`)
221
+ if (cfg.port && String(cfg.port) !== "22") parts.push(` Port ${cfg.port}`)
222
+ parts.push(" ServerAliveInterval 30")
223
+ parts.push(" ServerAliveCountMax 5")
224
+ parts.push(" StrictHostKeyChecking accept-new")
225
+ return parts.join("\n")
226
+ }
227
+
228
+ export function ensureSshConfigAlias(cfg) {
229
+ if (!cfg.host) return false // nessun host: l'utente usa la propria ~/.ssh/config
230
+ const p = sshConfigPath()
231
+ const target = `Host ${cfg.server}`
232
+ let content = ""
233
+ if (fs.existsSync(p)) {
234
+ content = fs.readFileSync(p, "utf8")
235
+ if (content.includes(target)) return false
236
+ if (content && !content.endsWith("\n")) content += "\n"
237
+ }
238
+ ensureDir(path.dirname(p))
239
+ fs.writeFileSync(p, content + hostAliasBlock(cfg) + "\n", "utf8")
240
+ return true
241
+ }
242
+
243
+ /** Verifica la connessione SSH senza password (BatchMode). */
244
+ export function verifyConnection(cfg) {
245
+ const res = run("ssh", [...sshTargetArgs(cfg), "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "echo CONN_OK"], {
246
+ silent: true,
247
+ timeout: 20000,
248
+ })
249
+ return res.ok && res.stdout.includes("CONN_OK")
250
+ }
251
+
252
+ /**
253
+ * Costruisce lo script bash remoto, componendo SOLO le sezioni attive.
254
+ * I file vengono trasferiti come base64 (niente escaping/quoting, nessuna injection).
255
+ * I segreti (API key) finiscono solo in .env (chmod 600) quando la sezione providers
256
+ * è attiva e l'utente ne ha fornite.
257
+ */
258
+ export function buildRemoteScript({ sections = new Set(), providers = new Set(), models = {}, omnirouteUrl = "", defaultModel, smallModel, tuning = false, plugins = [], claudeMem = false, envKeys = {} } = {}) {
259
+ const esc = (s) => Buffer.from(s, "utf8").toString("base64")
260
+ const act = (id) => sections.has(id)
261
+
262
+ const lines = [
263
+ "set -e",
264
+ "log(){ echo \"[oc] $*\"; }",
265
+ "command -v node >/dev/null || { log 'ERRORE: node non trovato sul server'; exit 2; }",
266
+ "command -v npm >/dev/null || { log 'ERRORE: npm non trovato sul server'; exit 2; }",
267
+ "log \"node: $(node -v)\"",
268
+ ]
269
+
270
+ if (act("server")) {
271
+ lines.push(
272
+ "OPENCODE_BIN=\"$(command -v opencode 2>/dev/null || ls -t $HOME/.nvm/versions/node/*/bin/opencode 2>/dev/null | head -n1)\"",
273
+ "if [ -z \"$OPENCODE_BIN\" ]; then",
274
+ " log 'opencode mancante: lo installo (npm i -g opencode-ai)'",
275
+ " npm install -g opencode-ai >/dev/null 2>&1 || true",
276
+ " OPENCODE_BIN=\"$(command -v opencode 2>/dev/null)\"",
277
+ "fi",
278
+ 'CFG_DIR="${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}"',
279
+ 'mkdir -p "$CFG_DIR/plugins" "$CFG_DIR/command"',
280
+ 'cd "$CFG_DIR"',
281
+ '[ -f package.json ] || printf \'{\\n "private": true,\\n "dependencies": {}\\n}\\n\' > package.json',
282
+ "[ -f .gitignore ] || printf 'node_modules\\n.env\\n' > .gitignore",
283
+ `echo '${esc(readTemplate("AGENTS.md"))}' | base64 -d > "$CFG_DIR/AGENTS.md"`,
284
+ )
285
+ }
286
+
287
+ if (act("commands")) {
288
+ lines.push(`echo '${esc(readTemplate("command-baseline-ui.md"))}' | base64 -d > "$CFG_DIR/command/baseline-ui.md"`)
289
+ }
290
+
291
+ if (claudeMem) {
292
+ lines.push(
293
+ "log 'claude-mem: installo opcode-mem + wrapper plugins/claude-mem-plugin.js'",
294
+ "npm install opcode-mem@latest >/dev/null 2>&1 || true",
295
+ `echo '${esc(readTemplate("claude-mem-plugin.js"))}' | base64 -d > "$CFG_DIR/plugins/claude-mem-plugin.js"`,
296
+ )
297
+ }
298
+
299
+ const providerNpm = [...new Set([...providers].map((id) => PROVIDER_META[id]?.npm).filter(Boolean))]
300
+ if (providerNpm.length) lines.push(`npm install ${providerNpm.join(" ")} >/dev/null 2>&1 || true`)
301
+
302
+ const pluginPkgs = [...new Set((plugins || []).map((p) => PLUGIN_PKG[p]).filter(Boolean))]
303
+ if (pluginPkgs.length) lines.push(`npm install ${pluginPkgs.join(" ")} >/dev/null 2>&1 || true`)
304
+
305
+ const hasServer = act("server") || act("commands") || act("plugins") || claudeMem || providers.size > 0
306
+ if (hasServer) {
307
+ const json = buildServerConfig({ providers, models, omnirouteUrl, defaultModel, smallModel, tuning, plugins, claudeMem })
308
+ lines.push(`echo '${esc(JSON.stringify(json, null, 2))}' | base64 -d > "$CFG_DIR/opencode.json"`)
309
+ }
310
+
311
+ const keyEntries = Object.entries(envKeys || {}).filter(([, v]) => v)
312
+ if (act("providers") && keyEntries.length) {
313
+ lines.push("umask 077")
314
+ lines.push("printf '' > \"$CFG_DIR/.env\"")
315
+ for (const [k, v] of keyEntries) {
316
+ lines.push(`echo '${esc(`${k}="${v}"`)}' | base64 -d >> "$CFG_DIR/.env"`)
317
+ }
318
+ lines.push("chmod 600 \"$CFG_DIR/.env\"")
319
+ } else if (act("server")) {
320
+ lines.push("umask 077; [ -f \"$CFG_DIR/.env\" ] || : > \"$CFG_DIR/.env\"")
321
+ }
322
+
323
+ lines.push('log "config opencode scritta in: $CFG_DIR"')
324
+ lines.push("[ -n \"$OPENCODE_BIN\" ] && \"$OPENCODE_BIN\" --version && echo REMOTE_OK || echo REMOTE_NO_OPENCODE")
325
+ return lines.join("\n")
326
+ }
327
+
328
+ /** Esegue lo script remoto via `ssh target "echo <b64> | base64 -d | bash"`. */
329
+ export function runRemote(cfg, script) {
330
+ const b64 = Buffer.from(script, "utf8").toString("base64")
331
+ const remoteCmd = `printf '%s' ${b64} | base64 -d | bash`
332
+ console.log(c.cyan("[..] Eseguo bootstrap sul server (può richiedere qualche minuto)..."))
333
+ const res = spawnSync("ssh", [...sshTargetArgs(cfg), remoteCmd], {
334
+ encoding: "utf8",
335
+ stdio: ["inherit", "inherit", "inherit"],
336
+ timeout: 600000,
337
+ })
338
+ return { ok: res.status === 0, status: res.status }
339
+ }
340
+
341
+ export function isPortValid(v) {
342
+ return v === "" || (/^\d+$/.test(v) && Number(v) >= 1 && Number(v) <= 65535)
343
+ }