@awak-app/simy-cli 0.5.14 → 0.5.16

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,28 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { maintainWindowsUpdate, selectedWindowsRoot } from './windows-update.js';
5
+ const installation = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
6
+ const stateRoot = path.join(process.env.LOCALAPPDATA, 'SIMY CLI', 'updates');
7
+ const argv = process.argv.slice(2);
8
+ let root = await selectedWindowsRoot(installation, stateRoot);
9
+ const updating = argv[0] === 'update';
10
+ try {
11
+ if (updating || (!argv.includes('--no-auto-update') && process.env.SIMY_AUTO_UPDATE !== '0' && !argv.includes('--help') && !argv.includes('-h'))) {
12
+ root = await maintainWindowsUpdate({ installation, stateRoot, force: updating, rollback: updating && argv.includes('--rollback'), checkOnly: updating && argv.includes('--check'), allowMajor: updating && argv.includes('--yes'), report: version => { if (updating) console.log(`Latest Windows release: ${version}`); } });
13
+ }
14
+ } catch (error) {
15
+ console.error(`SIMY Windows update: ${error.message}. Keeping the current installation.`);
16
+ if (updating) process.exitCode = 1;
17
+ }
18
+ if (updating) {
19
+ if (!process.exitCode) console.log(`SIMY Windows installation ready: ${root}`);
20
+ } else {
21
+ const child = spawn(path.join(root, 'runtime/node.exe'), [path.join(root, 'cli/src/index.js'), ...argv], {
22
+ stdio: 'inherit', env: { ...process.env, SIMY_AUTO_UPDATE: '0' },
23
+ });
24
+ // Windows console signals are already delivered to both processes.
25
+ process.on('SIGINT', () => {});
26
+ child.on('error', error => { console.error(error.message); process.exitCode = 1; });
27
+ child.on('exit', code => { process.exitCode = code ?? 1; });
28
+ }
@@ -0,0 +1,10 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ const expected = JSON.parse(await readFile(new URL('../package.json', import.meta.url))).version;
3
+ import { startAgent } from './agent.js';
4
+ import { closeAgent } from './shutdown.js';
5
+ const agent = await startAgent({ requestedPort: 0, quiet: true, sessionRoot: process.env.SIMY_HOME });
6
+ try {
7
+ const response = await fetch(`http://127.0.0.1:${agent.port}/v1/health`);
8
+ const health = await response.json();
9
+ if (!response.ok || !health.ok || health.session_valid || health.cli_version !== expected) throw new Error('Candidate health failed');
10
+ } finally { await closeAgent(agent); }
@@ -0,0 +1,129 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, writeFile, rename, rm, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+
7
+ const exec = promisify(execFile);
8
+ export const WINDOWS_MANIFEST_URL = "https://simy.one/downloads/simy-cli/windows/latest.json";
9
+ const stable = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
10
+ const sha = /^[a-f0-9]{64}$/;
11
+ export function compareWindowsVersions(a, b) {
12
+ if (!stable.test(a) || !stable.test(b)) throw new Error("Invalid release version");
13
+ const aa = a.split('.').map(Number), bb = b.split('.').map(Number);
14
+ for (let i = 0; i < 3; i++) if (aa[i] !== bb[i]) return aa[i] - bb[i];
15
+ return 0;
16
+ }
17
+ export function validateWindowsRelease(value) {
18
+ if (value?.schemaVersion !== 1 || !stable.test(value.version) || value.artifacts?.length !== 1) throw new Error("Invalid Windows manifest");
19
+ const artifact = value.artifacts[0];
20
+ if (artifact.platform !== "win32" || artifact.arch !== "x64" || typeof artifact.signed !== "boolean") throw new Error("Invalid platform");
21
+ for (const [item, suffix] of [[artifact, '-setup.exe'], [artifact.updater, '.zip']]) {
22
+ const url = new URL(item?.url);
23
+ if (url.origin !== 'https://simy.one' || url.username || url.password || url.search || url.hash ||
24
+ url.pathname !== `/downloads/simy-cli/windows/${value.version}/SIMY-CLI-${value.version}-windows-x64${suffix}` ||
25
+ !sha.test(item.sha256) || !Number.isSafeInteger(item.size) || item.size <= 0 || item.size > 256 * 1024 * 1024) throw new Error("Invalid Windows artifact");
26
+ }
27
+ return value;
28
+ }
29
+ async function json(file, fallback = null) { try { return JSON.parse(await readFile(file, 'utf8')); } catch { return fallback; } }
30
+ async function atomicJson(file, data) {
31
+ const temporary = `${file}.${randomUUID()}.tmp`;
32
+ await writeFile(temporary, JSON.stringify(data));
33
+ await rename(temporary, file);
34
+ }
35
+ export async function selectedWindowsRoot(installation, stateRoot) {
36
+ const installed = await json(path.join(installation, 'cli/package.json'));
37
+ const state = await json(path.join(stateRoot, 'current.json'));
38
+ if (!state || !stable.test(state.version) || compareWindowsVersions(state.version, installed.version) < 0) return installation;
39
+ const candidate = path.join(stateRoot, 'releases', state.version);
40
+ if ((await json(path.join(candidate, 'cli/package.json')))?.version !== state.version) return installation;
41
+ try { await stat(path.join(candidate, 'runtime/node.exe')); } catch { return installation; }
42
+ return candidate;
43
+ }
44
+ export async function downloadWindowsArtifact(item, destination, fetchImpl = fetch) {
45
+ const response = await fetchImpl(item.url, { signal: AbortSignal.timeout(120_000), redirect: 'error' });
46
+ if (!response.ok || !response.body) throw new Error('Update download failed');
47
+ const chunks = []; let size = 0;
48
+ for await (const chunk of response.body) { size += chunk.length; if (size > item.size) throw new Error('Oversized update'); chunks.push(chunk); }
49
+ const bytes = Buffer.concat(chunks);
50
+ if (size !== item.size || createHash('sha256').update(bytes).digest('hex') !== item.sha256) throw new Error('Update checksum mismatch');
51
+ await writeFile(destination, bytes);
52
+ }
53
+ export async function applyWindowsUpdate({ installation, stateRoot, release, fetchImpl = fetch, verifyCandidate = verifyWindowsCandidate, extract = extractWindowsZip }) {
54
+ validateWindowsRelease(release);
55
+ const current = await selectedWindowsRoot(installation, stateRoot);
56
+ const currentVersion = (await json(path.join(current, 'cli/package.json'))).version;
57
+ if (compareWindowsVersions(release.version, currentVersion) <= 0) return current;
58
+ const marker = await json(path.join(installation, 'installation.json'));
59
+ if (marker?.signed && !release.artifacts[0].signed) throw new Error('Refusing unsigned update of signed installation');
60
+ const staging = path.join(stateRoot, `staging-${randomUUID()}`);
61
+ await mkdir(staging, { recursive: true });
62
+ try {
63
+ const archive = path.join(staging, 'update.zip');
64
+ await downloadWindowsArtifact(release.artifacts[0].updater, archive, fetchImpl);
65
+ const candidate = path.join(staging, 'payload');
66
+ await extract(archive, candidate, installation);
67
+ if ((await json(path.join(candidate, 'cli/package.json')))?.version !== release.version) throw new Error('Candidate version mismatch');
68
+ await verifyCandidate(candidate, marker);
69
+ const target = path.join(stateRoot, 'releases', release.version);
70
+ await mkdir(path.dirname(target), { recursive: true });
71
+ // Immutable directory: never replace a running or previously staged version.
72
+ try { await stat(target); throw new Error('Release directory already exists'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
73
+ await rename(candidate, target);
74
+ await atomicJson(path.join(stateRoot, 'current.json'), { version: release.version, previous: currentVersion });
75
+ return target;
76
+ } finally { await rm(staging, { recursive: true, force: true }); }
77
+ }
78
+ const powershell = () => path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
79
+ async function extractWindowsZip(archive, destination, installation) {
80
+ await exec(powershell(), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', path.join(installation, 'extract-update.ps1'), '-Archive', archive, '-Destination', destination], { timeout: 120_000, windowsHide: true });
81
+ }
82
+ async function verifyWindowsCandidate(candidate, marker) {
83
+ if (marker?.signed) {
84
+ if (!/^[A-Fa-f0-9]{40}$/.test(marker.thumbprint || '')) throw new Error('Missing trusted signer');
85
+ // Paths are passed through environment values, never inserted into shell source.
86
+ await exec(powershell(), ['-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop';foreach($file in @('simy.exe','runtime/node.exe')){$s=Get-AuthenticodeSignature (Join-Path $env:SIMY_CANDIDATE $file);if($s.Status -ne 'Valid'){throw 'Invalid signature'};if($file -eq 'simy.exe' -and $s.SignerCertificate.Thumbprint -ne $env:SIMY_SIGNER){throw 'Unexpected signer'}}"], { env: { ...process.env, SIMY_CANDIDATE: candidate, SIMY_SIGNER: marker.thumbprint }, timeout: 30_000, windowsHide: true });
87
+ }
88
+ const probeHome = path.join(candidate, '.update-probe');
89
+ try {
90
+ await exec(path.join(candidate, 'runtime/node.exe'), [path.join(candidate, 'cli/src/windows-candidate-probe.js')], { env: { ...process.env, SIMY_HOME: probeHome, SIMY_AUTO_UPDATE: '0' }, timeout: 30_000, windowsHide: true });
91
+ } finally { await rm(probeHome, { recursive: true, force: true }); }
92
+ }
93
+ export async function maintainWindowsUpdate({ installation, stateRoot, force = false, rollback = false, checkOnly = false, allowMajor = false, report = () => {}, fetchImpl = fetch } = {}) {
94
+ await mkdir(stateRoot, { recursive: true });
95
+ const lock = path.join(stateRoot, 'update.lock');
96
+ // The OS releases this per-process lock file on exit only through finally; stale
97
+ // locks defer updates safely, and can be removed when no SIMY process is running.
98
+ try { await mkdir(lock); } catch (error) {
99
+ if (error.code !== 'EEXIST') throw error;
100
+ return selectedWindowsRoot(installation, stateRoot);
101
+ }
102
+ try {
103
+ if (rollback) {
104
+ const state = await json(path.join(stateRoot, 'current.json'));
105
+ if (!state?.previous || !stable.test(state.previous)) throw new Error('No previous version to restore');
106
+ const installed = (await json(path.join(installation, 'cli/package.json'))).version;
107
+ if (state.previous !== installed && (await json(path.join(stateRoot, 'releases', state.previous, 'cli/package.json')))?.version !== state.previous) throw new Error('Previous version missing');
108
+ await atomicJson(path.join(stateRoot, 'current.json'), { version: state.previous, blocked: state.version });
109
+ return selectedWindowsRoot(installation, stateRoot);
110
+ }
111
+ const check = await json(path.join(stateRoot, 'checked.json'));
112
+ if (!force && Date.now() - Number(check?.at) < 60 * 60_000) return selectedWindowsRoot(installation, stateRoot);
113
+ const response = await fetchImpl(WINDOWS_MANIFEST_URL, { signal: AbortSignal.timeout(5_000), redirect: 'error' });
114
+ if (!response.ok) throw new Error('Windows release discovery unavailable');
115
+ const text = await response.text();
116
+ if (text.length > 128 * 1024) throw new Error('Oversized manifest');
117
+ const release = validateWindowsRelease(JSON.parse(text));
118
+ report(release.version);
119
+ const selected = await selectedWindowsRoot(installation, stateRoot);
120
+ if (checkOnly) return selected;
121
+ const currentVersion = (await json(path.join(selected, 'cli/package.json'))).version;
122
+ if (!allowMajor && Number(release.version.split('.')[0]) > Number(currentVersion.split('.')[0])) throw new Error('Major update requires simy update --yes');
123
+ const state = await json(path.join(stateRoot, 'current.json'));
124
+ if (state?.blocked === release.version) return selectedWindowsRoot(installation, stateRoot);
125
+ const root = await applyWindowsUpdate({ installation, stateRoot, release, fetchImpl });
126
+ await atomicJson(path.join(stateRoot, 'checked.json'), { at: Date.now() });
127
+ return root;
128
+ } finally { await rm(lock, { recursive: true, force: true }); }
129
+ }