@owear/cli 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,18 @@
5
5
  <head>
6
6
  <meta charset="UTF-8" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
- <title>Desinstalar — __APP_NAME__</title>
8
+ <title>Desinstalar · __APP_NAME__</title>
9
9
  </head>
10
10
  <body>
11
11
  <header class="titlebar">
12
12
  <div class="titlebar__drag" data-ow-drag>
13
+ <span class="brand__mark" aria-hidden="true"></span>
13
14
  <span class="brand__name">Desinstalador · __APP_NAME__</span>
14
15
  </div>
15
16
  <div class="titlebar__controls">
17
+ <button id="win-min" class="winbtn" title="Minimizar" aria-label="Minimizar">
18
+ <svg viewBox="0 0 12 12" width="12" height="12"><path d="M2 6h8" /></svg>
19
+ </button>
16
20
  <button id="win-close" class="winbtn winbtn--close" title="Cerrar" aria-label="Cerrar">
17
21
  <svg viewBox="0 0 12 12" width="12" height="12"><path d="M2.5 2.5l7 7M9.5 2.5l-7 7" /></svg>
18
22
  </button>
@@ -21,28 +25,30 @@
21
25
 
22
26
  <main class="page">
23
27
  <section class="hero">
24
- <span class="eyebrow">Owear Uninstaller</span>
25
28
  <h1>Desinstalar __APP_NAME__</h1>
26
29
  <p class="lead" id="summary">Localizando la instalación…</p>
27
30
  </section>
28
31
 
29
- <section class="card">
30
- <header class="card__head"><h2>Instalación</h2></header>
31
- <div class="row">
32
- <input id="dir" class="input" type="text" spellcheck="false" />
33
- <button id="pick" class="btn">Elegir…</button>
34
- </div>
35
- <div class="row">
36
- <label class="opt"><input type="checkbox" id="keepData" checked /> Conservar mis datos</label>
37
- </div>
32
+ <section class="board">
33
+ <article class="card">
34
+ <header class="card__head"><h2>Instalación</h2></header>
35
+ <div class="row">
36
+ <input id="dir" class="field" type="text" spellcheck="false" aria-label="Directorio de instalación" />
37
+ <button id="pick" class="btn">Elegir…</button>
38
+ </div>
39
+ <div class="row">
40
+ <label class="opt"><input type="checkbox" id="keepData" checked /> Conservar mis datos</label>
41
+ </div>
42
+ </article>
38
43
  </section>
39
-
40
- <footer class="actions">
41
- <span id="status" class="status"></span>
42
- <button id="uninstall" class="btn btn--primary" disabled>Desinstalar</button>
43
- </footer>
44
44
  </main>
45
45
 
46
+ <footer class="foot">
47
+ <span id="status" class="status"></span>
48
+ <span class="foot__spacer"></span>
49
+ <button id="uninstall" class="btn btn--primary" disabled>Desinstalar</button>
50
+ </footer>
51
+
46
52
  <script type="module" src="/src/uninstaller.ts"></script>
47
53
  </body>
48
54
  </html>
@@ -2,21 +2,35 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  //
4
4
  // src/uninstaller.ts — UI del desinstalador (vanilla). Mismo bridge, pero
5
- // invoca `installer.uninstall`.
5
+ // invoca `installer.uninstall`. Detecta la instalación por el REGISTRO de Owear
6
+ // (no hace falta escribir la ruta ni usar módulos .owm).
7
+ // Estilos: tokens del Starter (ver ../../src/style.css).
6
8
 
7
9
  import '../../src/style.css'
8
10
 
