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.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/bin/oc-setup.js +87 -0
- package/package.json +41 -0
- package/src/client.js +109 -0
- package/src/config.js +64 -0
- package/src/prompts.js +164 -0
- package/src/sections.js +160 -0
- package/src/server.js +343 -0
- package/src/setup.js +271 -0
- package/src/shell.js +102 -0
- package/src/uninstall.js +109 -0
- package/templates/AGENTS.md +17 -0
- package/templates/claude-mem-plugin.js +6 -0
- package/templates/client-bash.sh +418 -0
- package/templates/client-pwsh.ps1 +527 -0
- package/templates/command-baseline-ui.md +23 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rhaegal222
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# opencode-wyvern
|
|
2
|
+
|
|
3
|
+
Setup **modulare** per OpenCode: installi tutto, attivi quello che vuoi.
|
|
4
|
+
|
|
5
|
+
Il pacchetto non contiene alcun dato privato: host, utente, chiavi SSH e
|
|
6
|
+
API key vengono **richiesti/creati durante il setup**, mai salvati in locale
|
|
7
|
+
(le API key finiscono solo in `~/.config/opencode/.env` sul server, `chmod 600`).
|
|
8
|
+
|
|
9
|
+
## Installazione
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g opencode-wyvern
|
|
13
|
+
oc-setup
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
La procedura guidata:
|
|
17
|
+
|
|
18
|
+
1. **Moduli** — scegli quali sezioni attivare (default: tutte).
|
|
19
|
+
2. **Connessione** — solo i dati necessari ai moduli scelti
|
|
20
|
+
(host obbligatorio solo se usi SSH/sezioni remote; coi soli client basta l'alias).
|
|
21
|
+
3. **Provider / plugin / claude-mem** — solo se le sezioni corrispondenti sono attive;
|
|
22
|
+
endpoint e chiavi sono richieste al volo.
|
|
23
|
+
4. **Applicazione** — chiave SSH, alias, profili client e bootstrap remoto.
|
|
24
|
+
|
|
25
|
+
Le sezioni restano salvate in `~/.config/opencode-wyvern/config.json`
|
|
26
|
+
(solo dati non sensibili) e puoi attivarle/disattivarle in seguito.
|
|
27
|
+
|
|
28
|
+
## Comandi
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
oc-setup avvia la procedura guidata
|
|
32
|
+
oc-setup generate riapplica le sezioni attive dalla config
|
|
33
|
+
oc-setup status mostra moduli, entry e provider configurati
|
|
34
|
+
oc-setup activate <sezione> attiva un modulo
|
|
35
|
+
oc-setup deactivate <sezione> disattiva un modulo
|
|
36
|
+
oc-setup print <sezione> stampa l'artefatto di una sezione
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Moduli
|
|
40
|
+
|
|
41
|
+
| Sezione | Dove | Cosa fa |
|
|
42
|
+
|---------------|-------|---------|
|
|
43
|
+
| `ssh` | locale | genera/riusa la chiave ed25519, alias in `~/.ssh/config`, install chiave sul server |
|
|
44
|
+
| `client-pwsh` | locale | comandi `oc-*` nel profilo PowerShell (`oc-sessions`, `oc-go`, `oc-resume`, `oc-recap`, ...) |
|
|
45
|
+
| `client-bash` | locale | stesso blocco in `~/.bashrc` |
|
|
46
|
+
| `server` | remoto | controlla node/npm, installa opencode se manca, crea `~/.config/opencode` + `AGENTS.md` |
|
|
47
|
+
| `commands` | remoto | comando `/baseline-ui` (`command/baseline-ui.md`) |
|
|
48
|
+
| `providers` | remoto | provider opencode in `opencode.json`; chiavi in `.env` |
|
|
49
|
+
| `plugins` | remoto | plugin npm installati nella dir config e listati in `opencode.json` |
|
|
50
|
+
| `claude-mem` | remoto | memoria: wrapper `plugins/claude-mem-plugin.js` |
|
|
51
|
+
|
|
52
|
+
## Provider presets
|
|
53
|
+
|
|
54
|
+
La sezione `providers` propone (checkbox): **GitHub Copilot**, **Google Gemini**,
|
|
55
|
+
**OpenCode Zen**, **Anthropic Claude**, **OpenAI / Codex** e **OmniRoute**
|
|
56
|
+
(gateway multi-modello con supporto `auto/*`). Le chiavi vengono chieste al setup
|
|
57
|
+
solo per i provider che le richiedono (`GOOGLE_GENERATIVE_AI_API_KEY`,
|
|
58
|
+
`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) e finiscono solo nel `.env` remoto.
|
|
59
|
+
Per OmniRoute viene chiesto l'URL del gateway (default `http://127.0.0.1:20128`).
|
|
60
|
+
|
|
61
|
+
Il modello default è scelto automaticamente (con OmniRoute: `omniroute/auto/best-coding`,
|
|
62
|
+
altrimenti il primo modello del primo provider attivo), insieme a `small_model`.
|
|
63
|
+
Un'opzione del setup aggiunge i tuning collaudati `tool_output` e `compaction`.
|
|
64
|
+
|
|
65
|
+
## Client
|
|
66
|
+
|
|
67
|
+
I client espongono i comandi `oc-*` e mostrano le **sessioni delle ultime 24 ore**
|
|
68
|
+
(finestra scorrevole). In particolare `oc-sessions` elenca le sessioni recenti,
|
|
69
|
+
`oc-go` apre una sessione, `oc-resume` ne riapre più di una, `oc-recap` riepiloga
|
|
70
|
+
a fine connessione. Richiedono `oc-setup ssh` o una `~/.ssh/config` già pronta.
|
|
71
|
+
|
|
72
|
+
## Sviluppo
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
node scripts/sync-client.mjs # applica le modifiche condivise e rigenera il template pwsh (sanitizzato)
|
|
76
|
+
node bin/oc-setup.js --help
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Sicurezza
|
|
80
|
+
|
|
81
|
+
- Nessuna chiave/token/password nel pacchetto o in `config.json` locale.
|
|
82
|
+
- Le API key sono scritte dal bootstrap remoto in `~/.config/opencode/.env` (`chmod 600`).
|
|
83
|
+
- I file remoti viaggiano come base64 dentro lo script; con server non raggiungibile
|
|
84
|
+
lo script non viene stampato a video (evita leak delle chiavi).
|
package/bin/oc-setup.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run, cmdStatus, cmdActivate, cmdDeactivate, cmdGenerate, cmdPrint, runPrintClient } from "../src/setup.js"
|
|
3
|
+
import { runUninstall } from "../src/uninstall.js"
|
|
4
|
+
|
|
5
|
+
const argv = process.argv.slice(2)
|
|
6
|
+
|
|
7
|
+
const HELP = `oc-setup — OpenCode Wyvern
|
|
8
|
+
|
|
9
|
+
Setup modulare per OpenCode: installi tutto, attivi quello che vuoi.
|
|
10
|
+
Nessun dato privato è incluso nel pacchetto: host, utente, chiavi e
|
|
11
|
+
API key vengono richiesti/creati in locale durante l'esecuzione.
|
|
12
|
+
|
|
13
|
+
Uso:
|
|
14
|
+
oc-setup avvia la procedura guidata interattiva
|
|
15
|
+
oc-setup generate riapplica le sezioni attive dalla config
|
|
16
|
+
oc-setup uninstall disinstallazione completa (client, config, cache, alias SSH)
|
|
17
|
+
oc-setup status mostra i moduli e lo stato
|
|
18
|
+
oc-setup activate <sezione> attiva un modulo
|
|
19
|
+
oc-setup deactivate <sezione> disattiva un modulo
|
|
20
|
+
oc-setup print <sezione> stampa l'artefatto di una sezione
|
|
21
|
+
oc-setup --version | -v mostra la versione
|
|
22
|
+
oc-setup --help | -h questo aiuto
|
|
23
|
+
|
|
24
|
+
Sezioni:
|
|
25
|
+
ssh, client-pwsh, client-bash, server, commands, providers, plugins, claude-mem
|
|
26
|
+
`
|
|
27
|
+
|
|
28
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
29
|
+
console.log(HELP)
|
|
30
|
+
process.exit(0)
|
|
31
|
+
}
|
|
32
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
33
|
+
const meta = await import("../package.json", { with: { type: "json" } })
|
|
34
|
+
console.log(meta.default.version)
|
|
35
|
+
process.exit(0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const printIdx = argv.indexOf("--print-client")
|
|
39
|
+
if (printIdx !== -1) {
|
|
40
|
+
const kind = argv[printIdx + 1]
|
|
41
|
+
if (kind !== "pwsh" && kind !== "bash") {
|
|
42
|
+
console.error("uso: oc-setup --print-client <pwsh|bash>")
|
|
43
|
+
process.exit(2)
|
|
44
|
+
}
|
|
45
|
+
await runPrintClient(kind)
|
|
46
|
+
process.exit(0)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [sub, arg1] = argv
|
|
50
|
+
try {
|
|
51
|
+
switch (sub) {
|
|
52
|
+
case "status":
|
|
53
|
+
await cmdStatus()
|
|
54
|
+
break
|
|
55
|
+
case "activate":
|
|
56
|
+
if (!arg1) fail("uso: oc-setup activate <sezione>")
|
|
57
|
+
await cmdActivate(arg1)
|
|
58
|
+
break
|
|
59
|
+
case "deactivate":
|
|
60
|
+
if (!arg1) fail("uso: oc-setup deactivate <sezione>")
|
|
61
|
+
await cmdDeactivate(arg1)
|
|
62
|
+
break
|
|
63
|
+
case "generate":
|
|
64
|
+
await cmdGenerate()
|
|
65
|
+
break
|
|
66
|
+
case "uninstall":
|
|
67
|
+
await runUninstall()
|
|
68
|
+
break
|
|
69
|
+
case "print":
|
|
70
|
+
if (!arg1) fail("uso: oc-setup print <sezione>")
|
|
71
|
+
await cmdPrint(arg1)
|
|
72
|
+
break
|
|
73
|
+
case undefined:
|
|
74
|
+
await run(argv)
|
|
75
|
+
break
|
|
76
|
+
default:
|
|
77
|
+
fail(`comando sconosciuto: ${sub}`)
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error(`\n[error] ${err && err.message ? err.message : err}`)
|
|
81
|
+
process.exit(1)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fail(msg) {
|
|
85
|
+
console.error(`[error] ${msg}`)
|
|
86
|
+
process.exit(2)
|
|
87
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-wyvern",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Setup modulare per OpenCode: SSH, client PowerShell/bash (comandi oc-*), configurazione server opencode, provider, plugin e comandi custom. Installi tutto, attivi quello che vuoi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Rhaegal222",
|
|
9
|
+
"url": "https://github.com/Rhaegal222"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=18"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"oc-setup": "bin/oc-setup.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"src",
|
|
20
|
+
"templates",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"opencode",
|
|
26
|
+
"ssh",
|
|
27
|
+
"setup",
|
|
28
|
+
"modular",
|
|
29
|
+
"cli",
|
|
30
|
+
"claude-mem",
|
|
31
|
+
"powershell",
|
|
32
|
+
"bash"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Rhaegal222/opencode-wyvern.git"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node bin/oc-setup.js --help"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import crypto from "node:crypto"
|
|
3
|
+
import { render, readTemplate } from "./config.js"
|
|
4
|
+
import { pwshProfilePath, bashRcPath } from "./shell.js"
|
|
5
|
+
import { c } from "./prompts.js"
|
|
6
|
+
|
|
7
|
+
export const MARKER = "# ---- OpenCode Wyvern (auto-generato) ----"
|
|
8
|
+
/** Marker storici: un blocco generato in passato va comunque rimosso. */
|
|
9
|
+
export const LEGACY_MARKERS = ["# ---- Opencode Remote (auto-generato) ----"]
|
|
10
|
+
|
|
11
|
+
/** Mappa tipo template -> file di profilo su cui appende il blocco. */
|
|
12
|
+
const TARGETS = {
|
|
13
|
+
pwsh: { tpl: "client-pwsh.ps1", path: pwshProfilePath },
|
|
14
|
+
bash: { tpl: "client-bash.sh", path: bashRcPath },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Blocco client renderizzato con i valori reali.
|
|
19
|
+
* Nessun dato sensibile: solo alias/host/user/dir, mai chiavi o password.
|
|
20
|
+
*/
|
|
21
|
+
export function renderClient(kind, { server, host, user, dir }) {
|
|
22
|
+
const vars = {
|
|
23
|
+
OC_SERVER: server || "remote-server",
|
|
24
|
+
OC_HOST: host || "localhost",
|
|
25
|
+
OC_USER: user || "user",
|
|
26
|
+
OC_DIR: dir || "~",
|
|
27
|
+
}
|
|
28
|
+
const tpl = readTemplate(TARGETS[kind].tpl)
|
|
29
|
+
return render(tpl, vars).replace(/\r\n/g, "\n")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Sostituisce/crea il blocco generato in un profilo, con backup. */
|
|
33
|
+
function upsertBlock(filePath, block, { dryRun = false } = {}) {
|
|
34
|
+
const existed = fs.existsSync(filePath)
|
|
35
|
+
let content = existed ? fs.readFileSync(filePath, "utf8") : ""
|
|
36
|
+
|
|
37
|
+
if (content.trim() !== "" && !content.endsWith("\n")) content += "\n"
|
|
38
|
+
|
|
39
|
+
// rimuove ogni blocco già generato (marker attuale o legacy), sempre in coda
|
|
40
|
+
const all = [MARKER, ...LEGACY_MARKERS]
|
|
41
|
+
const hits = all.map((m) => content.indexOf(m)).filter((i) => i !== -1)
|
|
42
|
+
if (hits.length) {
|
|
43
|
+
const start = Math.min(...hits)
|
|
44
|
+
content = content.slice(0, start)
|
|
45
|
+
if (content !== "" && !content.endsWith("\n")) content += "\n"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const stamp = `# --- installato da oc-setup il ${new Date().toISOString().slice(0, 10)} ---`
|
|
49
|
+
const blockOut = block.replace(
|
|
50
|
+
`${MARKER}\n`,
|
|
51
|
+
`${MARKER}\n${stamp}\n`
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
let backup = null
|
|
55
|
+
if (existed && !dryRun) {
|
|
56
|
+
backup = `${filePath}.bak-${crypto.randomBytes(3).toString("hex")}`
|
|
57
|
+
fs.writeFileSync(backup, content, "utf8")
|
|
58
|
+
}
|
|
59
|
+
if (!dryRun) {
|
|
60
|
+
fs.writeFileSync(filePath, content + blockOut + "\n", "utf8")
|
|
61
|
+
}
|
|
62
|
+
return { filePath, backup, changed: true, dryRun }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Rimuove i blocchi client auto-generati (marker attuale o legacy) da un
|
|
67
|
+
* profilo, con backup dell'originale. Utile per la disinstallazione.
|
|
68
|
+
*/
|
|
69
|
+
export function stripClientBlock(filePath) {
|
|
70
|
+
if (!fs.existsSync(filePath)) return { filePath, changed: false }
|
|
71
|
+
const content = fs.readFileSync(filePath, "utf8")
|
|
72
|
+
const all = [MARKER, ...LEGACY_MARKERS]
|
|
73
|
+
const hits = all.map((m) => content.indexOf(m)).filter((i) => i !== -1)
|
|
74
|
+
if (!hits.length) return { filePath, changed: false }
|
|
75
|
+
const start = Math.min(...hits)
|
|
76
|
+
const stripped = content.slice(0, start).replace(/\s+$/, "")
|
|
77
|
+
const backup = `${filePath}.bak-${crypto.randomBytes(3).toString("hex")}`
|
|
78
|
+
fs.writeFileSync(backup, content, "utf8")
|
|
79
|
+
fs.writeFileSync(filePath, stripped ? stripped + "\n" : "", "utf8")
|
|
80
|
+
return { filePath, backup, changed: true }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Installa i comandi client `oc-*` nei profili richiesti.
|
|
85
|
+
* `onlyPrint` (null | "pwsh" | "bash") stampa il blocco renderizzato
|
|
86
|
+
* senza modificare file. `targets` è l'array dei tipi da installare.
|
|
87
|
+
*/
|
|
88
|
+
export async function installClient(cfg, { targets = ["pwsh", "bash"], onlyPrint = null } = {}) {
|
|
89
|
+
if (onlyPrint) {
|
|
90
|
+
const kind = onlyPrint === "bash" ? "bash" : "pwsh"
|
|
91
|
+
console.log(renderClient(kind, cfg))
|
|
92
|
+
return { printed: true, kind }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const results = []
|
|
96
|
+
for (const t of targets) {
|
|
97
|
+
const target = TARGETS[t]
|
|
98
|
+
if (!target) continue
|
|
99
|
+
const block = renderClient(t, cfg)
|
|
100
|
+
results.push(upsertBlock(target.path(), block))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(c.green("\nClient installato:"))
|
|
104
|
+
for (const r of results) {
|
|
105
|
+
console.log(` - ${r.filePath}${r.backup ? ` (backup: ${r.backup})` : ""}`)
|
|
106
|
+
}
|
|
107
|
+
console.log(c.dim("\nRicarica il profilo con: . $PROFILE (PowerShell) / source ~/.bashrc (bash)"))
|
|
108
|
+
return { installed: results }
|
|
109
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { configDir, configFilePath } from "./shell.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configurazione persistente della CLI.
|
|
8
|
+
*
|
|
9
|
+
* NB: NON contiene mai chiavi API né password. I segreti API vengono scritti
|
|
10
|
+
* esclusivamente nel file `.env` sul SERVER (chmod 600), mai qui.
|
|
11
|
+
*/
|
|
12
|
+
export function loadConfig() {
|
|
13
|
+
try {
|
|
14
|
+
const f = configFilePath()
|
|
15
|
+
if (!fs.existsSync(f)) return {}
|
|
16
|
+
return JSON.parse(fs.readFileSync(f, "utf8"))
|
|
17
|
+
} catch {
|
|
18
|
+
return {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveConfig(cfg) {
|
|
23
|
+
fs.mkdirSync(configDir(), { recursive: true })
|
|
24
|
+
fs.writeFileSync(configFilePath(), JSON.stringify(cfg, null, 2) + "\n", "utf8")
|
|
25
|
+
try {
|
|
26
|
+
fs.chmodSync(configFilePath(), 0o600)
|
|
27
|
+
} catch {
|
|
28
|
+
/* non critico */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function maskSecret(value) {
|
|
33
|
+
if (!value) return value
|
|
34
|
+
if (value.length <= 8) return "*".repeat(value.length)
|
|
35
|
+
return value.slice(0, 4) + "*".repeat(Math.max(4, value.length - 8)) + value.slice(-4)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ensureDir(p) {
|
|
39
|
+
fs.mkdirSync(p, { recursive: true })
|
|
40
|
+
return p
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function templatePath(name) {
|
|
44
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates", name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readTemplate(name) {
|
|
48
|
+
const p = templatePath(name)
|
|
49
|
+
if (!fs.existsSync(p)) {
|
|
50
|
+
throw new Error(`template mancante: ${name}`)
|
|
51
|
+
}
|
|
52
|
+
return fs.readFileSync(p, "utf8")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sostituisce i segnaposto `__KEY__` con i valori forniti.
|
|
57
|
+
*/
|
|
58
|
+
export function render(template, vars) {
|
|
59
|
+
let out = template
|
|
60
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
61
|
+
out = out.split(`__${key.toUpperCase()}__`).join(String(value))
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { stdin as input, stdout as output } from "node:process"
|
|
2
|
+
|
|
3
|
+
const color = (code, s) => `\x1b[${code}m${s}\x1b[0m`
|
|
4
|
+
export const c = {
|
|
5
|
+
dim: (s) => color(90, s),
|
|
6
|
+
cyan: (s) => color(36, s),
|
|
7
|
+
green: (s) => color(32, s),
|
|
8
|
+
yellow: (s) => color(33, s),
|
|
9
|
+
red: (s) => color(31, s),
|
|
10
|
+
bold: (s) => color(1, s),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function enableRaw() {
|
|
14
|
+
try {
|
|
15
|
+
input.setRawMode(true)
|
|
16
|
+
input.resume()
|
|
17
|
+
input.setEncoding("utf8")
|
|
18
|
+
return true
|
|
19
|
+
} catch {
|
|
20
|
+
return false // stdin non interattivo
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fallback senza TTY: legge lo stdin a righe e le mette in coda, così
|
|
26
|
+
* più domande successive consumano le risposte in ordine (utile per
|
|
27
|
+
* piping/test e ambiente completamente non interattivo).
|
|
28
|
+
*/
|
|
29
|
+
const fallbackQueue = []
|
|
30
|
+
|
|
31
|
+
function readLineFallback(done) {
|
|
32
|
+
const next = () => {
|
|
33
|
+
if (fallbackQueue.length) {
|
|
34
|
+
const v = fallbackQueue.shift()
|
|
35
|
+
return done(v)
|
|
36
|
+
}
|
|
37
|
+
const onData = (chunk) => {
|
|
38
|
+
const text = chunk.toString()
|
|
39
|
+
const parts = text.split(/\r\n|\r|\n/)
|
|
40
|
+
fallbackQueue.push(...parts)
|
|
41
|
+
input.removeListener("data", onData)
|
|
42
|
+
next()
|
|
43
|
+
}
|
|
44
|
+
input.on("data", onData)
|
|
45
|
+
input.resume()
|
|
46
|
+
}
|
|
47
|
+
next()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Legge una riga da tastiera. `masked` oscura la digitazione (per segreti); */
|
|
51
|
+
/** gestisce backspace e Ctrl+C. */
|
|
52
|
+
export function readLine({ masked = false, allowEmpty = false } = {}, done) {
|
|
53
|
+
if (!enableRaw()) {
|
|
54
|
+
readLineFallback(done)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let buf = ""
|
|
59
|
+
const onData = (chunk) => {
|
|
60
|
+
for (const ch of chunk) {
|
|
61
|
+
if (ch === "\r" || ch === "\n") {
|
|
62
|
+
if (!allowEmpty && buf === "") continue
|
|
63
|
+
input.removeListener("data", onData)
|
|
64
|
+
input.setRawMode(false)
|
|
65
|
+
input.pause()
|
|
66
|
+
output.write("\n")
|
|
67
|
+
return done(buf)
|
|
68
|
+
}
|
|
69
|
+
if (ch === "\u0003") {
|
|
70
|
+
input.setRawMode(false)
|
|
71
|
+
input.pause()
|
|
72
|
+
process.exit(130)
|
|
73
|
+
}
|
|
74
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
75
|
+
if (buf.length) {
|
|
76
|
+
buf = buf.slice(0, -1)
|
|
77
|
+
output.write("\b \b")
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
buf += ch
|
|
81
|
+
output.write(masked ? "*" : ch)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
input.on("data", onData)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Domanda a testo semplice con default opzionale. */
|
|
89
|
+
export async function ask(question, { defaultValue = "", hint = "", validate } = {}) {
|
|
90
|
+
const suffix = hint ? ` ${c.dim(`(${hint})`)}` : ""
|
|
91
|
+
const def = defaultValue ? c.dim(`[${defaultValue}]`) : ""
|
|
92
|
+
// eslint-disable-next-line no-constant-condition
|
|
93
|
+
for (;;) {
|
|
94
|
+
output.write(`${c.cyan("?")} ${question}${suffix} ${def}\n> `)
|
|
95
|
+
const raw = await new Promise((resolve) => readLine({}, resolve))
|
|
96
|
+
const value = raw.trim() === "" ? defaultValue : raw.trim()
|
|
97
|
+
if (validate && !validate(value)) {
|
|
98
|
+
console.log(c.yellow(" risposta non valida, riprova."))
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
return value
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Domanda sì/no con default indicato in maiuscolo. */
|
|
106
|
+
export async function confirm(question, defaultValue = false) {
|
|
107
|
+
const hint = defaultValue ? "S/n" : "s/N"
|
|
108
|
+
// eslint-disable-next-line no-constant-condition
|
|
109
|
+
for (;;) {
|
|
110
|
+
output.write(`${c.cyan("?")} ${question} ${c.dim(`(${hint})`)}\n> `)
|
|
111
|
+
const a = (await new Promise((resolve) => readLine({}, resolve))).trim().toLowerCase()
|
|
112
|
+
if (a === "") return defaultValue
|
|
113
|
+
if (/^(s|si|sì|y|yes)$/.test(a)) return true
|
|
114
|
+
if (/^(n|no)$/.test(a)) return false
|
|
115
|
+
console.log(c.yellow(" rispondi s o n."))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Selezione singola (indice numerico). */
|
|
120
|
+
export async function select(question, choices, { defaultValue = 0 } = {}) {
|
|
121
|
+
console.log(`\n${c.cyan("?")} ${question}`)
|
|
122
|
+
choices.forEach((choice, i) => {
|
|
123
|
+
console.log(` ${i + 1}. ${choice.label}`)
|
|
124
|
+
})
|
|
125
|
+
return choices[Number(await ask("Scelta (numero)", {
|
|
126
|
+
defaultValue: String(defaultValue + 1),
|
|
127
|
+
validate: (v) => /^\d+$/.test(v) && Number(v) >= 1 && Number(v) <= choices.length,
|
|
128
|
+
})) - 1]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Selezione multipla: numeri separati da virgola/spazio (es. "1,3,5"); invio = nessuna. */
|
|
132
|
+
export async function checkbox(question, choices, { defaultIndices = [] } = {}) {
|
|
133
|
+
console.log(`\n${c.cyan("?")} ${question} ${c.dim("(invio = nessuno; es. 1,3,5)")}`)
|
|
134
|
+
choices.forEach((choice, i) => {
|
|
135
|
+
const sel = defaultIndices.includes(i) ? "•" : " "
|
|
136
|
+
console.log(` ${sel} ${i + 1}. ${choice.label}`)
|
|
137
|
+
})
|
|
138
|
+
const answer = await ask("Seleziona (es. 1,3,5)", {
|
|
139
|
+
validate: (v) => {
|
|
140
|
+
const nums = v.split(/[\s,]+/).filter(Boolean)
|
|
141
|
+
if (!nums.length) return true // invio = nessuno (salta)
|
|
142
|
+
return nums.every((n) => /^\d+$/.test(n) && Number(n) >= 1 && Number(n) <= choices.length)
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
const nums = answer.split(/[\s,]+/).filter(Boolean)
|
|
146
|
+
if (!nums.length) return []
|
|
147
|
+
return [...new Set(nums.map((n) => Number(n) - 1))].map((i) => choices[i])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Input mascherato per segreti (API key ecc.). */
|
|
151
|
+
export async function secret(question, { allowEmpty = false } = {}) {
|
|
152
|
+
output.write(`${c.cyan("?")} ${question}\n> `)
|
|
153
|
+
return new Promise((resolve) => readLine({ masked: true, allowEmpty }, (v) => resolve(v)))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function closePrompts() {
|
|
157
|
+
input.removeAllListeners("data")
|
|
158
|
+
try {
|
|
159
|
+
input.setRawMode(false)
|
|
160
|
+
} catch {
|
|
161
|
+
/* ok */
|
|
162
|
+
}
|
|
163
|
+
input.pause()
|
|
164
|
+
}
|