@tikoci/quickchr 0.2.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/DESIGN.md +193 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +61 -0
- package/src/cli/format.ts +154 -0
- package/src/cli/index.ts +2625 -0
- package/src/cli/wizard.ts +727 -0
- package/src/index.ts +124 -0
- package/src/lib/auth.ts +68 -0
- package/src/lib/channels.ts +222 -0
- package/src/lib/completions.ts +513 -0
- package/src/lib/console.ts +473 -0
- package/src/lib/credentials.ts +99 -0
- package/src/lib/device-mode.ts +283 -0
- package/src/lib/disk.ts +400 -0
- package/src/lib/exec.ts +101 -0
- package/src/lib/images.ts +167 -0
- package/src/lib/license.ts +292 -0
- package/src/lib/log.ts +41 -0
- package/src/lib/network.ts +635 -0
- package/src/lib/packages.ts +225 -0
- package/src/lib/password.ts +21 -0
- package/src/lib/platform.ts +458 -0
- package/src/lib/provision.ts +422 -0
- package/src/lib/qemu.ts +526 -0
- package/src/lib/qga.ts +667 -0
- package/src/lib/quickchr.ts +1871 -0
- package/src/lib/rest.ts +244 -0
- package/src/lib/secrets.ts +133 -0
- package/src/lib/socket-registry.ts +140 -0
- package/src/lib/state.ts +173 -0
- package/src/lib/storage.ts +163 -0
- package/src/lib/types.ts +690 -0
- package/src/lib/versions.ts +181 -0
- package/src/lib/zip.ts +44 -0
package/src/lib/rest.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized HTTP client for all RouterOS CHR REST API calls.
|
|
3
|
+
*
|
|
4
|
+
* Uses node:http with agent:false to bypass Bun's connection pool.
|
|
5
|
+
* Bun pools TCP connections by host:port; when a CHR is stopped and a new
|
|
6
|
+
* one starts on the same port, the pool returns stale responses. This is
|
|
7
|
+
* the root cause of "REST flakiness" we chased across 7 sessions.
|
|
8
|
+
*
|
|
9
|
+
* Uses setTimeout + done flag instead of req.setTimeout + req.destroy
|
|
10
|
+
* because Bun's node:http req.destroy() does not reliably emit the
|
|
11
|
+
* "error" event, which would leave the Promise pending forever.
|
|
12
|
+
*
|
|
13
|
+
* External URLs (mikrotik.com, upgrade server) use fetch() — no pool
|
|
14
|
+
* issue there. This module is ONLY for CHR REST calls (127.0.0.1).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { request as nodeRequest } from "node:http";
|
|
18
|
+
|
|
19
|
+
export interface RestResponse {
|
|
20
|
+
status: number;
|
|
21
|
+
body: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HTTP GET to a CHR REST endpoint.
|
|
26
|
+
*
|
|
27
|
+
* @param url Full URL, e.g. "http://127.0.0.1:9100/rest/system/resource"
|
|
28
|
+
* @param auth HTTP Authorization header value (e.g. "Basic YWRtaW46")
|
|
29
|
+
* @param timeoutMs Per-request timeout in milliseconds (default 10s)
|
|
30
|
+
*/
|
|
31
|
+
export function restGet(
|
|
32
|
+
url: string,
|
|
33
|
+
auth: string,
|
|
34
|
+
timeoutMs = 10_000,
|
|
35
|
+
): Promise<RestResponse> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
let done = false;
|
|
39
|
+
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
if (!done) { done = true; req.destroy(); reject(new Error(`restGet timeout after ${timeoutMs}ms: ${url}`)); }
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
|
|
44
|
+
const req = nodeRequest(
|
|
45
|
+
{
|
|
46
|
+
hostname: parsed.hostname,
|
|
47
|
+
port: parsed.port,
|
|
48
|
+
path: parsed.pathname + parsed.search,
|
|
49
|
+
method: "GET",
|
|
50
|
+
headers: { Authorization: auth, Connection: "close" },
|
|
51
|
+
agent: false,
|
|
52
|
+
},
|
|
53
|
+
(res) => {
|
|
54
|
+
let body = "";
|
|
55
|
+
res.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
56
|
+
res.on("end", () => {
|
|
57
|
+
if (!done) { done = true; clearTimeout(timer); resolve({ status: res.statusCode ?? 0, body }); }
|
|
58
|
+
});
|
|
59
|
+
res.on("error", (e) => {
|
|
60
|
+
if (!done) { done = true; clearTimeout(timer); reject(e); }
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
req.on("error", (e) => {
|
|
65
|
+
if (!done) { done = true; clearTimeout(timer); reject(e); }
|
|
66
|
+
});
|
|
67
|
+
req.end();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* HTTP POST to a CHR REST endpoint with a JSON body.
|
|
73
|
+
*
|
|
74
|
+
* @param url Full URL, e.g. "http://127.0.0.1:9100/rest/system/reboot"
|
|
75
|
+
* @param auth HTTP Authorization header value
|
|
76
|
+
* @param jsonBody Object to serialize as JSON request body
|
|
77
|
+
* @param timeoutMs Per-request timeout in milliseconds (default 10s)
|
|
78
|
+
*/
|
|
79
|
+
export function restPost(
|
|
80
|
+
url: string,
|
|
81
|
+
auth: string,
|
|
82
|
+
jsonBody: Record<string, unknown>,
|
|
83
|
+
timeoutMs = 10_000,
|
|
84
|
+
): Promise<RestResponse> {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const parsed = new URL(url);
|
|
87
|
+
const bodyBuf = Buffer.from(JSON.stringify(jsonBody), "utf-8");
|
|
88
|
+
let done = false;
|
|
89
|
+
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
if (!done) { done = true; req.destroy(); reject(new Error(`restPost timeout after ${timeoutMs}ms: ${url}`)); }
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
|
|
94
|
+
const req = nodeRequest(
|
|
95
|
+
{
|
|
96
|
+
hostname: parsed.hostname,
|
|
97
|
+
port: parsed.port,
|
|
98
|
+
path: parsed.pathname + parsed.search,
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: auth,
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
"Content-Length": bodyBuf.length,
|
|
104
|
+
Connection: "close",
|
|
105
|
+
},
|
|
106
|
+
agent: false,
|
|
107
|
+
},
|
|
108
|
+
(res) => {
|
|
109
|
+
let body = "";
|
|
110
|
+
res.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
111
|
+
res.on("end", () => {
|
|
112
|
+
if (!done) { done = true; clearTimeout(timer); resolve({ status: res.statusCode ?? 0, body }); }
|
|
113
|
+
});
|
|
114
|
+
res.on("error", (e) => {
|
|
115
|
+
if (!done) { done = true; clearTimeout(timer); reject(e); }
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
req.on("error", (e) => {
|
|
120
|
+
if (!done) { done = true; clearTimeout(timer); reject(e); }
|
|
121
|
+
});
|
|
122
|
+
req.write(bodyBuf);
|
|
123
|
+
req.end();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* HTTP PATCH to a CHR REST endpoint with a JSON body.
|
|
129
|
+
*
|
|
130
|
+
* @param url Full URL, e.g. "http://127.0.0.1:9100/rest/user/*1"
|
|
131
|
+
* @param auth HTTP Authorization header value
|
|
132
|
+
* @param jsonBody Object to serialize as JSON request body
|
|
133
|
+
* @param timeoutMs Per-request timeout in milliseconds (default 10s)
|
|
134
|
+
*/
|
|
135
|
+
export function restPatch(
|
|
136
|
+
url: string,
|
|
137
|
+
auth: string,
|
|
138
|
+
jsonBody: Record<string, unknown>,
|
|
139
|
+
timeoutMs = 10_000,
|
|
140
|
+
): Promise<RestResponse> {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const parsed = new URL(url);
|
|
143
|
+
const bodyBuf = Buffer.from(JSON.stringify(jsonBody), "utf-8");
|
|
144
|
+
let done = false;
|
|
145
|
+
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
if (!done) { done = true; req.destroy(); reject(new Error(`restPatch timeout after ${timeoutMs}ms: ${url}`)); }
|
|
148
|
+
}, timeoutMs);
|
|
149
|
+
|
|
150
|
+
const req = nodeRequest(
|
|
151
|
+
{
|
|
152
|
+
hostname: parsed.hostname,
|
|
153
|
+
port: parsed.port,
|
|
154
|
+
path: parsed.pathname + parsed.search,
|
|
155
|
+
method: "PATCH",
|
|
156
|
+
headers: {
|
|
157
|
+
Authorization: auth,
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
"Content-Length": bodyBuf.length,
|
|
160
|
+
Connection: "close",
|
|
161
|
+
},
|
|
162
|
+
agent: false,
|
|
163
|
+
},
|
|
164
|
+
(res) => {
|
|
165
|
+
let body = "";
|
|
166
|
+
res.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
167
|
+
res.on("end", () => {
|
|
168
|
+
if (!done) { done = true; clearTimeout(timer); resolve({ status: res.statusCode ?? 0, body }); }
|
|
169
|
+
});
|
|
170
|
+
res.on("error", (e) => {
|
|
171
|
+
if (!done) { done = true; clearTimeout(timer); reject(e); }
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
req.on("error", (e) => {
|
|
176
|
+
if (!done) { done = true; clearTimeout(timer); reject(e); }
|
|
177
|
+
});
|
|
178
|
+
req.write(bodyBuf);
|
|
179
|
+
req.end();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generic HTTP request to a CHR REST endpoint.
|
|
185
|
+
* Used for arbitrary methods (PUT, DELETE) or when method is dynamic.
|
|
186
|
+
*
|
|
187
|
+
* @param url Full URL
|
|
188
|
+
* @param method HTTP method
|
|
189
|
+
* @param auth HTTP Authorization header value
|
|
190
|
+
* @param jsonBody Optional JSON body (for POST/PUT/PATCH)
|
|
191
|
+
* @param timeoutMs Per-request timeout in milliseconds (default 10s)
|
|
192
|
+
*/
|
|
193
|
+
export function restRequest(
|
|
194
|
+
url: string,
|
|
195
|
+
method: string,
|
|
196
|
+
auth: string,
|
|
197
|
+
jsonBody?: Record<string, unknown> | string | null,
|
|
198
|
+
timeoutMs = 10_000,
|
|
199
|
+
): Promise<RestResponse> {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const parsed = new URL(url);
|
|
202
|
+
const bodyStr = jsonBody != null
|
|
203
|
+
? (typeof jsonBody === "string" ? jsonBody : JSON.stringify(jsonBody))
|
|
204
|
+
: null;
|
|
205
|
+
const bodyBuf = bodyStr != null ? Buffer.from(bodyStr, "utf-8") : null;
|
|
206
|
+
let done = false;
|
|
207
|
+
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
if (!done) { done = true; req.destroy(); reject(new Error(`restRequest timeout after ${timeoutMs}ms: ${method} ${url}`)); }
|
|
210
|
+
}, timeoutMs);
|
|
211
|
+
|
|
212
|
+
const headers: Record<string, string | number> = { Authorization: auth, Connection: "close" };
|
|
213
|
+
if (bodyBuf) {
|
|
214
|
+
headers["Content-Type"] = "application/json";
|
|
215
|
+
headers["Content-Length"] = bodyBuf.length;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const req = nodeRequest(
|
|
219
|
+
{
|
|
220
|
+
hostname: parsed.hostname,
|
|
221
|
+
port: parsed.port,
|
|
222
|
+
path: parsed.pathname + parsed.search,
|
|
223
|
+
method,
|
|
224
|
+
headers,
|
|
225
|
+
agent: false,
|
|
226
|
+
},
|
|
227
|
+
(res) => {
|
|
228
|
+
let body = "";
|
|
229
|
+
res.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
230
|
+
res.on("end", () => {
|
|
231
|
+
if (!done) { done = true; clearTimeout(timer); resolve({ status: res.statusCode ?? 0, body }); }
|
|
232
|
+
});
|
|
233
|
+
res.on("error", (e) => {
|
|
234
|
+
if (!done) { done = true; clearTimeout(timer); reject(e); }
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
req.on("error", (e) => {
|
|
239
|
+
if (!done) { done = true; clearTimeout(timer); reject(e); }
|
|
240
|
+
});
|
|
241
|
+
if (bodyBuf) req.write(bodyBuf);
|
|
242
|
+
req.end();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform secret storage wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Uses Bun.secrets when available (macOS Keychain, Linux libsecret, Windows
|
|
5
|
+
* Credential Manager). Falls back to a config file with 0o600 permissions
|
|
6
|
+
* when Bun.secrets is unavailable (Node.js, older Bun, headless Linux without
|
|
7
|
+
* a secret-service daemon).
|
|
8
|
+
*
|
|
9
|
+
* Callers should never touch Bun.secrets directly — this module handles the
|
|
10
|
+
* runtime detection and fallback.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
function configDir(): string {
|
|
18
|
+
return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), ".config", "quickchr");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** True when the Bun.secrets API is available at runtime. */
|
|
22
|
+
function hasBunSecrets(): boolean {
|
|
23
|
+
try {
|
|
24
|
+
return typeof Bun !== "undefined" && Bun.secrets != null;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- Config-file fallback helpers ---
|
|
31
|
+
|
|
32
|
+
function configPath(service: string): string {
|
|
33
|
+
// One JSON file per service, keyed by name
|
|
34
|
+
const safeName = service.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
35
|
+
return join(configDir(), `${safeName}.json`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readConfigStore(service: string): Record<string, string> {
|
|
39
|
+
const path = configPath(service);
|
|
40
|
+
if (!existsSync(path)) return {};
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeConfigStore(service: string, store: Record<string, string>): void {
|
|
49
|
+
const path = configPath(service);
|
|
50
|
+
const dir = dirname(path);
|
|
51
|
+
if (!existsSync(dir)) {
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
writeFileSync(path, JSON.stringify(store, null, 2), "utf8");
|
|
55
|
+
chmodSync(path, 0o600);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Public API ---
|
|
59
|
+
|
|
60
|
+
/** Retrieve a secret from the config file only (no OS keychain).
|
|
61
|
+
* Use for credentials that don't need OS-level security (e.g., per-instance CHR passwords). */
|
|
62
|
+
export function secretGetSync(service: string, name: string): string | null {
|
|
63
|
+
return readConfigStore(service)[name] ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Store a secret in the config file only (no OS keychain). */
|
|
67
|
+
export function secretSetSync(service: string, name: string, value: string): void {
|
|
68
|
+
const store = readConfigStore(service);
|
|
69
|
+
store[name] = value;
|
|
70
|
+
writeConfigStore(service, store);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Delete a secret from the config file only (no OS keychain). Returns true if deleted. */
|
|
74
|
+
export function secretDeleteSync(service: string, name: string): boolean {
|
|
75
|
+
const store = readConfigStore(service);
|
|
76
|
+
if (!(name in store)) return false;
|
|
77
|
+
delete store[name];
|
|
78
|
+
writeConfigStore(service, store);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Retrieve a secret. Returns null when not found. */
|
|
83
|
+
export async function secretGet(service: string, name: string): Promise<string | null> {
|
|
84
|
+
if (hasBunSecrets()) {
|
|
85
|
+
try {
|
|
86
|
+
return await Bun.secrets.get({ service, name });
|
|
87
|
+
} catch {
|
|
88
|
+
// Bun.secrets threw (e.g. no keyring daemon on Linux) — fall through
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return readConfigStore(service)[name] ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Store or update a secret. */
|
|
95
|
+
export async function secretSet(service: string, name: string, value: string): Promise<void> {
|
|
96
|
+
if (hasBunSecrets()) {
|
|
97
|
+
try {
|
|
98
|
+
await Bun.secrets.set({ service, name, value });
|
|
99
|
+
return;
|
|
100
|
+
} catch {
|
|
101
|
+
// fall through to config file
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const store = readConfigStore(service);
|
|
105
|
+
store[name] = value;
|
|
106
|
+
writeConfigStore(service, store);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Delete a secret. Returns true if something was deleted. */
|
|
110
|
+
export async function secretDelete(service: string, name: string): Promise<boolean> {
|
|
111
|
+
let deleted = false;
|
|
112
|
+
if (hasBunSecrets()) {
|
|
113
|
+
try {
|
|
114
|
+
deleted = await Bun.secrets.delete({ service, name });
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Always clean up config file entry too
|
|
120
|
+
const store = readConfigStore(service);
|
|
121
|
+
if (name in store) {
|
|
122
|
+
delete store[name];
|
|
123
|
+
writeConfigStore(service, store);
|
|
124
|
+
deleted = true;
|
|
125
|
+
}
|
|
126
|
+
return deleted;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Human-readable label for where secrets are stored. */
|
|
130
|
+
export function secretStorageLabel(): string {
|
|
131
|
+
if (hasBunSecrets()) return "OS credential store (via Bun.secrets)";
|
|
132
|
+
return `config file (${configDir()})`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named socket registry — persistent named L2 links between CHR instances.
|
|
3
|
+
* Socket entries are stored as JSON files in ~/.local/share/quickchr/networks/.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { QuickCHRError } from "./types.ts";
|
|
9
|
+
import { getDataDir } from "./state.ts";
|
|
10
|
+
|
|
11
|
+
export interface SocketEntry {
|
|
12
|
+
name: string;
|
|
13
|
+
mode: "mcast" | "listen-connect";
|
|
14
|
+
mcastGroup?: string;
|
|
15
|
+
port: number;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
members: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_START_PORT = 4000;
|
|
21
|
+
const DEFAULT_MCAST_GROUP = "230.0.0.1";
|
|
22
|
+
|
|
23
|
+
// In-memory write-through cache — works around Bun Windows FS caching bugs
|
|
24
|
+
// where writeFileSync followed by immediate readdirSync/readFileSync returns stale data.
|
|
25
|
+
// Uses a Map for well-defined semantics on every Bun platform; the previous plain-
|
|
26
|
+
// object + `in`-operator approach silently failed on windows-latest under Bun (CI #1).
|
|
27
|
+
const _cache: Map<string, SocketEntry> = new Map();
|
|
28
|
+
|
|
29
|
+
/** Reset in-memory socket cache. Exported for test cleanup only. */
|
|
30
|
+
export function _resetSocketCache(): void {
|
|
31
|
+
_cache.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getSocketRegistryDir(): string {
|
|
35
|
+
const dir = join(getDataDir(), "networks");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function socketPath(name: string): string {
|
|
41
|
+
return join(getSocketRegistryDir(), `${name}.json`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveEntry(entry: SocketEntry): void {
|
|
45
|
+
writeFileSync(socketPath(entry.name), JSON.stringify(entry, null, "\t") + "\n");
|
|
46
|
+
_cache.set(entry.name, entry);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function allocatePort(existing: SocketEntry[]): number {
|
|
50
|
+
if (existing.length === 0) return DEFAULT_START_PORT;
|
|
51
|
+
const maxPort = Math.max(...existing.map((e) => e.port));
|
|
52
|
+
return maxPort + 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createNamedSocket(
|
|
56
|
+
name: string,
|
|
57
|
+
opts?: { mode?: "mcast" | "listen-connect"; port?: number; mcastGroup?: string },
|
|
58
|
+
): SocketEntry {
|
|
59
|
+
if (_cache.has(name) || existsSync(socketPath(name))) {
|
|
60
|
+
throw new QuickCHRError("STATE_ERROR", `Named socket "${name}" already exists`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mode = opts?.mode ?? "mcast";
|
|
64
|
+
const port = opts?.port ?? allocatePort(listNamedSockets());
|
|
65
|
+
const mcastGroup = mode === "mcast" ? (opts?.mcastGroup ?? DEFAULT_MCAST_GROUP) : undefined;
|
|
66
|
+
|
|
67
|
+
const entry: SocketEntry = {
|
|
68
|
+
name,
|
|
69
|
+
mode,
|
|
70
|
+
port,
|
|
71
|
+
mcastGroup,
|
|
72
|
+
createdAt: new Date().toISOString(),
|
|
73
|
+
members: [],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
saveEntry(entry);
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getNamedSocket(name: string): SocketEntry | undefined {
|
|
81
|
+
const cached = _cache.get(name);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
try {
|
|
84
|
+
const entry = JSON.parse(readFileSync(socketPath(name), "utf-8")) as SocketEntry;
|
|
85
|
+
_cache.set(name, entry);
|
|
86
|
+
return entry;
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function listNamedSockets(): SocketEntry[] {
|
|
93
|
+
const dir = getSocketRegistryDir();
|
|
94
|
+
const merged = new Map(_cache);
|
|
95
|
+
try {
|
|
96
|
+
for (const file of readdirSync(dir)) {
|
|
97
|
+
if (!file.endsWith(".json")) continue;
|
|
98
|
+
const name = file.replace(/\.json$/, "");
|
|
99
|
+
if (!merged.has(name)) {
|
|
100
|
+
try {
|
|
101
|
+
const entry = JSON.parse(readFileSync(join(dir, file), "utf-8")) as SocketEntry;
|
|
102
|
+
merged.set(name, entry);
|
|
103
|
+
_cache.set(name, entry);
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch {}
|
|
108
|
+
return Array.from(merged.values());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function removeNamedSocket(name: string): boolean {
|
|
112
|
+
const wasCached = _cache.has(name);
|
|
113
|
+
_cache.delete(name);
|
|
114
|
+
const path = socketPath(name);
|
|
115
|
+
if (!existsSync(path)) return wasCached;
|
|
116
|
+
rmSync(path);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function addSocketMember(name: string, machineName: string): void {
|
|
121
|
+
const entry = getNamedSocket(name);
|
|
122
|
+
if (!entry) {
|
|
123
|
+
throw new QuickCHRError("STATE_ERROR", `Named socket "${name}" not found`);
|
|
124
|
+
}
|
|
125
|
+
if (!entry.members.includes(machineName)) {
|
|
126
|
+
entry.members.push(machineName);
|
|
127
|
+
saveEntry(entry);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function removeSocketMember(name: string, machineName: string): void {
|
|
132
|
+
const entry = getNamedSocket(name);
|
|
133
|
+
if (!entry) return;
|
|
134
|
+
entry.members = entry.members.filter((m) => m !== machineName);
|
|
135
|
+
if (entry.members.length === 0) {
|
|
136
|
+
removeNamedSocket(name);
|
|
137
|
+
} else {
|
|
138
|
+
saveEntry(entry);
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/lib/state.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine state persistence — JSON files in ~/.local/share/quickchr/machines/.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
|
|
6
|
+
import { join, } from "node:path";
|
|
7
|
+
import type { MachineState, } from "./types.ts";
|
|
8
|
+
import { QuickCHRError } from "./types.ts";
|
|
9
|
+
import { networkModeToConfigs } from "./network.ts";
|
|
10
|
+
|
|
11
|
+
/** Get the quickchr data directory root. Override with QUICKCHR_DATA_DIR env var. */
|
|
12
|
+
export function getDataDir(): string {
|
|
13
|
+
if (process.env.QUICKCHR_DATA_DIR) return process.env.QUICKCHR_DATA_DIR;
|
|
14
|
+
if (process.platform === "win32") {
|
|
15
|
+
const appData = process.env.LOCALAPPDATA || join(process.env.USERPROFILE || "", "AppData", "Local");
|
|
16
|
+
return join(appData, "quickchr");
|
|
17
|
+
}
|
|
18
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
19
|
+
return join(home, ".local", "share", "quickchr");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Get the machines directory. */
|
|
23
|
+
export function getMachinesDir(): string {
|
|
24
|
+
return join(getDataDir(), "machines");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Get the cache directory. */
|
|
28
|
+
export function getCacheDir(): string {
|
|
29
|
+
return join(getDataDir(), "cache");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Get a specific machine's directory. */
|
|
33
|
+
export function getMachineDir(name: string): string {
|
|
34
|
+
return join(getMachinesDir(), name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Get the path to a machine's state file. */
|
|
38
|
+
function machineJsonPath(name: string): string {
|
|
39
|
+
return join(getMachineDir(name), "machine.json");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ensure a directory exists. */
|
|
43
|
+
export function ensureDir(dir: string): void {
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Save machine state to disk. */
|
|
48
|
+
export function saveMachine(state: MachineState): void {
|
|
49
|
+
ensureDir(state.machineDir);
|
|
50
|
+
writeFileSync(machineJsonPath(state.name), JSON.stringify(state, null, "\t") + "\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Load machine state from disk. Returns undefined if not found. */
|
|
54
|
+
export function loadMachine(name: string): MachineState | undefined {
|
|
55
|
+
const path = machineJsonPath(name);
|
|
56
|
+
if (!existsSync(path)) return undefined;
|
|
57
|
+
const data = readFileSync(path, "utf-8");
|
|
58
|
+
const state = JSON.parse(data) as MachineState;
|
|
59
|
+
// Migrate legacy `network` field → `networks` array
|
|
60
|
+
if (!state.networks && (state as unknown as Record<string, unknown>).network) {
|
|
61
|
+
const legacy = (state as unknown as Record<string, unknown>).network as MachineState["networks"][0]["specifier"] | "user" | "vmnet-shared" | { type: "vmnet-bridge"; iface: string };
|
|
62
|
+
state.networks = networkModeToConfigs(legacy as Parameters<typeof networkModeToConfigs>[0]);
|
|
63
|
+
} else if (!state.networks) {
|
|
64
|
+
state.networks = [{ specifier: "user", id: "net0" }];
|
|
65
|
+
}
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Load all machine states. */
|
|
70
|
+
export function loadAllMachines(): MachineState[] {
|
|
71
|
+
const dir = getMachinesDir();
|
|
72
|
+
if (!existsSync(dir)) return [];
|
|
73
|
+
|
|
74
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
75
|
+
const machines: MachineState[] = [];
|
|
76
|
+
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!entry.isDirectory()) continue;
|
|
79
|
+
const state = loadMachine(entry.name);
|
|
80
|
+
if (state) machines.push(state);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return machines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Get all existing machine names. */
|
|
87
|
+
export function listMachineNames(): string[] {
|
|
88
|
+
const dir = getMachinesDir();
|
|
89
|
+
if (!existsSync(dir)) return [];
|
|
90
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
91
|
+
.filter((e) => e.isDirectory())
|
|
92
|
+
.map((e) => e.name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Get all port bases currently in use by existing machines. */
|
|
96
|
+
export function getUsedPortBases(): number[] {
|
|
97
|
+
return loadAllMachines().map((m) => m.portBase);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Delete a machine and all its files. */
|
|
101
|
+
export function removeMachine(name: string): void {
|
|
102
|
+
const dir = getMachineDir(name);
|
|
103
|
+
if (!existsSync(dir)) {
|
|
104
|
+
throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
|
|
105
|
+
}
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Update the status of a machine. */
|
|
110
|
+
export function updateMachineStatus(
|
|
111
|
+
name: string,
|
|
112
|
+
status: MachineState["status"],
|
|
113
|
+
pid?: number,
|
|
114
|
+
): void {
|
|
115
|
+
const state = loadMachine(name);
|
|
116
|
+
if (!state) {
|
|
117
|
+
throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
|
|
118
|
+
}
|
|
119
|
+
state.status = status;
|
|
120
|
+
state.pid = pid;
|
|
121
|
+
if (status === "running") {
|
|
122
|
+
state.lastStartedAt = new Date().toISOString();
|
|
123
|
+
}
|
|
124
|
+
saveMachine(state);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Check if a machine is actually still running (verify PID). */
|
|
128
|
+
export function isMachineRunning(state: MachineState): boolean {
|
|
129
|
+
if (state.status !== "running" || !state.pid) return false;
|
|
130
|
+
try {
|
|
131
|
+
process.kill(state.pid, 0);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Refresh status for all machines (check PIDs). */
|
|
139
|
+
export function refreshAllStatuses(): MachineState[] {
|
|
140
|
+
const machines = loadAllMachines();
|
|
141
|
+
for (const m of machines) {
|
|
142
|
+
if (m.status === "running" && !isMachineRunning(m)) {
|
|
143
|
+
m.status = "stopped";
|
|
144
|
+
m.pid = undefined;
|
|
145
|
+
saveMachine(m);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return machines;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Prune old cached images older than the given number of days. */
|
|
152
|
+
export function pruneCache(maxAgeDays: number = 30): number {
|
|
153
|
+
const cacheDir = getCacheDir();
|
|
154
|
+
if (!existsSync(cacheDir)) return 0;
|
|
155
|
+
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
|
158
|
+
let removed = 0;
|
|
159
|
+
|
|
160
|
+
const entries = readdirSync(cacheDir);
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const fullPath = join(cacheDir, entry);
|
|
163
|
+
try {
|
|
164
|
+
const stat = statSync(fullPath);
|
|
165
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
166
|
+
rmSync(fullPath);
|
|
167
|
+
removed++;
|
|
168
|
+
}
|
|
169
|
+
} catch { /* skip */ }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return removed;
|
|
173
|
+
}
|