9
- declare global {
10
- interface Window {
11
- __owWindowId: number
12
- ow: { invoke<T = unknown>(module: string, fn: string, ...args: unknown[]): Promise<T> }
13
- }
11
+ interface OwBridge {
12
+ invoke<T = unknown>(module: string, fn: string, ...args: unknown[]): Promise<T>
14
13
  }
14
+ const ow = (window as unknown as { ow: OwBridge }).ow
15
+ const wid = (window as unknown as { __owWindowId: number }).__owWindowId
15
16
 
16
17
  const $ = <T extends HTMLElement = HTMLElement>(sel: string): T =>
17
18
  document.querySelector(sel) as T
18
19
  const invoke = <T = unknown>(module: string, fn: string, ...args: unknown[]): Promise<T> =>
19
- window.ow.invoke(module, fn, ...args) as Promise<T>
20
+ ow.invoke<T>(module, fn, ...args)
21
+
22
+ interface StateResult {
23
+ installed: boolean
24
+ version?: string
25
+ app?: string
26
+ dir?: string
27
+ }
28
+ interface Info {
29
+ appName?: string
30
+ version?: string
31
+ dir?: string
32
+ installed?: boolean
33
+ }
20
34
 
21
35
  function setStatus(msg: string, kind: 'ok' | 'err' | '' = ''): void {
22
36
  const el = $('#status')
@@ -25,25 +39,37 @@ function setStatus(msg: string, kind: 'ok' | 'err' | '' = ''): void {
25
39
  }
26
40
 
27
41
  async function boot(): Promise<void> {
28
- $('#win-close').addEventListener('click', () => void invoke('ow-window', 'close', window.__owWindowId))
42
+ $('#win-min').addEventListener('click', () => void invoke('ow-window', 'minimize', wid))
43
+ $('#win-close').addEventListener('click', () => void invoke('ow-window', 'close', wid))
29
44
 
30
- const info = await invoke<{ appName?: string; dir?: string }>('installer', 'info').catch(() => ({}))
31
- const st = await invoke<{ installed: boolean; version?: string; app?: string }>(
32
- 'installer',
33
- 'state',
34
- { dir: info.dir ?? '' },
35
- ).catch(() => ({ installed: false }))
45
+ const info = await invoke<Info>('installer', 'info').catch(() => ({}))
46
+ const st = await invoke<StateResult>('installer', 'state', {}).catch(() => ({
47
+ installed: false,
48
+ }))
49
+ const name = st.app ?? info.appName ?? 'La app'
50
+ const dir = st.dir ?? info.dir ?? ''
36
51
 
37
- $('#summary').textContent = st.installed
38
- ? `${st.app ?? info.appName ?? 'La app'} ${st.version ?? ''} está instalada.`
39
- : 'No se detectó una instalación en el directorio por defecto; elígelo.'
40
- ;($('#dir') as HTMLInputElement).value = info.dir ?? ''
52
+ if (st.installed) {
53
+ const v = st.version ?? info.version ?? ''
54
+ $('#summary').textContent = `${name}${v ? ' ' + v : ''} está instalada${
55
+ dir ? ' en ' + dir : ''
56
+ }.`
57
+ } else {
58
+ $('#summary').textContent =
59
+ 'No se detectó ninguna instalación registrada. Elige la carpeta donde se instaló.'
60
+ }
61
+ ;($('#dir') as HTMLInputElement).value = dir
41
62
  $('#uninstall').removeAttribute('disabled')
42
63
  }
43
64
 
44
65
  async function pickDir(): Promise<void> {
66
+ const current = ($('#dir') as HTMLInputElement).value.trim()
45
67
  try {
46
- const dir = await invoke<string | null>('dialog', 'open', 'openDirectory', 'Elegir carpeta')
68
+ // Selector NATIVO del builtin installer (no usa el módulo `dialog`).
69
+ const dir = await invoke<string | null>('installer', 'chooseDir', {
70
+ title: 'Elegir carpeta de instalación',
71
+ defaultPath: current,
72
+ })
47
73
  if (dir) ($('#dir') as HTMLInputElement).value = dir
48
74
  } catch {
49
75
  /* cancelado */
@@ -52,19 +78,18 @@ async function pickDir(): Promise<void> {
52
78
 
53
79
  async function doUninstall(): Promise<void> {
54
80
  const dir = ($('#dir') as HTMLInputElement).value.trim()
55
- if (!dir) {
56
- setStatus('Elige la carpeta de instalación', 'err')
57
- return
58
- }
81
+ const keepData = ($('#keepData') as HTMLInputElement).checked
59
82
  $('#uninstall').setAttribute('disabled', 'true')
60
83
  setStatus('Desinstalando…')
61
84
  try {
62
- const res = await invoke<{ removed: number }>('installer', 'uninstall', {
63
- dir,
64
- keepData: ($('#keepData') as HTMLInputElement).checked,
65
- })
85
+ // Si no hay dir, el builtin usa el registrado.
86
+ const res = await invoke<{ removed: number }>(
87
+ 'installer',
88
+ 'uninstall',
89
+ dir ? { dir, keepData } : { keepData },
90
+ )
66
91
  setStatus(`Eliminados ${res.removed} ficheros.`, 'ok')
67
- setTimeout(() => void invoke('ow-window', 'close', window.__owWindowId), 900)
92
+ setTimeout(() => void invoke('ow-window', 'close', wid), 900)
68
93
  } catch (e) {
69
94
  setStatus(e instanceof Error ? e.message : String(e), 'err')
70
95
  $('#uninstall').removeAttribute('disabled')
@@ -44,6 +44,7 @@ const payload = arg('--payload')
44
44
  const bridge = arg('--bridge')
45
45
  const meta = arg('--meta')
46
46
  const modules = arg('--modules')
47
+ const icon = arg('--icon')
47
48
  const name = arg('--name', 'app')
48
49
 
49
50
  if (!kernel || !out || !ui) die('uso: --kernel <owear> --out <bin> --ui <distUI> [--payload <dir>]')
@@ -62,6 +63,10 @@ if (payload && fs.existsSync(payload)) copyInto(payload, 'payload')
62
63
  if (bridge && fs.existsSync(bridge)) fs.copyFileSync(bridge, path.join(stage, 'bridge.json'))
63
64
  if (meta && fs.existsSync(meta)) fs.copyFileSync(meta, path.join(stage, 'installer.json'))
64
65
  if (modules && fs.existsSync(modules)) copyInto(modules, 'modules')
66
+ // icono de la app (para el .desktop / acceso directo del instalado)
67
+ if (icon && fs.existsSync(icon)) {
68
+ fs.copyFileSync(icon, path.join(stage, 'icon' + path.extname(icon)))
69
+ }
65
70
 
66
71
  // manifiesto mínimo del payload del instalador (informativo)
67
72
  fs.writeFileSync(
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // Copyright 2026 Owear Contributors
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ //
5
+ // tools/owear-sign.mjs — firma de artefactos de Owear.
6
+ //
7
+ // Dos mecanismos, combinables:
8
+ //
9
+ // 1. Authenticode (Windows PE, .exe/.msi): firma del binario con un
10
+ // certificado (PFX/P12). Requiere `osslsigncode` (Linux/macOS) o
11
+ // `signtool` (Windows). Da confianza de **editor** ante el SO
12
+ // (SmartScreen, "Unknown publisher").
13
+ //
14
+ // 2. Ed25519 desprendida (cualquier plataforma): escribe `<file>.sig` con la
15
+ // firma del fichero completo. La clave pública (misma que el updater)
16
+ // permite verificar el artefacto de forma independiente del feed.
17
+ //
18
+ // Nunca rompe un build: si no hay material de firma o falta la herramienta,
19
+ // omite con un aviso (salvo `--require`, que lo convierte en error).
20
+ //
21
+ // Uso:
22
+ // node tools/owear-sign.mjs --file release/MiApp \
23
+ // [--ed25519-key owear-signing.pem] \
24
+ // [--pfx cert.p12 --pfx-password-env OW_PFX_PASS] \
25
+ // [--timestamp http://timestamp.digicert.com] \
26
+ // [--name "Mi App" --url https://miapp.dev] \
27
+ // [--require] [--dry-run]
28
+ //
29
+ import * as fs from 'node:fs'
30
+ import * as path from 'node:path'
31
+ import * as crypto from 'node:crypto'
32
+ import { spawnSync } from 'node:child_process'
33
+ import { fileURLToPath } from 'node:url'
34
+
35
+ const C = { reset: '\x1b[0m', dim: '\x1b[2m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m' }
36
+ const info = (m) => console.log(`${C.dim}[owear-sign]${C.reset} ${m}`)
37
+ const warn = (m) => console.warn(`${C.yellow}[owear-sign] ⚠${C.reset} ${m}`)
38
+ const fail = (m) => console.error(`${C.red}[owear-sign] ✗${C.reset} ${m}`)
39
+
40
+ const isPE = (file) => {
41
+ try {
42
+ const fd = fs.openSync(file, 'r')
43
+ const b = Buffer.alloc(2)
44
+ fs.readSync(fd, b, 0, 2, 0)
45
+ fs.closeSync(fd)
46
+ return b[0] === 0x4d && b[1] === 0x5a // "MZ"
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ const PE_EXT = new Set(['.exe', '.dll', '.msi', '.sys', '.ocx', '.scr'])
53
+
54
+ /** ¿Es un PE/MSI firmable con Authenticode? (por extensión o cabecera MZ). */
55
+ export function isAuthenticodeTarget(file) {
56
+ if (PE_EXT.has(path.extname(file).toLowerCase())) return true
57
+ return isPE(file)
58
+ }
59
+
60
+ /** Comando de firma Authenticode según la herramienta disponible. */
61
+ export function buildAuthenticodeInvocation({ tool, file, pfx, password, timestamp, name, url }) {
62
+ if (tool === 'osslsigncode') {
63
+ const args = ['sign', '-pkcs12', pfx, '-in', file, '-out', `${file}.signed`]
64
+ if (password) args.push('-pass', password)
65
+ if (name) args.push('-n', name)
66
+ if (url) args.push('-i', url)
67
+ if (timestamp) args.push('-t', timestamp)
68
+ return { cmd: 'osslsigncode', args, out: `${file}.signed`, inPlace: true }
69
+ }
70
+ if (tool === 'signtool') {
71
+ const args = ['sign', '/f', pfx, '/fd', 'SHA256', '/d', name || path.basename(file)]
72
+ if (password) args.push('/p', password)
73
+ if (timestamp) args.push('/tr', timestamp, '/td', 'SHA256')
74
+ args.push(file)
75
+ return { cmd: 'signtool', args, out: file, inPlace: false }
76
+ }
77
+ throw new Error(`herramienta de firma desconocida: ${tool}`)
78
+ }
79
+
80
+ /** ¿Está la herramienta en el PATH? */
81
+ export function hasTool(cmd) {
82
+ const probe = process.platform === 'win32' ? 'where' : 'which'
83
+ return spawnSync(probe, [cmd], { stdio: 'ignore' }).status === 0
84
+ }
85
+
86
+ /** Firma Ed25519 desprendida: escribe `<file>.sig` (base64) y devuelve la firma. */
87
+ export function signEd25519(file, keyPath) {
88
+ const data = fs.readFileSync(file)
89
+ const key = crypto.createPrivateKey(fs.readFileSync(keyPath))
90
+ const sig = crypto.sign(null, data, key)
91
+ fs.writeFileSync(`${file}.sig`, sig.toString('base64') + '\n')
92
+ return sig
93
+ }
94
+
95
+ /** Verifica una firma Ed25519 desprendida contra `pub` (PEM o SPKI base64). */
96
+ export function verifyEd25519(file, sigPath, pub) {
97
+ const key = pub.includes('BEGIN')
98
+ ? crypto.createPublicKey(pub)
99
+ : crypto.createPublicKey({ key: Buffer.from(pub, 'base64'), format: 'der', type: 'spki' })
100
+ const data = fs.readFileSync(file)
101
+ const sig = Buffer.from(fs.readFileSync(sigPath, 'utf8').trim(), 'base64')
102
+ return crypto.verify(null, data, key, sig)
103
+ }
104
+
105
+ /** Firma Authenticode (in-place si es posible). Devuelve 'signed'|'skipped'|'failed'. */
106
+ export function signAuthenticode({ file, pfx, password, timestamp, name, url, require = false }) {
107
+ const tool = hasTool('osslsigncode') ? 'osslsigncode' : hasTool('signtool') ? 'signtool' : null
108
+ if (!tool) {
109
+ const msg = 'ni osslsigncode ni signtool están en el PATH'
110
+ if (require) throw new Error(msg)
111
+ warn(`${msg} — Authenticode omitido`)
112
+ return 'skipped'
113
+ }
114
+ if (!pfx || !fs.existsSync(pfx)) {
115
+ const msg = `certificado no encontrado: ${pfx ?? '(sin --pfx)'}`
116
+ if (require) throw new Error(msg)
117
+ warn(`${msg} — Authenticode omitido`)
118
+ return 'skipped'
119
+ }
120
+ const inv = buildAuthenticodeInvocation({ tool, file, pfx, password, timestamp, name, url })
121
+ const r = spawnSync(inv.cmd, inv.args, { stdio: 'inherit' })
122
+ if (r.status !== 0) {
123
+ if (require) throw new Error(`${inv.cmd} falló (${r.status})`)
124
+ warn(`${inv.cmd} falló (${r.status})`)
125
+ return 'failed'
126
+ }
127
+ if (inv.inPlace) {
128
+ fs.renameSync(inv.out, file)
129
+ info(`Authenticode OK (${tool})`)
130
+ } else {
131
+ info(`Authenticode OK (${tool})`)
132
+ }
133
+ return 'signed'
134
+ }
135
+
136
+ function main() {
137
+ const argv = process.argv.slice(2)
138
+ const arg = (n, def = null) => (argv.includes(n) ? argv[argv.indexOf(n) + 1] : def)
139
+ const flag = (n) => argv.includes(n)
140
+
141
+ const file = arg('--file')
142
+ if (!file) {
143
+ fail('--file es obligatorio')
144
+ process.exit(1)
145
+ }
146
+ if (!fs.existsSync(file)) {
147
+ fail(`no existe: ${file}`)
148
+ process.exit(1)
149
+ }
150
+
151
+ const edKey = arg('--ed25519-key')
152
+ const pfx = arg('--pfx')
153
+ const pwEnv = arg('--pfx-password-env')
154
+ const password = arg('--password', pwEnv ? process.env[pwEnv] ?? '' : '')
155
+ const timestamp = arg('--timestamp')
156
+ const name = arg('--name')
157
+ const url = arg('--url')
158
+ const require = flag('--require')
159
+ const dryRun = flag('--dry-run')
160
+ const isWin = process.platform === 'win32'
161
+
162
+ let did = 0
163
+
164
+ // 1) Authenticode (solo PE/MSI y solo en Windows o con osslsigncode)
165
+ if (pfx) {
166
+ if (!isAuthenticodeTarget(file)) {
167
+ info(`${path.basename(file)} no es PE/MSI — Authenticode no aplica`)
168
+ } else if (dryRun) {
169
+ const tool = hasTool('osslsigncode') ? 'osslsigncode' : 'signtool'
170
+ const inv = buildAuthenticodeInvocation({ tool, file, pfx, password, timestamp, name, url })
171
+ console.log(JSON.stringify({ authenticode: { cmd: inv.cmd, args: inv.args } }))
172
+ did++
173
+ } else {
174
+ if (signAuthenticode({ file, pfx, password, timestamp, name, url, require }) === 'signed') did++
175
+ }
176
+ }
177
+
178
+ // 2) Ed25519 desprendida (siempre disponible)
179
+ if (edKey) {
180
+ if (!fs.existsSync(edKey)) {
181
+ fail(`clave no encontrada: ${edKey}`)
182
+ process.exit(1)
183
+ }
184
+ if (dryRun) {
185
+ console.log(JSON.stringify({ ed25519: { file, key: edKey, out: `${file}.sig` } }))
186
+ } else {
187
+ signEd25519(file, edKey)
188
+ info(`Ed25519 OK → ${path.basename(file)}.sig`)
189
+ did++
190
+ }
191
+ }
192
+
193
+ if (!did) {
194
+ if (require) {
195
+ fail('no se firmó nada (--require)')
196
+ process.exit(1)
197
+ }
198
+ warn('sin material de firma (--ed25519-key / --pfx) — nada que hacer')
199
+ }
200
+ void isWin
201
+ }
202
+
203
+ // Ejecuta main solo como script (permite importarlo en tests).
204
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) main()
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Copyright 2026 Owear Contributors
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ //
5
+ // tools/owear-update.mjs — publica un update de Owear: calcula el blockmap
6
+ // (delta por bloques), genera el manifiesto YAML del canal y lo firma con
7
+ // Ed25519.
8
+ //
9
+ // Salidas (en --out-dir, por defecto junto al artefacto):
10
+ // <file>.blockmap { blockSize, size, blocks:[sha256…] }
11
+ // <channel>.yml manifiesto YAML (version, sha256, sha512, blockSize,
12
+ // blockmap, path, releaseDate, notes, signature)
13
+ //
14
+ // El SDK (autoUpdater) verifica `signature` con la clave pública del bridge
15
+ // (Ed25519 sobre "<version>:<sha256>") y descarga solo los bloques cambiados.
16
+ //
17
+ // Uso:
18
+ // node tools/owear-update.mjs --file release/MiApp-1.4.0 --version 1.4.0 \
19
+ // --channel latest --url https://up.miapp.dev --key private.pem \
20
+ // [--notes "…"|--notes @notes.md] [--block-size 262144] [--mandatory] \
21
+ // [--out-dir release]
22
+ //
23
+ // # claves (una vez):
24
+ // node tools/owear-update.mjs --gen-key ./owear-signing
25
+ // → owear-signing.pem (privada, Ed25519 PKCS8)
26
+ // owear-signing.pub.b64 (pública SPKI base64 — va en el bridge)
27
+ //
28
+ import * as fs from 'node:fs'
29
+ import * as path from 'node:path'
30
+ import * as crypto from 'node:crypto'
31
+
32
+ function die(m) {
33
+ console.error(`[owear-update] ✗ ${m}`)
34
+ process.exit(1)
35
+ }
36
+ const argv = process.argv.slice(2)
37
+ const arg = (n, def = null) => (argv.includes(n) ? argv[argv.indexOf(n) + 1] : def)
38
+ const flag = (n) => argv.includes(n)
39
+
40
+ // ── generación de claves ────────────────────────────────────────────────────
41
+ const genKey = arg('--gen-key')
42
+ if (genKey) {
43
+ if (fs.existsSync(`${genKey}.pem`)) die(`ya existe ${genKey}.pem`)
44
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
45
+ fs.writeFileSync(`${genKey}.pem`, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })
46
+ fs.writeFileSync(`${genKey}.pub.b64`, publicKey.export({ format: 'der', type: 'spki' }).toString('base64'))
47
+ console.log(`[owear-update] ✓ clave privada: ${genKey}.pem`)
48
+ console.log(`[owear-update] ✓ clave pública: ${genKey}.pub.b64 (ponla en el bridge: updater.publicKey)`)
49
+ process.exit(0)
50
+ }
51
+
52
+ // ── validación de argumentos ────────────────────────────────────────────────
53
+ const file = arg('--file')
54
+ const version = arg('--version')
55
+ if (!file || !version) die('--file y --version son obligatorios (o usa --gen-key)')
56
+ if (!fs.existsSync(file)) die(`no existe el artefacto: ${file}`)
57
+ if (!/^\d+\.\d+\.\d+/.test(version)) die(`versión inválida: ${version} (semver esperado)`)
58
+
59
+ const channel = arg('--channel', 'latest')
60
+ const blockSize = parseInt(arg('--block-size', '262144'), 10)
61
+ if (!Number.isFinite(blockSize) || blockSize <= 0) die('--block-size inválido')
62
+ const baseUrl = (arg('--url', '') || '').replace(/\/+$/, '')
63
+ const outDir = arg('--out-dir', path.dirname(file))
64
+ const keyPath = arg('--key')
65
+ const mandatory = flag('--mandatory')
66
+
67
+ let notes = arg('--notes', '')
68
+ if (notes.startsWith('@')) {
69
+ const nf = notes.slice(1)
70
+ if (!fs.existsSync(nf)) die(`no existe el fichero de notas: ${nf}`)
71
+ notes = fs.readFileSync(nf, 'utf8').trimEnd()
72
+ }
73
+
74
+ // ── integridad + blockmap ───────────────────────────────────────────────────
75
+ const data = fs.readFileSync(file)
76
+ const sha256 = crypto.createHash('sha256').update(data).digest('hex')
77
+ const sha512 = crypto.createHash('sha512').update(data).digest('hex')
78
+
79
+ const blocks = []
80
+ for (let off = 0; off < data.length; off += blockSize) {
81
+ blocks.push(crypto.createHash('sha256').update(data.subarray(off, Math.min(off + blockSize, data.length))).digest('hex'))
82
+ }
83
+ if (data.length === 0) blocks.push(crypto.createHash('sha256').update(Buffer.alloc(0)).digest('hex'))
84
+
85
+ fs.mkdirSync(outDir, { recursive: true })
86
+ const artifactName = path.basename(file)
87
+ const blockmapPath = path.join(outDir, `${artifactName}.blockmap`)
88
+ fs.writeFileSync(blockmapPath, JSON.stringify({ blockSize, size: data.length, sha256, blocks }))
89
+
90
+ // ── firma Ed25519 (del manifiesto y del binario) ────────────────────────────
91
+ // signature: sobre "<version>:<sha256>" (autentica metadata)
92
+ // binarySig: sobre los bytes del artefacto (autentica el payload)
93
+ let signature = ''
94
+ let binarySig = ''
95
+ if (keyPath) {
96
+ if (!fs.existsSync(keyPath)) die(`no existe la clave: ${keyPath}`)
97
+ const privateKey = crypto.createPrivateKey(fs.readFileSync(keyPath))
98
+ signature = crypto
99
+ .sign(null, Buffer.from(`${version}:${sha256}`), privateKey)
100
+ .toString('base64')
101
+ binarySig = crypto.sign(null, data, privateKey).toString('base64')
102
+ }
103
+
104
+ // ── manifiesto YAML ─────────────────────────────────────────────────────────
105
+ const blockmapUrl = baseUrl ? `${baseUrl}/${path.basename(blockmapPath)}` : path.basename(blockmapPath)
106
+ const releaseDate = new Date().toISOString()
107
+ const yamlEscape = (s) => (/[:#'"@|>{}\[\],&*!?%`-]|^\s|\s$|\n/.test(s) ? `'${s.replace(/'/g, "''")}'` : s)
108
+ // las notas van como bloque literal
109
+ const notesBlock = notes
110
+ ? `notes: |\n${notes
111
+ .split('\n')
112
+ .map((l) => ` ${l}`)
113
+ .join('\n')}\n`
114
+ : ''
115
+
116
+ const manifest = [
117
+ `version: ${version}`,
118
+ `releaseDate: '${releaseDate}'`,
119
+ `path: ${yamlEscape(artifactName)}`,
120
+ // hashes y firmas se citan siempre: un valor "numérico" (p. ej. un hash solo
121
+ // de dígitos) no debe reinterpretarse como número al parsear el YAML.
122
+ `sha256: '${sha256}'`,
123
+ `sha512: '${sha512}'`,
124
+ `size: ${data.length}`,
125
+ `blockSize: ${blockSize}`,
126
+ `blockmap: ${yamlEscape(blockmapUrl)}`,
127
+ mandatory ? 'mandatory: true' : null,
128
+ signature ? `signature: '${signature}'` : null,
129
+ binarySig ? `binarySig: '${binarySig}'` : null,
130
+ notesBlock || null,
131
+ '',
132
+ ]
133
+ .filter((l) => l !== null)
134
+ .join('\n')
135
+
136
+ const manifestPath = path.join(outDir, `${channel}.yml`)
137
+ fs.writeFileSync(manifestPath, manifest)
138
+
139
+ // ── resumen ─────────────────────────────────────────────────────────────────
140
+ const mb = (n) => (n / 1024 / 1024).toFixed(1)
141
+ console.log(`[owear-update] ✓ artefacto ${artifactName} ${mb(data.length)} MB`)
142
+ console.log(`[owear-update] ✓ blockmap ${path.basename(blockmapPath)} (${blocks.length} bloques × ${(blockSize / 1024).toFixed(0)} KiB)`)
143
+ console.log(`[owear-update] ✓ manifiesto ${manifestPath}`)
144
+ console.log(`[owear-update] ${signature ? '✓ firmado' : '⚠ sin firma (--key)'} sha256=${sha256.slice(0, 16)}…`)
145
+ if (baseUrl) {
146
+ console.log(`[owear-update] feed: ${baseUrl}`)
147
+ console.log(`[owear-update] ${baseUrl}/${channel}.yml`)
148
+ console.log(`[owear-update] ${baseUrl}/${artifactName}`)
149
+ console.log(`[owear-update] ${baseUrl}/${path.basename(blockmapPath)}`)
150
+ }