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 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.2`.
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 health = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/health`, 500);
121
- let instances = null;
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
- return null;
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', 'iranti-control-plane@latest']);
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') {