opencode-wyvern 0.1.1 → 0.2.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 +45 -11
- package/bin/oc-setup.js +36 -8
- package/package.json +8 -4
- package/src/client.js +7 -3
- package/src/i18n.js +19 -0
- package/src/prompts.js +238 -26
- package/src/sections.js +130 -28
- package/src/server.js +127 -19
- package/src/setup.js +248 -69
- package/src/shell.js +6 -1
- package/src/uninstall.js +62 -23
- package/templates/command-commit.md +18 -0
- package/templates/command-explain.md +16 -0
- package/templates/command-omniroute-restart.md +19 -0
- package/templates/command-refactor.md +18 -0
- package/templates/command-review.md +17 -0
- package/templates/command-tests.md +16 -0
package/README.md
CHANGED
|
@@ -15,12 +15,18 @@ oc-setup
|
|
|
15
15
|
|
|
16
16
|
La procedura guidata:
|
|
17
17
|
|
|
18
|
-
1. **
|
|
19
|
-
|
|
18
|
+
1. **Scenario** — scegli subito il contesto: **Client + Server** (tutto),
|
|
19
|
+
**Solo client**, **Solo server** o **Personalizzato** (sezioni singole).
|
|
20
|
+
2. Con **Solo server** viene chiesto come applicare lo script: **via SSH da questo
|
|
21
|
+
client** o **in locale su questa macchina (localhost)** — in quest'ultimo caso
|
|
22
|
+
non serve host/porta né la chiave SSH.
|
|
23
|
+
3. **Moduli** — con uno scenario preimpostato le sezioni sono già selezionate;
|
|
24
|
+
con "Personalizzato" le scegli una a una.
|
|
25
|
+
4. **Connessione** — solo i dati necessari ai moduli scelti
|
|
20
26
|
(host obbligatorio solo se usi SSH/sezioni remote; coi soli client basta l'alias).
|
|
21
|
-
|
|
22
|
-
endpoint e chiavi
|
|
23
|
-
|
|
27
|
+
5. **Provider / plugin / claude-mem / comandi / MCP** — solo se le sezioni
|
|
28
|
+
corrispondenti sono attive; endpoint e chiavi vengono richieste al volo.
|
|
29
|
+
6. **Applicazione** — chiave SSH, alias, profili client e bootstrap (remoto o locale).
|
|
24
30
|
|
|
25
31
|
Le sezioni restano salvate in `~/.config/opencode-wyvern/config.json`
|
|
26
32
|
(solo dati non sensibili) e puoi attivarle/disattivarle in seguito.
|
|
@@ -36,6 +42,17 @@ oc-setup deactivate <sezione> disattiva un modulo
|
|
|
36
42
|
oc-setup print <sezione> stampa l'artefatto di una sezione
|
|
37
43
|
```
|
|
38
44
|
|
|
45
|
+
Nella procedura guidata: frecce/Spazio per navigare, `Invio` conferma,
|
|
46
|
+
`Esc`/`Ctrl+C` annullano, `q` esce. **CTRL+SHIFT+C** in un terminale vero copia
|
|
47
|
+
la selezione (gestito dal terminale, mai visto dal wizard) e **CTRL+SHIFT+V**
|
|
48
|
+
incolla: un token con la lettera `q` o con un `a-capo` finale viene accettato
|
|
49
|
+
senza chiudere il setup e senza caratteri spuri.
|
|
50
|
+
|
|
51
|
+
La **cartella remota di default** (del menu Connessione) parte già dalla home
|
|
52
|
+
dell'utente sul server: lasciala **vuota** per `~`, scrivi un percorso relativo
|
|
53
|
+
(es. `projects/foo` → `~/projects/foo`) o assoluto (`/srv/data`). Non viene mai
|
|
54
|
+
accodata al valore precedente.
|
|
55
|
+
|
|
39
56
|
## Moduli
|
|
40
57
|
|
|
41
58
|
| Sezione | Dove | Cosa fa |
|
|
@@ -44,7 +61,8 @@ oc-setup print <sezione> stampa l'artefatto di una sezione
|
|
|
44
61
|
| `client-pwsh` | locale | comandi `oc-*` nel profilo PowerShell (`oc-sessions`, `oc-go`, `oc-resume`, `oc-recap`, ...) |
|
|
45
62
|
| `client-bash` | locale | stesso blocco in `~/.bashrc` |
|
|
46
63
|
| `server` | remoto | controlla node/npm, installa opencode se manca, crea `~/.config/opencode` + `AGENTS.md` |
|
|
47
|
-
| `commands` | remoto |
|
|
64
|
+
| `commands` | remoto | comandi custom in `command/*.md` (`/baseline-ui`, `/omniroute-restart`, `/review`, ...) |
|
|
65
|
+
| `mcp` | remoto | server MCP preset (Figma Desktop, Figma Developer MCP); token in `.env` |
|
|
48
66
|
| `providers` | remoto | provider opencode in `opencode.json`; chiavi in `.env` |
|
|
49
67
|
| `plugins` | remoto | plugin npm installati nella dir config e listati in `opencode.json` |
|
|
50
68
|
| `claude-mem` | remoto | memoria: wrapper `plugins/claude-mem-plugin.js` |
|
|
@@ -52,16 +70,32 @@ oc-setup print <sezione> stampa l'artefatto di una sezione
|
|
|
52
70
|
## Provider presets
|
|
53
71
|
|
|
54
72
|
La sezione `providers` propone (checkbox): **GitHub Copilot**, **Google Gemini**,
|
|
55
|
-
**OpenCode Zen**, **Anthropic Claude**, **OpenAI / Codex
|
|
56
|
-
(gateway multi-modello con supporto `auto/*`)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
73
|
+
**OpenCode Zen**, **Anthropic Claude**, **OpenAI / Codex**, **OmniRoute**
|
|
74
|
+
(gateway multi-modello con supporto `auto/*`) e **Ollama / vLLM** (modelli locali
|
|
75
|
+
OpenAI-compatible: viene chiesto il base URL, default `http://127.0.0.1:11434/v1`).
|
|
76
|
+
Le chiavi vengono chieste al setup solo per i provider che le richiedono
|
|
77
|
+
(`GOOGLE_GENERATIVE_AI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) e
|
|
78
|
+
finiscono solo nel `.env` remoto. Per OmniRoute viene chiesto l'URL del gateway
|
|
79
|
+
(default `http://127.0.0.1:20128`).
|
|
60
80
|
|
|
61
81
|
Il modello default è scelto automaticamente (con OmniRoute: `omniroute/auto/best-coding`,
|
|
62
82
|
altrimenti il primo modello del primo provider attivo), insieme a `small_model`.
|
|
63
83
|
Un'opzione del setup aggiunge i tuning collaudati `tool_output` e `compaction`.
|
|
64
84
|
|
|
85
|
+
## Comandi custom
|
|
86
|
+
|
|
87
|
+
La sezione `commands` installa agent in `~/.config/opencode/command/*.md`:
|
|
88
|
+
`/baseline-ui` (baseline interfacce), `/omniroute-restart` (riavvia il container
|
|
89
|
+
Docker OmniRoute e attende che sia `healthy`), `/review`, `/refactor`, `/tests`,
|
|
90
|
+
`/commit`, `/explain`.
|
|
91
|
+
|
|
92
|
+
## MCP
|
|
93
|
+
|
|
94
|
+
La sezione `mcp` scrive il blocco `mcp` di `opencode.json` con i preset:
|
|
95
|
+
**Figma Desktop** (Dev Mode, `http://127.0.0.1:3845/mcp`, nessun segreto) e
|
|
96
|
+
**Figma Developer MCP** (stdio via `npx figma-developer-mcp`; il Figma personal
|
|
97
|
+
token finisce in `.env` sul server, mai in `config.json`).
|
|
98
|
+
|
|
65
99
|
## Client
|
|
66
100
|
|
|
67
101
|
I client espongono i comandi `oc-*` e mostrano le **sessioni delle ultime 24 ore**
|
package/bin/oc-setup.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { run, cmdStatus, cmdActivate, cmdDeactivate, cmdGenerate, cmdPrint, runPrintClient } from "../src/setup.js"
|
|
3
3
|
import { runUninstall } from "../src/uninstall.js"
|
|
4
|
+
import { ui } from "../src/i18n.js"
|
|
4
5
|
|
|
5
6
|
const argv = process.argv.slice(2)
|
|
6
7
|
|
|
7
|
-
const HELP = `oc-setup — OpenCode Wyvern
|
|
8
|
+
const HELP = ui(`oc-setup — OpenCode Wyvern
|
|
8
9
|
|
|
9
10
|
Setup modulare per OpenCode: installi tutto, attivi quello che vuoi.
|
|
10
11
|
Nessun dato privato è incluso nel pacchetto: host, utente, chiavi e
|
|
@@ -22,8 +23,35 @@ Uso:
|
|
|
22
23
|
oc-setup --help | -h questo aiuto
|
|
23
24
|
|
|
24
25
|
Sezioni:
|
|
25
|
-
ssh, client-pwsh, client-bash, server, commands, providers, plugins, claude-mem
|
|
26
|
-
|
|
26
|
+
ssh, client-pwsh, client-bash, server, commands, mcp, providers, plugins, claude-mem
|
|
27
|
+
|
|
28
|
+
Uscita dal wizard:
|
|
29
|
+
TUI: Esc / Ctrl+C Fallback (NON terminale): rispondi 'q'
|
|
30
|
+
Lingua: rilevata dalla locale di sistema; override con OC_LANG=it o OC_LANG=en
|
|
31
|
+
`, `oc-setup — OpenCode Wyvern
|
|
32
|
+
|
|
33
|
+
Modular setup for OpenCode: install everything, activate what you want.
|
|
34
|
+
No private data is included in the package: host, user, keys and API
|
|
35
|
+
keys are requested/created locally during execution.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
oc-setup starts the interactive wizard
|
|
39
|
+
oc-setup generate re-applies active sections from config
|
|
40
|
+
oc-setup uninstall complete uninstall (client, config, cache, SSH alias)
|
|
41
|
+
oc-setup status shows modules and status
|
|
42
|
+
oc-setup activate <section> activates a module
|
|
43
|
+
oc-setup deactivate <section> deactivates a module
|
|
44
|
+
oc-setup print <section> prints the artifact of a section
|
|
45
|
+
oc-setup --version | -v shows the version
|
|
46
|
+
oc-setup --help | -h this help
|
|
47
|
+
|
|
48
|
+
Sections:
|
|
49
|
+
ssh, client-pwsh, client-bash, server, commands, mcp, providers, plugins, claude-mem
|
|
50
|
+
|
|
51
|
+
Leaving the wizard:
|
|
52
|
+
TUI: Esc / Ctrl+C Fallback (NON terminal): answer 'q'
|
|
53
|
+
Language: detected from system locale; override with OC_LANG=it or OC_LANG=en
|
|
54
|
+
`)
|
|
27
55
|
|
|
28
56
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
29
57
|
console.log(HELP)
|
|
@@ -39,7 +67,7 @@ const printIdx = argv.indexOf("--print-client")
|
|
|
39
67
|
if (printIdx !== -1) {
|
|
40
68
|
const kind = argv[printIdx + 1]
|
|
41
69
|
if (kind !== "pwsh" && kind !== "bash") {
|
|
42
|
-
console.error("uso: oc-setup --print-client <pwsh|bash>")
|
|
70
|
+
console.error(ui("uso: oc-setup --print-client <pwsh|bash>", "usage: oc-setup --print-client <pwsh|bash>"))
|
|
43
71
|
process.exit(2)
|
|
44
72
|
}
|
|
45
73
|
await runPrintClient(kind)
|
|
@@ -53,11 +81,11 @@ try {
|
|
|
53
81
|
await cmdStatus()
|
|
54
82
|
break
|
|
55
83
|
case "activate":
|
|
56
|
-
if (!arg1) fail("uso: oc-setup activate <sezione>")
|
|
84
|
+
if (!arg1) fail(ui("uso: oc-setup activate <sezione>", "usage: oc-setup activate <section>"))
|
|
57
85
|
await cmdActivate(arg1)
|
|
58
86
|
break
|
|
59
87
|
case "deactivate":
|
|
60
|
-
if (!arg1) fail("uso: oc-setup deactivate <sezione>")
|
|
88
|
+
if (!arg1) fail(ui("uso: oc-setup deactivate <sezione>", "usage: oc-setup deactivate <section>"))
|
|
61
89
|
await cmdDeactivate(arg1)
|
|
62
90
|
break
|
|
63
91
|
case "generate":
|
|
@@ -67,14 +95,14 @@ try {
|
|
|
67
95
|
await runUninstall()
|
|
68
96
|
break
|
|
69
97
|
case "print":
|
|
70
|
-
if (!arg1) fail("uso: oc-setup print <sezione>")
|
|
98
|
+
if (!arg1) fail(ui("uso: oc-setup print <sezione>", "usage: oc-setup print <section>"))
|
|
71
99
|
await cmdPrint(arg1)
|
|
72
100
|
break
|
|
73
101
|
case undefined:
|
|
74
102
|
await run(argv)
|
|
75
103
|
break
|
|
76
104
|
default:
|
|
77
|
-
fail(
|
|
105
|
+
fail(ui("comando sconosciuto:", "unknown command:") + ` ${sub}`)
|
|
78
106
|
}
|
|
79
107
|
} catch (err) {
|
|
80
108
|
console.error(`\n[error] ${err && err.message ? err.message : err}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-wyvern",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"url": "https://github.com/Rhaegal222"
|
|
10
10
|
},
|
|
11
11
|
"engines": {
|
|
12
|
-
"node": ">=
|
|
12
|
+
"node": ">=20.12.0"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
15
|
"oc-setup": "bin/oc-setup.js"
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
"url": "git+https://github.com/Rhaegal222/opencode-wyvern.git"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"test": "node bin/oc-setup.js --help"
|
|
39
|
+
"test": "node bin/oc-setup.js --help",
|
|
40
|
+
"prepack": "node scripts/check-secrets.mjs"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@clack/prompts": "^1.8.0"
|
|
40
44
|
}
|
|
41
|
-
}
|
|
45
|
+
}
|
package/src/client.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
2
3
|
import crypto from "node:crypto"
|
|
3
|
-
import { render, readTemplate } from "./config.js"
|
|
4
|
+
import { render, readTemplate, ensureDir } from "./config.js"
|
|
4
5
|
import { pwshProfilePath, bashRcPath } from "./shell.js"
|
|
5
6
|
import { c } from "./prompts.js"
|
|
7
|
+
import { ui } from "./i18n.js"
|
|
6
8
|
|
|
7
9
|
export const MARKER = "# ---- OpenCode Wyvern (auto-generato) ----"
|
|
8
10
|
/** Marker storici: un blocco generato in passato va comunque rimosso. */
|
|
@@ -31,6 +33,7 @@ export function renderClient(kind, { server, host, user, dir }) {
|
|
|
31
33
|
|
|
32
34
|
/** Sostituisce/crea il blocco generato in un profilo, con backup. */
|
|
33
35
|
function upsertBlock(filePath, block, { dryRun = false } = {}) {
|
|
36
|
+
ensureDir(path.dirname(filePath)) // la dir del profilo può non esistere (machina nuova)
|
|
34
37
|
const existed = fs.existsSync(filePath)
|
|
35
38
|
let content = existed ? fs.readFileSync(filePath, "utf8") : ""
|
|
36
39
|
|
|
@@ -100,10 +103,11 @@ export async function installClient(cfg, { targets = ["pwsh", "bash"], onlyPrint
|
|
|
100
103
|
results.push(upsertBlock(target.path(), block))
|
|
101
104
|
}
|
|
102
105
|
|
|
103
|
-
console.log(c.green("\nClient installato:"))
|
|
106
|
+
console.log(c.green(`${ui("\nClient installato:", "\nClient installed:")}`))
|
|
104
107
|
for (const r of results) {
|
|
105
108
|
console.log(` - ${r.filePath}${r.backup ? ` (backup: ${r.backup})` : ""}`)
|
|
106
109
|
}
|
|
107
|
-
console.log(c.dim("\nRicarica il profilo con: . $PROFILE (PowerShell) / source ~/.bashrc (bash)"
|
|
110
|
+
console.log(c.dim(ui("\nRicarica il profilo con: . $PROFILE (PowerShell) / source ~/.bashrc (bash)",
|
|
111
|
+
"\nReload your profile with: . $PROFILE (PowerShell) / source ~/.bashrc (bash)")))
|
|
108
112
|
return { installed: results }
|
|
109
113
|
}
|
package/src/i18n.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localizzazione interfacce.
|
|
3
|
+
*
|
|
4
|
+
* Rileva la locale di sistema e rende coerente TUTTA la GUI:
|
|
5
|
+
* - locale che inizia con "it" → italiano
|
|
6
|
+
* - altrimenti → inglese
|
|
7
|
+
*
|
|
8
|
+
* Override: `OC_LANG=it` oppure `OC_LANG=en`.
|
|
9
|
+
*/
|
|
10
|
+
const raw = process.env.OC_LANG || process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || ""
|
|
11
|
+
const sys = raw || (Intl?.DateTimeFormat?.().resolvedOptions?.().locale ?? "") || "en"
|
|
12
|
+
|
|
13
|
+
export const LANG = /^it\b/i.test(sys) ? "it" : "en"
|
|
14
|
+
export const isIt = LANG === "it"
|
|
15
|
+
export const ui = (it, en) => (isIt ? it : en)
|
|
16
|
+
|
|
17
|
+
export const qHint = ui("q=esci", "q=quit")
|
|
18
|
+
export const qLegend = ui("q=Esci", "q=Exit")
|
|
19
|
+
export const cancelMsg = ui("annullato.", "cancelled.")
|
package/src/prompts.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { stdin as input, stdout as output } from "node:process"
|
|
1
|
+
import { stdin as input, stdout as output, stderr as stderrStream } from "node:process"
|
|
2
|
+
import { ui, isIt, qHint, qLegend, cancelMsg } from "./i18n.js"
|
|
2
3
|
|
|
3
4
|
const color = (code, s) => `\x1b[${code}m${s}\x1b[0m`
|
|
4
5
|
export const c = {
|
|
@@ -10,6 +11,131 @@ export const c = {
|
|
|
10
11
|
bold: (s) => color(1, s),
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
// --------------------------------------------------------------------------
|
|
15
|
+
// Backend TUI nativo Node (@clack/prompts) — cross-platform.
|
|
16
|
+
// Quando stdin/stdout/stderr sono un terminale reale, i prompt diventano
|
|
17
|
+
// dialoghi interattivi identici su Windows/pwsh, WSL, Linux e macOS
|
|
18
|
+
// (frecce, Spazio, invio, Esc/Ctrl+C per annullare, exit code 0).
|
|
19
|
+
// Negli altri casi (pipe, CI, non-interattivo) si usa il fallback a righe.
|
|
20
|
+
// `secret` mantiene l'input mascherato (via clack password o raw mode).
|
|
21
|
+
//
|
|
22
|
+
// Il modulo @clack/prompts è importato dinamicamente: se manca (repo senza
|
|
23
|
+
// `npm install`) si cade automaticamente sul fallback a righe.
|
|
24
|
+
// --------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
let cpModule = null
|
|
27
|
+
let cpTried = false
|
|
28
|
+
|
|
29
|
+
async function clack() {
|
|
30
|
+
if (!cpTried) {
|
|
31
|
+
cpTried = true
|
|
32
|
+
try {
|
|
33
|
+
cpModule = await import("@clack/prompts")
|
|
34
|
+
} catch {
|
|
35
|
+
cpModule = null // dipendenza non installata: fallback a righe
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return cpModule
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const realTty = () => !!input.isTTY && !!output.isTTY && !!stderrStream.isTTY
|
|
42
|
+
|
|
43
|
+
// --------------------------------------------------------------------------
|
|
44
|
+
// Footer (@clack/prompts): localizziamo gli array di istruzioni esportati
|
|
45
|
+
// (MULTISELECT_INSTRUCTIONS/SELECT_INSTRUCTIONS) PRIMA che il prompt li usi.
|
|
46
|
+
// Non intercettiamo mai output.write: clack salva il frame reso per calcolare
|
|
47
|
+
// cursore/cancellazione, quindi un contenuto modificato dopo lo write lo
|
|
48
|
+
// desincronizza (le righe si duplicano senza refresh).
|
|
49
|
+
// --------------------------------------------------------------------------
|
|
50
|
+
function applyInstructions(cp) {
|
|
51
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
52
|
+
const multi = [
|
|
53
|
+
`${dim("\u2191/\u2193")} ${ui("per navigare", "to navigate")}`,
|
|
54
|
+
`${dim(ui("Spazio:", "Space:"))} ${ui("seleziona", "select")}`,
|
|
55
|
+
`${dim(ui("Invio:", "Enter:"))} ${ui("conferma", "confirm")}`,
|
|
56
|
+
`${dim("q:")} ${qLegend}`,
|
|
57
|
+
]
|
|
58
|
+
const sel = [
|
|
59
|
+
`${dim("\u2191/\u2193")} ${ui("per navigare", "to navigate")}`,
|
|
60
|
+
`${dim(ui("Invio:", "Enter:"))} ${ui("conferma", "confirm")}`,
|
|
61
|
+
`${dim("q:")} ${qLegend}`,
|
|
62
|
+
]
|
|
63
|
+
if (Array.isArray(cp.MULTISELECT_INSTRUCTIONS)) cp.MULTISELECT_INSTRUCTIONS.splice(0, cp.MULTISELECT_INSTRUCTIONS.length, ...multi)
|
|
64
|
+
if (Array.isArray(cp.SELECT_INSTRUCTIONS)) cp.SELECT_INSTRUCTIONS.splice(0, cp.SELECT_INSTRUCTIONS.length, ...sel)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function tuiOn() {
|
|
68
|
+
const cp = await clack()
|
|
69
|
+
const ok = !!(cp && realTty())
|
|
70
|
+
if (ok) applyInstructions(cp)
|
|
71
|
+
return ok
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --------------------------------------------------------------------------
|
|
75
|
+
// Uscita con "q" anche in TUI: @clack/prompts supporta `signal` (abort → il
|
|
76
|
+
// prompt si risolve in CANCEL_SYMBOL). Ascoltiamo il keypress 'q' su stdin e
|
|
77
|
+
// abortiamo il segnale condiviso; Esc/Ctrl+C restano gestiti da clack.
|
|
78
|
+
// NB: un incolla (CTRL+SHIFT+V) arriva come raffica di keypress: se il testo
|
|
79
|
+
// contiene una 'q' non deve chiudere il wizard. Riconosciamo la raffica dalla
|
|
80
|
+
// frequenza dei caratteri e mettiamo una tolleranza per la prima lettera.
|
|
81
|
+
// --------------------------------------------------------------------------
|
|
82
|
+
const quitKey = new AbortController()
|
|
83
|
+
let quitArmed = false
|
|
84
|
+
let lastKeyTs = 0
|
|
85
|
+
let burstLen = 0
|
|
86
|
+
let quitTimer = null
|
|
87
|
+
|
|
88
|
+
function cancelQuit() {
|
|
89
|
+
if (quitTimer) {
|
|
90
|
+
clearTimeout(quitTimer)
|
|
91
|
+
quitTimer = null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function armQuitKey() {
|
|
96
|
+
if (quitArmed) return
|
|
97
|
+
quitArmed = true
|
|
98
|
+
try {
|
|
99
|
+
input.on("keypress", (str) => {
|
|
100
|
+
const now = Date.now()
|
|
101
|
+
burstLen = now - lastKeyTs > 400 ? 1 : burstLen + 1
|
|
102
|
+
lastKeyTs = now
|
|
103
|
+
|
|
104
|
+
// qualunque altro carattere cancella un'eventuale 'q' in sospeso
|
|
105
|
+
if (str !== "q" && str !== "Q") {
|
|
106
|
+
cancelQuit()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
// 'q' nel mezzo di una raffica (incolla / tasto ripetuto): ignora
|
|
110
|
+
if (burstLen > 1) {
|
|
111
|
+
cancelQuit()
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
// 'q' isolata: aspetta ~200ms: se arrivano altri caratteri era un
|
|
115
|
+
// incolla e la prima lettera coincideva con la "q", altrimenti esce.
|
|
116
|
+
if (quitTimer) return
|
|
117
|
+
quitTimer = setTimeout(() => {
|
|
118
|
+
quitTimer = null
|
|
119
|
+
quitKey.abort()
|
|
120
|
+
}, 200)
|
|
121
|
+
})
|
|
122
|
+
} catch {
|
|
123
|
+
/* stdin senza keypress: resta Esc/Ctrl+C */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Opzioni clack con signal condiviso e suffisso "q=esci/q=quit" nel messaggio. */
|
|
128
|
+
function clackOpts(message, opts = {}) {
|
|
129
|
+
armQuitKey()
|
|
130
|
+
return { ...opts, signal: quitKey.signal, message: `${message} ${color(90, `(${qHint})`)}` }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function abortSetup() {
|
|
134
|
+
closePrompts()
|
|
135
|
+
console.log(c.yellow(`\n ${cancelMsg}`))
|
|
136
|
+
process.exit(0)
|
|
137
|
+
}
|
|
138
|
+
|
|
13
139
|
function enableRaw() {
|
|
14
140
|
try {
|
|
15
141
|
input.setRawMode(true)
|
|
@@ -48,7 +174,7 @@ function readLineFallback(done) {
|
|
|
48
174
|
}
|
|
49
175
|
|
|
50
176
|
/** Legge una riga da tastiera. `masked` oscura la digitazione (per segreti); */
|
|
51
|
-
/** gestisce backspace e
|
|
177
|
+
/** gestisce backspace, Ctrl+C (annullamento pulito) e incolla (CTRL+SHIFT+V). */
|
|
52
178
|
export function readLine({ masked = false, allowEmpty = false } = {}, done) {
|
|
53
179
|
if (!enableRaw()) {
|
|
54
180
|
readLineFallback(done)
|
|
@@ -57,6 +183,8 @@ export function readLine({ masked = false, allowEmpty = false } = {}, done) {
|
|
|
57
183
|
|
|
58
184
|
let buf = ""
|
|
59
185
|
const onData = (chunk) => {
|
|
186
|
+
// marcatori "bracketed paste" del terminale: se presenti vanno ignorati
|
|
187
|
+
chunk = chunk.replace(/\x1b\[200~|\x1b\[201~/g, "")
|
|
60
188
|
for (const ch of chunk) {
|
|
61
189
|
if (ch === "\r" || ch === "\n") {
|
|
62
190
|
if (!allowEmpty && buf === "") continue
|
|
@@ -64,12 +192,13 @@ export function readLine({ masked = false, allowEmpty = false } = {}, done) {
|
|
|
64
192
|
input.setRawMode(false)
|
|
65
193
|
input.pause()
|
|
66
194
|
output.write("\n")
|
|
67
|
-
|
|
195
|
+
// Windows e alcuni editor incollano con \r\n finale: scarta il \r
|
|
196
|
+
return done(buf.replace(/\r+$/u, ""))
|
|
68
197
|
}
|
|
69
|
-
if (ch === "\u0003") {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
198
|
+
if (ch === "\u0003" || ch === "\u0004") {
|
|
199
|
+
// Ctrl+C / Ctrl+D: annullamento pulito (uguale a clack isCancel),
|
|
200
|
+
// MAI un'uscita "muta" che lascia il terminale sporco.
|
|
201
|
+
abortSetup()
|
|
73
202
|
}
|
|
74
203
|
if (ch === "\u007f" || ch === "\b") {
|
|
75
204
|
if (buf.length) {
|
|
@@ -85,57 +214,61 @@ export function readLine({ masked = false, allowEmpty = false } = {}, done) {
|
|
|
85
214
|
input.on("data", onData)
|
|
86
215
|
}
|
|
87
216
|
|
|
88
|
-
|
|
89
|
-
|
|
217
|
+
// --------------------------------------------------------------------------
|
|
218
|
+
// Implementazioni classiche (fallback a righe, stesse API precedenti).
|
|
219
|
+
// --------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
async function askClassic(question, { defaultValue = "", hint = "", validate } = {}) {
|
|
90
222
|
const suffix = hint ? ` ${c.dim(`(${hint})`)}` : ""
|
|
91
223
|
const def = defaultValue ? c.dim(`[${defaultValue}]`) : ""
|
|
224
|
+
const err = ui("risposta non valida, riprova.", "invalid answer, try again.")
|
|
92
225
|
// eslint-disable-next-line no-constant-condition
|
|
93
226
|
for (;;) {
|
|
94
|
-
output.write(`${c.cyan("?")} ${question}${suffix} ${def}\n> `)
|
|
227
|
+
output.write(`${c.cyan("?")} ${question}${suffix} ${def} ${c.dim(`(${qHint})`)}\n> `)
|
|
95
228
|
const raw = await new Promise((resolve) => readLine({}, resolve))
|
|
229
|
+
if (/^q$/i.test(raw.trim())) abortSetup()
|
|
96
230
|
const value = raw.trim() === "" ? defaultValue : raw.trim()
|
|
97
231
|
if (validate && !validate(value)) {
|
|
98
|
-
console.log(c.yellow(
|
|
232
|
+
console.log(c.yellow(` ${err}`))
|
|
99
233
|
continue
|
|
100
234
|
}
|
|
101
235
|
return value
|
|
102
236
|
}
|
|
103
237
|
}
|
|
104
238
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
239
|
+
async function confirmClassic(question, defaultValue = false) {
|
|
240
|
+
const hint = defaultValue ? ui("S/n", "Y/n") : ui("s/N", "y/N")
|
|
241
|
+
const err = ui("rispondi s o n.", "answer y or n.")
|
|
108
242
|
// eslint-disable-next-line no-constant-condition
|
|
109
243
|
for (;;) {
|
|
110
|
-
output.write(`${c.cyan("?")} ${question} ${c.dim(`(${hint})`)}\n> `)
|
|
244
|
+
output.write(`${c.cyan("?")} ${question} ${c.dim(`(${hint}, ${qHint})`)}\n> `)
|
|
111
245
|
const a = (await new Promise((resolve) => readLine({}, resolve))).trim().toLowerCase()
|
|
246
|
+
if (a === "q") abortSetup()
|
|
112
247
|
if (a === "") return defaultValue
|
|
113
248
|
if (/^(s|si|sì|y|yes)$/.test(a)) return true
|
|
114
249
|
if (/^(n|no)$/.test(a)) return false
|
|
115
|
-
console.log(c.yellow(
|
|
250
|
+
console.log(c.yellow(` ${err}`))
|
|
116
251
|
}
|
|
117
252
|
}
|
|
118
253
|
|
|
119
|
-
|
|
120
|
-
export async function select(question, choices, { defaultValue = 0 } = {}) {
|
|
254
|
+
async function selectClassic(question, choices, { defaultValue = 0 } = {}) {
|
|
121
255
|
console.log(`\n${c.cyan("?")} ${question}`)
|
|
122
256
|
choices.forEach((choice, i) => {
|
|
123
257
|
console.log(` ${i + 1}. ${choice.label}`)
|
|
124
258
|
})
|
|
125
|
-
return choices[Number(await
|
|
259
|
+
return choices[Number(await askClassic(ui("Scelta (numero)", "Choice (number)"), {
|
|
126
260
|
defaultValue: String(defaultValue + 1),
|
|
127
261
|
validate: (v) => /^\d+$/.test(v) && Number(v) >= 1 && Number(v) <= choices.length,
|
|
128
262
|
})) - 1]
|
|
129
263
|
}
|
|
130
264
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
console.log(`\n${c.cyan("?")} ${question} ${c.dim("(invio = nessuno; es. 1,3,5)")}`)
|
|
265
|
+
async function checkboxClassic(question, choices, { defaultIndices = [] } = {}) {
|
|
266
|
+
console.log(`\n${c.cyan("?")} ${question} ${c.dim(ui("(invio = nessuno; es. 1,3,5)", "(blank = none; e.g. 1,3,5)"))}`)
|
|
134
267
|
choices.forEach((choice, i) => {
|
|
135
268
|
const sel = defaultIndices.includes(i) ? "•" : " "
|
|
136
269
|
console.log(` ${sel} ${i + 1}. ${choice.label}`)
|
|
137
270
|
})
|
|
138
|
-
const answer = await
|
|
271
|
+
const answer = await askClassic(ui("Seleziona (es. 1,3,5)", "Select (e.g. 1,3,5)"), {
|
|
139
272
|
validate: (v) => {
|
|
140
273
|
const nums = v.split(/[\s,]+/).filter(Boolean)
|
|
141
274
|
if (!nums.length) return true // invio = nessuno (salta)
|
|
@@ -147,10 +280,89 @@ export async function checkbox(question, choices, { defaultIndices = [] } = {})
|
|
|
147
280
|
return [...new Set(nums.map((n) => Number(n) - 1))].map((i) => choices[i])
|
|
148
281
|
}
|
|
149
282
|
|
|
283
|
+
async function secretClassic(question, { allowEmpty = false } = {}) {
|
|
284
|
+
output.write(`${c.cyan("?")} ${question} ${c.dim(`(${qHint})`)}\n> `)
|
|
285
|
+
return new Promise((resolve) => readLine({ masked: true, allowEmpty }, (v) => {
|
|
286
|
+
if (/^q$/i.test(v.trim())) abortSetup()
|
|
287
|
+
resolve(v)
|
|
288
|
+
}))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --------------------------------------------------------------------------
|
|
292
|
+
// API pubbliche: TUI @clack/prompts (TTY) con fallback classico.
|
|
293
|
+
// --------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/** Domanda a testo semplice con default opzionale (clack text). */
|
|
296
|
+
export async function ask(question, { defaultValue = "", hint = "", validate } = {}) {
|
|
297
|
+
if (!(await tuiOn())) return askClassic(question, { defaultValue, hint, validate })
|
|
298
|
+
const cp = await clack()
|
|
299
|
+
const message = hint ? `${question} (${hint})` : question
|
|
300
|
+
const errMsg = ui("risposta non valida, riprova.", "invalid answer, try again.")
|
|
301
|
+
// eslint-disable-next-line no-constant-condition
|
|
302
|
+
for (;;) {
|
|
303
|
+
const raw = await cp.text(clackOpts(message, {
|
|
304
|
+
initialValue: defaultValue || undefined,
|
|
305
|
+
validate: (val) => {
|
|
306
|
+
const value = String(val ?? "").trim() === "" ? defaultValue : String(val).trim()
|
|
307
|
+
return validate && !validate(value) ? errMsg : undefined
|
|
308
|
+
},
|
|
309
|
+
}))
|
|
310
|
+
if (cp.isCancel(raw)) abortSetup()
|
|
311
|
+
const value = String(raw ?? "").trim() === "" ? defaultValue : String(raw).trim()
|
|
312
|
+
if (validate && !validate(value)) {
|
|
313
|
+
cp.log.error(errMsg)
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
return value
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Domanda sì/no (clack confirm). */
|
|
321
|
+
export async function confirm(question, defaultValue = false) {
|
|
322
|
+
if (!(await tuiOn())) return confirmClassic(question, defaultValue)
|
|
323
|
+
const cp = await clack()
|
|
324
|
+
const v = await cp.confirm(clackOpts(question, {
|
|
325
|
+
initialValue: !!defaultValue,
|
|
326
|
+
active: isIt ? "sì" : "Yes",
|
|
327
|
+
inactive: isIt ? "no" : "No",
|
|
328
|
+
}))
|
|
329
|
+
if (cp.isCancel(v)) abortSetup()
|
|
330
|
+
return v === true
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Selezione singola (clack select, frecce). */
|
|
334
|
+
export async function select(question, choices, { defaultValue = 0 } = {}) {
|
|
335
|
+
if (!(await tuiOn())) return selectClassic(question, choices, { defaultValue })
|
|
336
|
+
const cp = await clack()
|
|
337
|
+
const idx = await cp.select(clackOpts(question, {
|
|
338
|
+
options: choices.map((ch, i) => ({ value: i, label: ch.label })),
|
|
339
|
+
initialValue: defaultValue ?? 0,
|
|
340
|
+
}))
|
|
341
|
+
if (cp.isCancel(idx)) abortSetup()
|
|
342
|
+
return choices[idx]
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Selezione multipla (clack multiselect, frecce + Spazio). */
|
|
346
|
+
export async function checkbox(question, choices, { defaultIndices = [] } = {}) {
|
|
347
|
+
if (!(await tuiOn())) return checkboxClassic(question, choices, { defaultIndices })
|
|
348
|
+
const cp = await clack()
|
|
349
|
+
const options = choices.map((ch) => ({ value: ch.value, label: ch.label }))
|
|
350
|
+
const initialValues = defaultIndices.map((i) => choices[i]?.value).filter((v) => v !== undefined)
|
|
351
|
+
const vals = await cp.multiselect(clackOpts(question, { options, initialValues, required: false }))
|
|
352
|
+
if (cp.isCancel(vals)) abortSetup()
|
|
353
|
+
const sel = new Set(vals)
|
|
354
|
+
return choices.filter((ch) => sel.has(ch.value))
|
|
355
|
+
}
|
|
356
|
+
|
|
150
357
|
/** Input mascherato per segreti (API key ecc.). */
|
|
151
358
|
export async function secret(question, { allowEmpty = false } = {}) {
|
|
152
|
-
|
|
153
|
-
|
|
359
|
+
if (!(await tuiOn())) return secretClassic(question, { allowEmpty })
|
|
360
|
+
const cp = await clack()
|
|
361
|
+
const v = await cp.password(clackOpts(question, {
|
|
362
|
+
validate: allowEmpty ? () => undefined : undefined,
|
|
363
|
+
}))
|
|
364
|
+
if (cp.isCancel(v)) abortSetup()
|
|
365
|
+
return String(v ?? "")
|
|
154
366
|
}
|
|
155
367
|
|
|
156
368
|
export function closePrompts() {
|
|
@@ -161,4 +373,4 @@ export function closePrompts() {
|
|
|
161
373
|
/* ok */
|
|
162
374
|
}
|
|
163
375
|
input.pause()
|
|
164
|
-
}
|
|
376
|
+
}
|