@floless/app 0.5.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/bin/floless.mjs +13 -0
- package/dist/floless-server.cjs +56801 -0
- package/dist/skills/floless-app-bridge/SKILL.md +80 -0
- package/dist/skills/floless-app-routines/SKILL.md +168 -0
- package/dist/skills/floless-app-routines/references/routines-api.md +130 -0
- package/dist/skills/floless-app-workflows/SKILL.md +352 -0
- package/dist/skills/floless-app-workflows/references/dev-server-and-run-trace.md +119 -0
- package/dist/skills/floless-app-workflows/references/exec-contract.md +104 -0
- package/dist/web/app.css +2129 -0
- package/dist/web/app.js +1334 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/aware.js +3274 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.svg +98 -0
- package/dist/web/index.html +484 -0
- package/launch.mjs +543 -0
- package/package.json +43 -0
- package/teardown.mjs +128 -0
package/launch.mjs
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// floless.app launcher — open / stop / restart the local server with one click.
|
|
2
|
+
// Dependency-free (Node built-ins only), Windows-first (the project is Windows).
|
|
3
|
+
//
|
|
4
|
+
// node launch.mjs open (default) start-if-needed, then open the browser
|
|
5
|
+
// node launch.mjs stop kill whatever is serving the port
|
|
6
|
+
// node launch.mjs restart stop, then open
|
|
7
|
+
//
|
|
8
|
+
// Health is checked on 127.0.0.1 (Node's resolver doesn't do the browser's
|
|
9
|
+
// *.localhost trick); the BROWSER is pointed at the branded host, which Chrome/
|
|
10
|
+
// Edge/Firefox resolve to 127.0.0.1 with no hosts-file edit.
|
|
11
|
+
import { spawn, execSync, execFileSync } from 'node:child_process';
|
|
12
|
+
import { dirname, join, basename } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import { createInterface } from 'node:readline';
|
|
17
|
+
import { rotateLog, openLogFd } from './log.mjs';
|
|
18
|
+
import { supervisorPidsToKill, teardownDecision, awareIsPresent, RUN_KEY, RUN_VALUE, PROTOCOL_KEY } from './teardown.mjs';
|
|
19
|
+
import { resolveRealInstallExe } from './install-path.mjs';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const PORT = Number(process.env.PORT ?? 4317);
|
|
23
|
+
const HEALTH_URL = `http://127.0.0.1:${PORT}/api/health`;
|
|
24
|
+
const BROWSER_URL = `http://floless.localhost:${PORT}`;
|
|
25
|
+
const isWin = process.platform === 'win32';
|
|
26
|
+
|
|
27
|
+
// Write a line, tolerating a dead stdout. The packaged exe is GUI-subsystem (the
|
|
28
|
+
// build flips CUI→GUI so no console window appears at logon — see build/make-sea.mjs);
|
|
29
|
+
// a GUI-subsystem process launched with no redirected stdout has an invalid fd 1, and
|
|
30
|
+
// a raw write there can throw EBADF/EPIPE. The long-lived processes route stdio to the
|
|
31
|
+
// logfile, but the first Scheduled-Task launch does not — so never let a log() write
|
|
32
|
+
// crash the supervisor. (Defensive; matches the "never fail silently, never crash on
|
|
33
|
+
// logging" intent.)
|
|
34
|
+
const log = (m) => { try { process.stdout.write(`floless: ${m}\n`); } catch { /* dead stdout (GUI subsystem, no console) */ } };
|
|
35
|
+
|
|
36
|
+
// One health probe → resolves true iff the server answers 200 quickly.
|
|
37
|
+
function ping() {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const req = http.get(HEALTH_URL, { timeout: 1500 }, (res) => {
|
|
40
|
+
res.resume();
|
|
41
|
+
resolve(res.statusCode === 200);
|
|
42
|
+
});
|
|
43
|
+
req.on('error', () => resolve(false));
|
|
44
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Probe the running server's build version via /api/health (it returns appVersion — the
|
|
49
|
+
// installed build, "so it's scriptable"). Returns null when unreachable, non-JSON, or not
|
|
50
|
+
// a floless server — in which case we never touch it.
|
|
51
|
+
function probeVersion() {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const req = http.get(HEALTH_URL, { timeout: 1500 }, (res) => {
|
|
54
|
+
let body = '';
|
|
55
|
+
res.on('data', (c) => { body += c; });
|
|
56
|
+
res.on('end', () => { try { resolve(JSON.parse(body).appVersion || null); } catch { resolve(null); } });
|
|
57
|
+
});
|
|
58
|
+
req.on('error', () => resolve(null));
|
|
59
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Compare dotted numeric versions: 1 if a>b, -1 if a<b, 0 if equal.
|
|
64
|
+
export function cmpVersion(a, b) {
|
|
65
|
+
const pa = String(a ?? '').split('.').map((n) => parseInt(n, 10) || 0);
|
|
66
|
+
const pb = String(b ?? '').split('.').map((n) => parseInt(n, 10) || 0);
|
|
67
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
68
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
69
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// "Newest wins": take over the port ONLY when a known-older floless.app build is serving it
|
|
75
|
+
// AND we know our own version — never kill an unknown/non-floless server, never downgrade.
|
|
76
|
+
export function shouldTakeOver(runningVersion, selfVersion) {
|
|
77
|
+
if (!runningVersion || !selfVersion) return false;
|
|
78
|
+
return cmpVersion(selfVersion, runningVersion) > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// This launcher's own build version (set by runAction from the dispatcher), so cmdOpen can
|
|
82
|
+
// compare against whatever is already serving the port.
|
|
83
|
+
let _selfVersion = null;
|
|
84
|
+
|
|
85
|
+
async function waitHealthy(timeoutMs = 30000) {
|
|
86
|
+
const deadline = Date.now() + timeoutMs;
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
if (await ping()) return true;
|
|
89
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolve how to start the server from wherever this launcher is installed:
|
|
95
|
+
// 1. Packaged SEA exe → spawn SELF with --serve (the main.ts dispatcher serves).
|
|
96
|
+
// 2. npm install → run the prebuilt bundle (dist/floless-server.cjs --serve).
|
|
97
|
+
// 3. Repo dev only → `npm run start` (= tsx index.ts); npm needs shell:true on
|
|
98
|
+
// Windows (Node 20+ CVE-2024-27980 hardening). Never used by an install/exe.
|
|
99
|
+
// In every case the server is a detached daemon that outlives this launcher.
|
|
100
|
+
function resolveServerStart() {
|
|
101
|
+
const packaged = /flolessapp\.exe$/i.test(process.execPath); // SEA exe → process.execPath is FlolessApp.exe
|
|
102
|
+
if (packaged) return { cmd: process.execPath, args: ['--serve'], shell: false };
|
|
103
|
+
const bundle = join(__dirname, 'dist', 'floless-server.cjs');
|
|
104
|
+
if (existsSync(bundle)) return { cmd: process.execPath, args: [bundle, '--serve'], shell: false };
|
|
105
|
+
return { cmd: 'npm', args: ['run', 'start'], shell: isWin }; // repo dev fallback
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function startServerDetached() {
|
|
109
|
+
const { cmd, args, shell } = resolveServerStart();
|
|
110
|
+
// Route the detached daemon's stdout+stderr to the rotating logfile (not 'ignore')
|
|
111
|
+
// so a silent death leaves a trace; the server also appends crash stacks there. If
|
|
112
|
+
// the log can't be opened, fall back to 'ignore' rather than failing the launch.
|
|
113
|
+
rotateLog();
|
|
114
|
+
const fd = openLogFd();
|
|
115
|
+
const child = spawn(cmd, args, {
|
|
116
|
+
cwd: __dirname,
|
|
117
|
+
detached: true,
|
|
118
|
+
stdio: fd == null ? 'ignore' : ['ignore', fd, fd],
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
shell,
|
|
121
|
+
});
|
|
122
|
+
child.unref();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function openBrowser(url) {
|
|
126
|
+
if (isWin) spawn('cmd', ['/c', 'start', '', url], { windowsHide: true, detached: true }).unref();
|
|
127
|
+
else spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { detached: true }).unref();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Kill whatever is LISTENING on PORT (robust: works no matter how the server was
|
|
131
|
+
// started — launcher, `npm run dev`, or a bare `tsx`). Returns true if it killed.
|
|
132
|
+
function stopServer() {
|
|
133
|
+
if (!isWin) {
|
|
134
|
+
try { execSync(`bash -lc "fuser -k ${PORT}/tcp"`, { stdio: 'ignore' }); return true; } catch { return false; }
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const ps =
|
|
138
|
+
`$p = Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue ` +
|
|
139
|
+
`| Select-Object -Expand OwningProcess -Unique; ` +
|
|
140
|
+
`if ($p) { $p | ForEach-Object { taskkill /PID $_ /T /F } } else { exit 9 }`;
|
|
141
|
+
execSync(`powershell -NoProfile -Command "${ps}"`, { stdio: 'ignore' });
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
return false; // exit 9 → nothing was listening
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Start the detached server if it isn't already listening; block until healthy.
|
|
149
|
+
// Shared by `open` (which then opens the browser) and `start` (which does not).
|
|
150
|
+
async function ensureServerUp() {
|
|
151
|
+
if (await ping()) return;
|
|
152
|
+
log('starting server…');
|
|
153
|
+
startServerDetached();
|
|
154
|
+
if (!(await waitHealthy())) {
|
|
155
|
+
log(`server did not become healthy on ${HEALTH_URL} within 30s — check for errors with "npm run dev"`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
log('server up');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function cmdOpen() {
|
|
162
|
+
if (await ping()) {
|
|
163
|
+
// Something is already serving the port. If it's a DIFFERENT, OLDER floless.app build than
|
|
164
|
+
// this one, take it over ("newest wins") so the version the user just launched is the one
|
|
165
|
+
// they get — instead of silently opening the stale instance (e.g. a leftover dev server).
|
|
166
|
+
const running = await probeVersion();
|
|
167
|
+
if (shouldTakeOver(running, _selfVersion)) {
|
|
168
|
+
log(`floless.app v${running} is already running on ${PORT}; this build is v${_selfVersion} — taking over`);
|
|
169
|
+
stopServer();
|
|
170
|
+
await new Promise((r) => setTimeout(r, 500)); // let the OS release the port
|
|
171
|
+
await ensureServerUp();
|
|
172
|
+
} else {
|
|
173
|
+
log(`already running${running ? ` (v${running})` : ''} — opening browser`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
await ensureServerUp();
|
|
177
|
+
}
|
|
178
|
+
log(`opening ${BROWSER_URL}`);
|
|
179
|
+
openBrowser(BROWSER_URL);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// `start` (the in-tab recovery trigger, fired as floless://start from a tab whose
|
|
183
|
+
// server died): start-if-needed but NEVER open a browser — the tab that fired this
|
|
184
|
+
// is already open and heals itself via its 5s health poll. This is the headless
|
|
185
|
+
// counterpart to `open`, and the action autostart-on-login uses too.
|
|
186
|
+
export async function cmdStart() {
|
|
187
|
+
if (await ping()) { log('already running'); return; }
|
|
188
|
+
await ensureServerUp();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// `supervise` — the login-autostart entry: KEEP the local server alive. Starts it if
|
|
192
|
+
// down, then polls every few seconds and respawns it within ~5s whenever it dies.
|
|
193
|
+
// This is the ROBUST recovery path: pure local process management — no browser, no
|
|
194
|
+
// floless:// protocol, no admin. (The in-tab Restart button depends on the browser
|
|
195
|
+
// honoring the floless:// handler, which locked-down / corporate-managed browsers can
|
|
196
|
+
// silently refuse; the supervisor does not depend on the browser at all.) An open tab
|
|
197
|
+
// heals its own UI via the 5s health poll once the server is back. Never returns —
|
|
198
|
+
// this process lives for the whole login session.
|
|
199
|
+
const SUPERVISE_POLL_MS = 3000;
|
|
200
|
+
// Env-flag self-respawn sentinel. The packaged exe is GUI-subsystem (build/make-sea.mjs
|
|
201
|
+
// flips CUI→GUI), so no console window ever appears — but we still re-spawn the
|
|
202
|
+
// watchdog DETACHED, then exit: that lets the launching process (the logon Scheduled
|
|
203
|
+
// Task) COMPLETE immediately (task returns to "Ready" instead of showing "Running" for
|
|
204
|
+
// the whole session) while the detached child (FLOLESS_SUPERVISE_RESPAWNED=1) carries
|
|
205
|
+
// the actual watchdog loop, decoupled from the task's lifetime. Set internally; do NOT
|
|
206
|
+
// set it from outside (a user-set value would short-circuit the detach step).
|
|
207
|
+
const SUPERVISE_RESPAWN_ENV = 'FLOLESS_SUPERVISE_RESPAWNED';
|
|
208
|
+
|
|
209
|
+
export async function cmdSupervise() {
|
|
210
|
+
// Only the packaged SEA exe (autostart-wired by the Velopack hook) takes the
|
|
211
|
+
// detached-respawn path — its argv is `<exe> --supervise`, which the dispatcher in
|
|
212
|
+
// main.ts handles. The npm channel has process.execPath = node.exe and respawning
|
|
213
|
+
// `node --supervise` would fail with `bad option: --supervise`; autostart is also
|
|
214
|
+
// never wired in that channel, so a foreground loop with visible logs is the right
|
|
215
|
+
// dev behavior. Detect the SEA channel by the exe basename (`FlolessApp.exe`).
|
|
216
|
+
const isSeaChannel = /flolessapp\.exe$/i.test(process.execPath);
|
|
217
|
+
if (isSeaChannel && !process.env[SUPERVISE_RESPAWN_ENV]) {
|
|
218
|
+
// First entry on the packaged SEA: re-spawn ourselves detached, then exit, so the
|
|
219
|
+
// logon Scheduled Task that launched us COMPLETES immediately (returns to "Ready")
|
|
220
|
+
// while the detached child carries the watchdog loop for the session. The exe is
|
|
221
|
+
// GUI-subsystem (no console window at all — build/make-sea.mjs), so this is purely
|
|
222
|
+
// about decoupling from the task's lifetime, not console-hiding. Stdout/stderr
|
|
223
|
+
// route to the rotating logfile so a supervisor crash leaves a trail — `stdio:
|
|
224
|
+
// 'ignore'` would silently swallow every log() line + any uncaught crash (dual-
|
|
225
|
+
// review finding #1, 2026-05-30). If the logfile can't be opened we fall back to
|
|
226
|
+
// 'ignore' rather than failing the respawn.
|
|
227
|
+
rotateLog();
|
|
228
|
+
const fd = openLogFd();
|
|
229
|
+
const child = spawn(process.execPath, ['--supervise'], {
|
|
230
|
+
detached: true,
|
|
231
|
+
stdio: fd == null ? 'ignore' : ['ignore', fd, fd],
|
|
232
|
+
windowsHide: true,
|
|
233
|
+
env: { ...process.env, [SUPERVISE_RESPAWN_ENV]: '1' },
|
|
234
|
+
});
|
|
235
|
+
child.unref();
|
|
236
|
+
log('supervisor re-spawned detached');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
log('supervisor up — keeping the server alive');
|
|
240
|
+
for (;;) {
|
|
241
|
+
if (!(await ping())) {
|
|
242
|
+
log('server not responding — (re)starting');
|
|
243
|
+
// Spawn + BLOCK on waitHealthy (≤30s) before the next iteration: this serializes
|
|
244
|
+
// the loop so one outage = one spawn (no self-race), and a persistently-failing
|
|
245
|
+
// server retries on a ~33s cadence rather than churning. We deliberately do NOT
|
|
246
|
+
// route through ensureServerUp() — it process.exit(1)s on failure, which would
|
|
247
|
+
// kill the supervisor itself. If a spawn ever races another launcher (e.g. a
|
|
248
|
+
// manual floless://start), the loser's app.listen() throws EADDRINUSE and that
|
|
249
|
+
// process exits cleanly — never two live servers.
|
|
250
|
+
startServerDetached();
|
|
251
|
+
log((await waitHealthy()) ? 'server up' : 'server not healthy yet — will retry');
|
|
252
|
+
}
|
|
253
|
+
await new Promise((r) => setTimeout(r, SUPERVISE_POLL_MS));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function cmdStop() {
|
|
258
|
+
// Decide "is it running?" from the port itself (Get-NetTCPConnection / fuser),
|
|
259
|
+
// NOT an HTTP ping: a transient 1.5s health-timeout used to make stop a no-op
|
|
260
|
+
// while the server was up. stopServer() is idempotent and returns false only
|
|
261
|
+
// when nothing was actually listening (Windows exit 9 / fuser no-match).
|
|
262
|
+
if (!stopServer()) { log('not running — nothing to stop'); return; }
|
|
263
|
+
log(`stopping server on port ${PORT}…`);
|
|
264
|
+
// Confirm the port is freed (taskkill /T /F is synchronous; give the OS a moment).
|
|
265
|
+
for (let i = 0; i < 10; i++) {
|
|
266
|
+
if (!(await ping())) { log('stopped'); return; }
|
|
267
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
268
|
+
}
|
|
269
|
+
log('warning: a process may still be holding the port');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- teardown (the engine behind `uninstall`) --------------------------------------
|
|
273
|
+
//
|
|
274
|
+
// Parse the uninstall flag tail (argv.slice(3)) into the two booleans teardownDecision
|
|
275
|
+
// understands. Pure + exported so the CLI shim and main.ts share one parser and it can
|
|
276
|
+
// be unit-tested without side effects. Unknown tokens are ignored (lenient).
|
|
277
|
+
export function parseTeardownFlags(argv = []) {
|
|
278
|
+
return {
|
|
279
|
+
purge: argv.includes('--purge'),
|
|
280
|
+
keepAware: argv.includes('--keep-aware'),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Parse `Get-CimInstance Win32_Process` JSON (ProcessId + CommandLine) into the
|
|
285
|
+
// {pid, cmd} shape supervisorPidsToKill expects. Pure + exported for unit tests.
|
|
286
|
+
// PowerShell emits a single object (not an array) when exactly one process matches,
|
|
287
|
+
// and CommandLine can be null for processes we can't read — both are tolerated.
|
|
288
|
+
export function parseProcessList(json) {
|
|
289
|
+
let parsed;
|
|
290
|
+
try { parsed = JSON.parse(json || '[]'); } catch { return []; }
|
|
291
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
292
|
+
return arr
|
|
293
|
+
.filter((p) => p && p.ProcessId != null)
|
|
294
|
+
.map((p) => ({ pid: Number(p.ProcessId), cmd: String(p.CommandLine ?? '') }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Enumerate running processes as [{pid, cmd}]. Win32-only (uses Win32_Process);
|
|
298
|
+
// returns [] elsewhere or on any failure (best-effort — teardown must never throw).
|
|
299
|
+
function enumerateProcesses() {
|
|
300
|
+
if (!isWin) return [];
|
|
301
|
+
try {
|
|
302
|
+
const ps =
|
|
303
|
+
'Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress';
|
|
304
|
+
const out = execSync(`powershell -NoProfile -Command "${ps}"`, {
|
|
305
|
+
encoding: 'utf8',
|
|
306
|
+
windowsHide: true,
|
|
307
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
308
|
+
});
|
|
309
|
+
return parseProcessList(out);
|
|
310
|
+
} catch {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Kill the --supervise watchdog FIRST (Codex B1) so it can't respawn the server in
|
|
316
|
+
// the gap before stopServer(). Matches THIS app's exe + the supervise action, never
|
|
317
|
+
// process.pid (self-kill guard lives in supervisorPidsToKill). Idempotent; win32-only.
|
|
318
|
+
//
|
|
319
|
+
// SAFETY (review #1): in the npm channel process.execPath is the GENERIC node.exe, so
|
|
320
|
+
// matching on it alone could kill ANY `node … supervise` process (e.g. a third-party
|
|
321
|
+
// watchdog). When we detect that channel, also require the launcher script (this
|
|
322
|
+
// launch.mjs) in the command line — our supervisor runs `node … launch.mjs supervise`.
|
|
323
|
+
// The packaged exe (unique FlolessApp.exe) passes no scriptMatch — its match is exact.
|
|
324
|
+
function killSupervisor() {
|
|
325
|
+
if (!isWin) return;
|
|
326
|
+
const isNpmChannel = /^node(\.exe)?$/i.test(basename(process.execPath));
|
|
327
|
+
const scriptMatch = isNpmChannel ? basename(fileURLToPath(import.meta.url)) : undefined; // 'launch.mjs'
|
|
328
|
+
// MSIX-container case: the uninstaller running INSIDE the container sees its own
|
|
329
|
+
// process.execPath as the bind-link VIRTUAL path, but a supervisor we launched at
|
|
330
|
+
// logon (via a Run key registered with the un-virtualized REAL path) shows the
|
|
331
|
+
// REAL path in its command line. supervisorPidsToKill accepts an array of needles
|
|
332
|
+
// and matches if cmd contains ANY of them — pass both so the kill works whether
|
|
333
|
+
// the supervisor was launched at the virtual or real path. (For non-container
|
|
334
|
+
// installs the two paths are identical → unchanged behavior.)
|
|
335
|
+
const realExe = resolveRealInstallExe(process.execPath);
|
|
336
|
+
const exeMatch = realExe === process.execPath ? process.execPath : [process.execPath, realExe];
|
|
337
|
+
const pids = supervisorPidsToKill(enumerateProcesses(), process.pid, exeMatch, scriptMatch);
|
|
338
|
+
for (const pid of pids) {
|
|
339
|
+
log(`stopping supervisor (pid ${pid})…`);
|
|
340
|
+
// execFileSync (no shell) with an argv array — pid is already Number()-coerced in
|
|
341
|
+
// parseProcessList, and avoiding the shell rules out any injection entirely.
|
|
342
|
+
try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch { /* already gone */ }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// cmdTeardown — stop floless.app's live footprint in the ONLY safe order (Codex B1/B2/S2):
|
|
347
|
+
// 1. kill the --supervise watchdog (else it respawns the server mid-teardown),
|
|
348
|
+
// 2. stop the server on the port,
|
|
349
|
+
// 3. a bounded (~3s) re-check over the FULL window: a slow-binding respawn or a
|
|
350
|
+
// freshly-spawned supervisor can appear at any tick within the supervisor's ~5s
|
|
351
|
+
// respawn cadence. So we poll the WHOLE window (never break early on a single
|
|
352
|
+
// miss — review B1/#2): on any tick the port answers, re-kill the supervisor
|
|
353
|
+
// (it may be the one that just respawned the server) THEN stop the server again.
|
|
354
|
+
// Each step is idempotent + best-effort; cmdStop() is left untouched (the update flow
|
|
355
|
+
// at main.ts depends on its port-only behavior).
|
|
356
|
+
export async function cmdTeardown() {
|
|
357
|
+
killSupervisor();
|
|
358
|
+
stopServer();
|
|
359
|
+
// Bounded respawn-catch: poll the port for the full ~3s window. Do NOT break on a
|
|
360
|
+
// miss — a respawn may bind a few hundred ms after a transient gap, and a fresh
|
|
361
|
+
// supervisor may re-appear; only the deadline ends the loop.
|
|
362
|
+
const deadline = Date.now() + 3000;
|
|
363
|
+
while (Date.now() < deadline) {
|
|
364
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
365
|
+
if (await ping()) {
|
|
366
|
+
log('server respawned after kill — re-killing supervisor and stopping again');
|
|
367
|
+
killSupervisor(); // re-kill: a freshly-spawned watchdog is what brought it back
|
|
368
|
+
stopServer();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// After the window, if the port is STILL answering something is holding it (a
|
|
372
|
+
// respawn we couldn't outrun) — surface it instead of an unconditional success
|
|
373
|
+
// line (review #3). Otherwise report the clean stop.
|
|
374
|
+
if (await ping()) log('warning: a process may still be holding the port');
|
|
375
|
+
else log('server and supervisor stopped');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function cmdRestart() {
|
|
379
|
+
await cmdStop();
|
|
380
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
381
|
+
await cmdOpen();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Read a JSON API endpoint on the local server.
|
|
385
|
+
function apiJson(path, method = 'GET') {
|
|
386
|
+
return new Promise((resolve, reject) => {
|
|
387
|
+
const req = http.request(`http://127.0.0.1:${PORT}${path}`, { method, timeout: 5000 }, (res) => {
|
|
388
|
+
let body = '';
|
|
389
|
+
res.on('data', (c) => (body += c));
|
|
390
|
+
res.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
|
|
391
|
+
});
|
|
392
|
+
req.on('error', reject);
|
|
393
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
394
|
+
req.end();
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// `floless login` — sign in to floless.io so the subscription gate opens. The
|
|
399
|
+
// server owns the licensing (licensing.ts); the launcher just starts it if needed,
|
|
400
|
+
// triggers the browser handoff, and polls the gate state until it goes valid.
|
|
401
|
+
export async function cmdLogin() {
|
|
402
|
+
if (!(await ping())) {
|
|
403
|
+
log('starting server…');
|
|
404
|
+
startServerDetached();
|
|
405
|
+
if (!(await waitHealthy())) { log('server did not start — try "npm run dev" to see errors'); process.exit(1); }
|
|
406
|
+
}
|
|
407
|
+
const started = await apiJson('/api/license/start', 'POST').catch(() => ({}));
|
|
408
|
+
log(`opening sign-in in your browser${started.signInUrl ? `: ${started.signInUrl}` : ''}`);
|
|
409
|
+
log('waiting for sign-in to complete…');
|
|
410
|
+
const deadline = Date.now() + 185000;
|
|
411
|
+
while (Date.now() < deadline) {
|
|
412
|
+
const s = await apiJson('/api/license/status').catch(() => ({}));
|
|
413
|
+
if (s.state === 'valid' || s.state === 'offline-grace') { log('signed in — subscription active'); return; }
|
|
414
|
+
if (s.state === 'expired') { log(`signed in, but the subscription is expired — subscribe at ${s.signInUrl ?? 'floless.io'}`); return; }
|
|
415
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
416
|
+
}
|
|
417
|
+
log('sign-in timed out — run "floless login" again to retry');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Map a bare action ("open"/"stop"/"restart") OR a floless:// URI (how the Windows
|
|
421
|
+
// protocol handler invokes us, e.g. floless://open) to an action name. For a URI
|
|
422
|
+
// the action is the host part: floless://restart → "restart"; floless:// → open.
|
|
423
|
+
export function parseAction(arg) {
|
|
424
|
+
let cmd = (arg || 'open').toLowerCase();
|
|
425
|
+
if (cmd.startsWith('floless:')) {
|
|
426
|
+
try { cmd = (new URL(arg).hostname || 'open').toLowerCase(); } catch { cmd = 'open'; }
|
|
427
|
+
}
|
|
428
|
+
return cmd;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// `floless update` via the npm bin lands here (bin/floless.mjs → launch.mjs).
|
|
432
|
+
// The npm install has no Velopack package to self-update — npm owns versions — so
|
|
433
|
+
// this is a deliberate no-op with the right instruction. The packaged exe never
|
|
434
|
+
// reaches this: main.ts handles `update` before delegating to runAction.
|
|
435
|
+
async function cmdUpdate() {
|
|
436
|
+
log('this is the npm build — update it with "npm i -g @floless/app"');
|
|
437
|
+
log('(the installer build self-updates via "floless-app update")');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Remove the autostart Run value + the floless:// scheme key. Idempotent + win32-only
|
|
441
|
+
// (reg.exe). Uses the SAME key strings autostart.ts/protocol.ts add (via teardown.mjs)
|
|
442
|
+
// so install and uninstall can't drift. Each delete is best-effort: an absent key is
|
|
443
|
+
// the success case (nothing to remove), so a non-zero exit is swallowed.
|
|
444
|
+
function removeRegistryFootprint() {
|
|
445
|
+
if (!isWin) return;
|
|
446
|
+
try { execFileSync('reg', ['delete', RUN_KEY, '/v', RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true }); } catch { /* value absent */ }
|
|
447
|
+
try { execFileSync('reg', ['delete', PROTOCOL_KEY, '/f'], { stdio: 'ignore', windowsHide: true }); } catch { /* key absent */ }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Ask a yes/no question on the TTY; default NO on empty input (the [y/N] convention).
|
|
451
|
+
// Only the caller's `decision.prompt` (which is already TTY-gated) reaches this.
|
|
452
|
+
function promptYesNo(question) {
|
|
453
|
+
return new Promise((resolve) => {
|
|
454
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
455
|
+
rl.question(`${question} `, (answer) => {
|
|
456
|
+
rl.close();
|
|
457
|
+
resolve(/^y(es)?$/i.test((answer ?? '').trim()));
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// `floless-app uninstall [--purge|--keep-aware]` — the npm channel. Tears down
|
|
463
|
+
// floless.app's own footprint (server + supervisor + autostart key + floless://),
|
|
464
|
+
// then per the decision removes the auto-installed AWARE runtime, and finally prints
|
|
465
|
+
// the one command that removes the app package itself (a running process can't npm
|
|
466
|
+
// rm -g itself cleanly mid-run). main.ts handles the exe channel + Velopack hook.
|
|
467
|
+
export async function cmdUninstall(flags = {}) {
|
|
468
|
+
// teardownDecision throws on conflicting --purge + --keep-aware — let it surface as
|
|
469
|
+
// a clean error (the CLI shim's catch logs it and exits 1) rather than guessing.
|
|
470
|
+
const decision = teardownDecision({ ...flags, isTTY: process.stdin.isTTY });
|
|
471
|
+
|
|
472
|
+
log('stopping the server and supervisor…');
|
|
473
|
+
await cmdTeardown();
|
|
474
|
+
|
|
475
|
+
log('removing the autostart entry and floless:// scheme…');
|
|
476
|
+
removeRegistryFootprint();
|
|
477
|
+
|
|
478
|
+
let removeAware = decision.removeAware;
|
|
479
|
+
if (decision.prompt) {
|
|
480
|
+
removeAware = await promptYesNo('Also remove the AWARE runtime (@aware-aeco/cli)? [y/N]');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (removeAware) {
|
|
484
|
+
log('removing the AWARE runtime — this will affect ANY other tool that uses @aware-aeco/cli.');
|
|
485
|
+
try {
|
|
486
|
+
execSync('npm uninstall -g @aware-aeco/cli', { stdio: 'inherit', shell: isWin });
|
|
487
|
+
} catch {
|
|
488
|
+
log('warning: "npm uninstall -g @aware-aeco/cli" failed — see the output above.');
|
|
489
|
+
}
|
|
490
|
+
// S4: a clean uninstall exit does NOT prove removal — verify by parsing
|
|
491
|
+
// `npm ls … --json`'s `dependencies` key (awareIsPresent), matching the
|
|
492
|
+
// exe channel's path in main.ts:removeAwarePerDecision (single source: both
|
|
493
|
+
// channels share the same JSON-verify, never diverge on exit-code semantics
|
|
494
|
+
// that vary across npm versions for a missing global). npm ls exits non-zero
|
|
495
|
+
// when the package is absent, so capture stdout from BOTH the success path
|
|
496
|
+
// and the thrown error; an unreadable result is treated as removed (defensive).
|
|
497
|
+
let lsJson = '';
|
|
498
|
+
try {
|
|
499
|
+
lsJson = execSync('npm ls -g @aware-aeco/cli --depth=0 --json', {
|
|
500
|
+
encoding: 'utf8',
|
|
501
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
502
|
+
shell: isWin,
|
|
503
|
+
});
|
|
504
|
+
} catch (err) {
|
|
505
|
+
lsJson = String(err?.stdout ?? '');
|
|
506
|
+
}
|
|
507
|
+
const stillPresent = awareIsPresent(lsJson);
|
|
508
|
+
if (stillPresent) {
|
|
509
|
+
log('AWARE may still be installed — remove it manually with: npm uninstall -g @aware-aeco/cli');
|
|
510
|
+
log('(if that fails with a permission error, retry in an elevated shell).');
|
|
511
|
+
} else {
|
|
512
|
+
log('AWARE runtime removed.');
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
log('keeping the AWARE runtime (@aware-aeco/cli). Remove it later with: npm uninstall -g @aware-aeco/cli');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
log('done. Finally, remove floless.app itself with: npm uninstall -g @floless/app');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const ACTIONS = { open: cmdOpen, start: cmdStart, supervise: cmdSupervise, stop: cmdStop, restart: cmdRestart, login: cmdLogin, update: cmdUpdate, uninstall: cmdUninstall };
|
|
522
|
+
|
|
523
|
+
// Run one launcher action by name/URI. Shared by the npm bin (CLI shim below) and
|
|
524
|
+
// the packaged exe dispatcher (main.ts), so both surfaces behave identically. The
|
|
525
|
+
// flag tail (argv.slice(3)) is parsed once here and passed to the action; every
|
|
526
|
+
// existing action takes no params and simply ignores it (only `uninstall` reads it).
|
|
527
|
+
export async function runAction(arg, flagArgv = [], selfVersion = null) {
|
|
528
|
+
_selfVersion = selfVersion;
|
|
529
|
+
const cmd = parseAction(arg);
|
|
530
|
+
const action = ACTIONS[cmd];
|
|
531
|
+
if (!action) { log(`unknown command "${cmd}" — use: open | start | supervise | stop | restart | login | update | uninstall`); process.exit(2); }
|
|
532
|
+
await action(parseTeardownFlags(flagArgv));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// CLI shim: run only when this file is the actual entry (`node launch.mjs <arg>`),
|
|
536
|
+
// NOT when bundled into / imported by the dispatcher (main.ts). We key off the
|
|
537
|
+
// entry's basename rather than import.meta.url: esbuild collapses every module's
|
|
538
|
+
// import.meta.url to the bundle file, so a URL compare would wrongly fire inside
|
|
539
|
+
// the bundle. The bundle/exe entry is never named "launch.mjs".
|
|
540
|
+
const entry = basename(process.argv[1] ?? '').toLowerCase();
|
|
541
|
+
if (entry === 'launch.mjs') {
|
|
542
|
+
runAction(process.argv[2], process.argv.slice(3)).catch((e) => { log(`error: ${e?.message ?? e}`); process.exit(1); });
|
|
543
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@floless/app",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"floless-app": "bin/floless.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"launch.mjs",
|
|
12
|
+
"teardown.mjs",
|
|
13
|
+
"dist/",
|
|
14
|
+
"package.json"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "tsx watch main.ts --serve",
|
|
21
|
+
"start": "tsx main.ts --serve",
|
|
22
|
+
"app": "node launch.mjs open",
|
|
23
|
+
"app:stop": "node launch.mjs stop",
|
|
24
|
+
"app:restart": "node launch.mjs restart",
|
|
25
|
+
"build": "node build/bundle.mjs",
|
|
26
|
+
"build:exe": "node build/bundle.mjs && node build/make-sea.mjs",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepack": "npm run build"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@fastify/static": "^8.0.0",
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"chokidar": "^4.0.0",
|
|
34
|
+
"esbuild": "^0.28.0",
|
|
35
|
+
"fastify": "^5.0.0",
|
|
36
|
+
"pino-pretty": "^13.0.0",
|
|
37
|
+
"postject": "^1.0.0-alpha.6",
|
|
38
|
+
"rcedit": "^5.0.2",
|
|
39
|
+
"tsx": "^4.19.0",
|
|
40
|
+
"typescript": "^5.6.0",
|
|
41
|
+
"yaml": "^2.9.0"
|
|
42
|
+
}
|
|
43
|
+
}
|