iranti-control-plane 0.4.2 → 0.5.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/README.md +5 -1
- package/bin/iranti-cp.js +198 -16
- package/dist/server/bundle.cjs +25400 -3181
- package/package.json +1 -1
- package/public/control-plane/assets/index-BTD8_zu4.css +1 -0
- package/public/control-plane/assets/index-CcG4ZB0K.js +77 -0
- package/public/control-plane/index.html +2 -2
- package/public/control-plane/assets/index-OSHzCl4w.css +0 -1
- package/public/control-plane/assets/index-XZAadN0I.js +0 -77
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Local-first operator dashboard for [Iranti](https://github.com/nfemmanuel/iranti
|
|
|
4
4
|
|
|
5
5
|
## Status
|
|
6
6
|
|
|
7
|
-
Current package version: `0.4.
|
|
7
|
+
Current package version: `0.4.3`.
|
|
8
8
|
The operator surface is live and under active UX hardening.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
@@ -23,7 +23,9 @@ Useful packaged CLI commands:
|
|
|
23
23
|
```bash
|
|
24
24
|
iranti-cp open
|
|
25
25
|
iranti-cp start --port 3010
|
|
26
|
+
iranti-cp stop --port 3010
|
|
26
27
|
iranti-cp status
|
|
28
|
+
iranti-cp uninstall
|
|
27
29
|
iranti-cp doctor --instance my_instance
|
|
28
30
|
iranti-cp upgrade self
|
|
29
31
|
iranti-cp upgrade iranti --all --dry-run
|
|
@@ -141,4 +143,6 @@ The control plane is a standalone Express server + React SPA that connects to th
|
|
|
141
143
|
See `docs/specs/control-plane-api.md` for the full API spec and `docs/prd/control-plane.md` for product requirements.
|
|
142
144
|
|
|
143
145
|
For release and manual publish checks, see [`docs/guides/releasing.md`](docs/guides/releasing.md).
|
|
146
|
+
|
|
147
|
+
If npm publish is run from GitHub Actions, the repo `NPM_TOKEN` secret must be an npm **Automation token**. A standard token that still requires OTP will fail with `EOTP`.
|
|
144
148
|
|
package/bin/iranti-cp.js
CHANGED
|
@@ -7,6 +7,7 @@ const { spawn, spawnSync } = require('child_process');
|
|
|
7
7
|
const ROOT = path.resolve(__dirname, '..');
|
|
8
8
|
const BUNDLE = path.join(ROOT, 'dist', 'server', 'bundle.cjs');
|
|
9
9
|
const PACKAGE_JSON = path.join(ROOT, 'package.json');
|
|
10
|
+
const PACKAGE_NAME = 'iranti-control-plane';
|
|
10
11
|
|
|
11
12
|
function readPackageVersion() {
|
|
12
13
|
try {
|
|
@@ -23,7 +24,9 @@ Usage:
|
|
|
23
24
|
iranti-cp
|
|
24
25
|
iranti-cp open [--port <n>]
|
|
25
26
|
iranti-cp start [--port <n>]
|
|
27
|
+
iranti-cp stop [--port <n>]
|
|
26
28
|
iranti-cp status [--port <n>] [--json]
|
|
29
|
+
iranti-cp uninstall
|
|
27
30
|
iranti-cp version
|
|
28
31
|
iranti-cp doctor [iranti doctor args...]
|
|
29
32
|
iranti-cp upgrade [self]
|
|
@@ -32,7 +35,9 @@ Usage:
|
|
|
32
35
|
Commands:
|
|
33
36
|
open Open an existing Control Plane if one is running, otherwise start it in the background.
|
|
34
37
|
start Start the Control Plane in the foreground without auto-opening the browser.
|
|
38
|
+
stop Stop one or more running local Control Plane servers.
|
|
35
39
|
status Show the installed Control Plane version and any running local Control Plane servers.
|
|
40
|
+
uninstall Stop running Control Plane servers, then remove the global npm package.
|
|
36
41
|
version Print the installed iranti-control-plane version.
|
|
37
42
|
doctor Proxy to "iranti doctor".
|
|
38
43
|
upgrade Upgrade iranti-control-plane itself, or proxy to "iranti upgrade" for core Iranti.
|
|
@@ -114,32 +119,101 @@ async function fetchJson(url, timeoutMs = 1500) {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
|
|
122
|
+
async function probeControlPlane(port) {
|
|
123
|
+
try {
|
|
124
|
+
const ping = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/ping`, 1000);
|
|
125
|
+
const packageName = typeof ping.package === 'string' ? ping.package : null;
|
|
126
|
+
if (packageName && packageName !== PACKAGE_NAME) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
port,
|
|
131
|
+
url: `http://localhost:${port}/control-plane`,
|
|
132
|
+
version: typeof ping.version === 'string' ? ping.version : null,
|
|
133
|
+
instances: null,
|
|
134
|
+
};
|
|
135
|
+
} catch {
|
|
136
|
+
// Fall back to the heavier health probe so older control-plane builds
|
|
137
|
+
// without /ping still remain discoverable.
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const health = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/health`, 5000);
|
|
142
|
+
return {
|
|
143
|
+
port,
|
|
144
|
+
url: `http://localhost:${port}/control-plane`,
|
|
145
|
+
version: typeof health.version === 'string' ? health.version : null,
|
|
146
|
+
instances: null,
|
|
147
|
+
};
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
117
153
|
async function findRunningControlPlanes(explicitPort) {
|
|
118
154
|
const checks = preferredPorts(explicitPort).map(async (port) => {
|
|
155
|
+
const running = await probeControlPlane(port);
|
|
156
|
+
if (!running) return null;
|
|
157
|
+
|
|
119
158
|
try {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const body = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/instances`, 500);
|
|
124
|
-
instances = Array.isArray(body) ? body.length : null;
|
|
125
|
-
} catch {
|
|
126
|
-
instances = null;
|
|
127
|
-
}
|
|
128
|
-
return {
|
|
129
|
-
port,
|
|
130
|
-
url: `http://localhost:${port}/control-plane`,
|
|
131
|
-
version: typeof health.version === 'string' ? health.version : null,
|
|
132
|
-
instances,
|
|
133
|
-
};
|
|
159
|
+
const body = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/instances`, 2000);
|
|
160
|
+
running.instances = Array.isArray(body) ? body.length : null;
|
|
134
161
|
} catch {
|
|
135
|
-
|
|
162
|
+
running.instances = null;
|
|
136
163
|
}
|
|
164
|
+
|
|
165
|
+
return running;
|
|
137
166
|
});
|
|
138
167
|
|
|
139
168
|
const resolved = await Promise.all(checks);
|
|
140
169
|
return resolved.filter(Boolean);
|
|
141
170
|
}
|
|
142
171
|
|
|
172
|
+
function readLines(command, args) {
|
|
173
|
+
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
174
|
+
if (result.status !== 0) return [];
|
|
175
|
+
return String(result.stdout || '')
|
|
176
|
+
.split(/\r?\n/)
|
|
177
|
+
.map((line) => line.trim())
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function findPidsForPort(port) {
|
|
182
|
+
const portText = String(port);
|
|
183
|
+
|
|
184
|
+
if (process.platform === 'win32') {
|
|
185
|
+
const lines = readLines('netstat', ['-ano', '-p', 'tcp']);
|
|
186
|
+
const pids = new Set();
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
if (!/\bLISTENING\b/i.test(line)) continue;
|
|
189
|
+
const parts = line.split(/\s+/);
|
|
190
|
+
if (parts.length < 5) continue;
|
|
191
|
+
const localAddress = parts[1];
|
|
192
|
+
const state = parts[3];
|
|
193
|
+
const pid = Number.parseInt(parts[4], 10);
|
|
194
|
+
if (!Number.isFinite(pid) || !/\bLISTENING\b/i.test(state)) continue;
|
|
195
|
+
if (localAddress.endsWith(`:${portText}`)) pids.add(pid);
|
|
196
|
+
}
|
|
197
|
+
return [...pids];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const lsofPids = readLines('lsof', ['-ti', `tcp:${portText}`])
|
|
201
|
+
.map((value) => Number.parseInt(value, 10))
|
|
202
|
+
.filter((value) => Number.isFinite(value));
|
|
203
|
+
if (lsofPids.length > 0) return [...new Set(lsofPids)];
|
|
204
|
+
|
|
205
|
+
const ssLines = readLines('ss', ['-ltnp']);
|
|
206
|
+
const pids = new Set();
|
|
207
|
+
for (const line of ssLines) {
|
|
208
|
+
if (!line.includes(`:${portText}`)) continue;
|
|
209
|
+
const pidMatch = line.match(/pid=(\d+)/);
|
|
210
|
+
if (!pidMatch) continue;
|
|
211
|
+
const pid = Number.parseInt(pidMatch[1], 10);
|
|
212
|
+
if (Number.isFinite(pid)) pids.add(pid);
|
|
213
|
+
}
|
|
214
|
+
return [...pids];
|
|
215
|
+
}
|
|
216
|
+
|
|
143
217
|
function openUrl(url) {
|
|
144
218
|
if (process.platform === 'win32') {
|
|
145
219
|
return spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
@@ -166,6 +240,45 @@ function spawnControlPlane({ port, openBrowser, detached }) {
|
|
|
166
240
|
return child;
|
|
167
241
|
}
|
|
168
242
|
|
|
243
|
+
async function terminatePid(pid) {
|
|
244
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
245
|
+
throw new Error(`invalid pid "${pid}"`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (process.platform === 'win32') {
|
|
249
|
+
const code = await runChild('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
|
|
250
|
+
if (code !== 0) {
|
|
251
|
+
throw new Error(`taskkill exited with status ${code}`);
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
process.kill(pid, 'SIGTERM');
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error && error.code === 'ESRCH') return;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const deadline = Date.now() + 2000;
|
|
264
|
+
while (Date.now() < deadline) {
|
|
265
|
+
try {
|
|
266
|
+
process.kill(pid, 0);
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error && error.code === 'ESRCH') return;
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
process.kill(pid, 'SIGKILL');
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error && error.code === 'ESRCH') return;
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
169
282
|
function resolveIrantiCommand() {
|
|
170
283
|
const explicit = process.env.IRANTI_CLI_PATH || process.env.IRANTI_CP_IRANTI_CLI;
|
|
171
284
|
if (explicit && explicit.trim()) {
|
|
@@ -293,10 +406,75 @@ async function handleStart(port) {
|
|
|
293
406
|
});
|
|
294
407
|
}
|
|
295
408
|
|
|
409
|
+
async function handleStop(port) {
|
|
410
|
+
const running = await findRunningControlPlanes(port);
|
|
411
|
+
if (running.length === 0) {
|
|
412
|
+
console.log(port
|
|
413
|
+
? `No local Control Plane server detected on port ${port}.`
|
|
414
|
+
: 'No local Control Plane server is running.');
|
|
415
|
+
return 0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let failures = 0;
|
|
419
|
+
for (const server of running) {
|
|
420
|
+
const pids = findPidsForPort(server.port);
|
|
421
|
+
if (pids.length === 0) {
|
|
422
|
+
console.error(`Could not resolve a process ID for Control Plane on port ${server.port}.`);
|
|
423
|
+
failures += 1;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let stopped = false;
|
|
428
|
+
for (const pid of pids) {
|
|
429
|
+
try {
|
|
430
|
+
await terminatePid(pid);
|
|
431
|
+
console.log(`Stopped Control Plane on port ${server.port} (PID ${pid}).`);
|
|
432
|
+
stopped = true;
|
|
433
|
+
} catch (error) {
|
|
434
|
+
console.error(`Failed to stop Control Plane on port ${server.port} (PID ${pid}): ${error.message}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (!stopped) failures += 1;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return failures === 0 ? 0 : 1;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function launchDetachedUninstall() {
|
|
445
|
+
if (process.platform === 'win32') {
|
|
446
|
+
const child = spawn('cmd', ['/d', '/s', '/c', `ping 127.0.0.1 -n 2 >nul && npm.cmd uninstall -g ${PACKAGE_NAME}`], {
|
|
447
|
+
detached: true,
|
|
448
|
+
stdio: 'ignore',
|
|
449
|
+
windowsHide: true,
|
|
450
|
+
});
|
|
451
|
+
child.unref();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const child = spawn('sh', ['-lc', `sleep 1 && npm uninstall -g ${PACKAGE_NAME}`], {
|
|
456
|
+
detached: true,
|
|
457
|
+
stdio: 'ignore',
|
|
458
|
+
});
|
|
459
|
+
child.unref();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function handleUninstall() {
|
|
463
|
+
const stopCode = await handleStop(null);
|
|
464
|
+
if (stopCode !== 0) {
|
|
465
|
+
console.error('Continuing with uninstall even though one or more Control Plane processes could not be stopped cleanly.');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
launchDetachedUninstall();
|
|
469
|
+
console.log(`Started global uninstall for ${PACKAGE_NAME}.`);
|
|
470
|
+
console.log('If your shell still sees the old command for a moment, open a new terminal after uninstall finishes.');
|
|
471
|
+
return 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
296
474
|
async function handleUpgrade(target, trailingArgs) {
|
|
297
475
|
if (!target || target === 'self') {
|
|
298
476
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
299
|
-
return runChild(npmCommand, ['install', '-g',
|
|
477
|
+
return runChild(npmCommand, ['install', '-g', `${PACKAGE_NAME}@latest`]);
|
|
300
478
|
}
|
|
301
479
|
|
|
302
480
|
if (target === 'iranti') {
|
|
@@ -339,8 +517,12 @@ async function main() {
|
|
|
339
517
|
exitCode = await handleOpen(parsed.port);
|
|
340
518
|
} else if (command === 'start') {
|
|
341
519
|
exitCode = await handleStart(parsed.port);
|
|
520
|
+
} else if (command === 'stop') {
|
|
521
|
+
exitCode = await handleStop(parsed.port);
|
|
342
522
|
} else if (command === 'status') {
|
|
343
523
|
exitCode = await handleStatus(parsed.port, parsed.json);
|
|
524
|
+
} else if (command === 'uninstall') {
|
|
525
|
+
exitCode = await handleUninstall();
|
|
344
526
|
} else if (command === 'doctor') {
|
|
345
527
|
exitCode = await runIrantiProxy(['doctor', ...rest]);
|
|
346
528
|
} else if (command === 'upgrade') {
|