iranti-control-plane 0.4.2 → 0.4.3

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.
@@ -117,10 +122,10 @@ async function fetchJson(url, timeoutMs = 1500) {
117
122
  async function findRunningControlPlanes(explicitPort) {
118
123
  const checks = preferredPorts(explicitPort).map(async (port) => {
119
124
  try {
120
- const health = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/health`, 500);
125
+ const health = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/health`, 2500);
121
126
  let instances = null;
122
127
  try {
123
- const body = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/instances`, 500);
128
+ const body = await fetchJson(`http://127.0.0.1:${port}/api/control-plane/instances`, 1500);
124
129
  instances = Array.isArray(body) ? body.length : null;
125
130
  } catch {
126
131
  instances = null;
@@ -140,6 +145,51 @@ async function findRunningControlPlanes(explicitPort) {
140
145
  return resolved.filter(Boolean);
141
146
  }
142
147
 
148
+ function readLines(command, args) {
149
+ const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
150
+ if (result.status !== 0) return [];
151
+ return String(result.stdout || '')
152
+ .split(/\r?\n/)
153
+ .map((line) => line.trim())
154
+ .filter(Boolean);
155
+ }
156
+
157
+ function findPidsForPort(port) {
158
+ const portText = String(port);
159
+
160
+ if (process.platform === 'win32') {
161
+ const lines = readLines('netstat', ['-ano', '-p', 'tcp']);
162
+ const pids = new Set();
163
+ for (const line of lines) {
164
+ if (!/\bLISTENING\b/i.test(line)) continue;
165
+ const parts = line.split(/\s+/);
166
+ if (parts.length < 5) continue;
167
+ const localAddress = parts[1];
168
+ const state = parts[3];
169
+ const pid = Number.parseInt(parts[4], 10);
170
+ if (!Number.isFinite(pid) || !/\bLISTENING\b/i.test(state)) continue;
171
+ if (localAddress.endsWith(`:${portText}`)) pids.add(pid);
172
+ }
173
+ return [...pids];
174
+ }
175
+
176
+ const lsofPids = readLines('lsof', ['-ti', `tcp:${portText}`])
177
+ .map((value) => Number.parseInt(value, 10))
178
+ .filter((value) => Number.isFinite(value));
179
+ if (lsofPids.length > 0) return [...new Set(lsofPids)];
180
+
181
+ const ssLines = readLines('ss', ['-ltnp']);
182
+ const pids = new Set();
183
+ for (const line of ssLines) {
184
+ if (!line.includes(`:${portText}`)) continue;
185
+ const pidMatch = line.match(/pid=(\d+)/);
186
+ if (!pidMatch) continue;
187
+ const pid = Number.parseInt(pidMatch[1], 10);
188
+ if (Number.isFinite(pid)) pids.add(pid);
189
+ }
190
+ return [...pids];
191
+ }
192
+
143
193
  function openUrl(url) {
144
194
  if (process.platform === 'win32') {
145
195
  return spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true });
@@ -166,6 +216,45 @@ function spawnControlPlane({ port, openBrowser, detached }) {
166
216
  return child;
167
217
  }
168
218
 
219
+ async function terminatePid(pid) {
220
+ if (!Number.isFinite(pid) || pid <= 0) {
221
+ throw new Error(`invalid pid "${pid}"`);
222
+ }
223
+
224
+ if (process.platform === 'win32') {
225
+ const code = await runChild('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
226
+ if (code !== 0) {
227
+ throw new Error(`taskkill exited with status ${code}`);
228
+ }
229
+ return;
230
+ }
231
+
232
+ try {
233
+ process.kill(pid, 'SIGTERM');
234
+ } catch (error) {
235
+ if (error && error.code === 'ESRCH') return;
236
+ throw error;
237
+ }
238
+
239
+ const deadline = Date.now() + 2000;
240
+ while (Date.now() < deadline) {
241
+ try {
242
+ process.kill(pid, 0);
243
+ await new Promise((resolve) => setTimeout(resolve, 100));
244
+ } catch (error) {
245
+ if (error && error.code === 'ESRCH') return;
246
+ throw error;
247
+ }
248
+ }
249
+
250
+ try {
251
+ process.kill(pid, 'SIGKILL');
252
+ } catch (error) {
253
+ if (error && error.code === 'ESRCH') return;
254
+ throw error;
255
+ }
256
+ }
257
+
169
258
  function resolveIrantiCommand() {
170
259
  const explicit = process.env.IRANTI_CLI_PATH || process.env.IRANTI_CP_IRANTI_CLI;
171
260
  if (explicit && explicit.trim()) {
@@ -293,10 +382,75 @@ async function handleStart(port) {
293
382
  });
294
383
  }
295
384
 
385
+ async function handleStop(port) {
386
+ const running = await findRunningControlPlanes(port);
387
+ if (running.length === 0) {
388
+ console.log(port
389
+ ? `No local Control Plane server detected on port ${port}.`
390
+ : 'No local Control Plane server is running.');
391
+ return 0;
392
+ }
393
+
394
+ let failures = 0;
395
+ for (const server of running) {
396
+ const pids = findPidsForPort(server.port);
397
+ if (pids.length === 0) {
398
+ console.error(`Could not resolve a process ID for Control Plane on port ${server.port}.`);
399
+ failures += 1;
400
+ continue;
401
+ }
402
+
403
+ let stopped = false;
404
+ for (const pid of pids) {
405
+ try {
406
+ await terminatePid(pid);
407
+ console.log(`Stopped Control Plane on port ${server.port} (PID ${pid}).`);
408
+ stopped = true;
409
+ } catch (error) {
410
+ console.error(`Failed to stop Control Plane on port ${server.port} (PID ${pid}): ${error.message}`);
411
+ }
412
+ }
413
+
414
+ if (!stopped) failures += 1;
415
+ }
416
+
417
+ return failures === 0 ? 0 : 1;
418
+ }
419
+
420
+ function launchDetachedUninstall() {
421
+ if (process.platform === 'win32') {
422
+ const child = spawn('cmd', ['/d', '/s', '/c', `ping 127.0.0.1 -n 2 >nul && npm.cmd uninstall -g ${PACKAGE_NAME}`], {
423
+ detached: true,
424
+ stdio: 'ignore',
425
+ windowsHide: true,
426
+ });
427
+ child.unref();
428
+ return;
429
+ }
430
+
431
+ const child = spawn('sh', ['-lc', `sleep 1 && npm uninstall -g ${PACKAGE_NAME}`], {
432
+ detached: true,
433
+ stdio: 'ignore',
434
+ });
435
+ child.unref();
436
+ }
437
+
438
+ async function handleUninstall() {
439
+ const stopCode = await handleStop(null);
440
+ if (stopCode !== 0) {
441
+ console.error('Continuing with uninstall even though one or more Control Plane processes could not be stopped cleanly.');
442
+ }
443
+
444
+ launchDetachedUninstall();
445
+ console.log(`Started global uninstall for ${PACKAGE_NAME}.`);
446
+ console.log('If your shell still sees the old command for a moment, open a new terminal after uninstall finishes.');
447
+ return 0;
448
+ }
449
+
296
450
  async function handleUpgrade(target, trailingArgs) {
297
451
  if (!target || target === 'self') {
298
452
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
299
- return runChild(npmCommand, ['install', '-g', 'iranti-control-plane@latest']);
453
+ return runChild(npmCommand, ['install', '-g', `${PACKAGE_NAME}@latest`]);
300
454
  }
301
455
 
302
456
  if (target === 'iranti') {
@@ -339,8 +493,12 @@ async function main() {
339
493
  exitCode = await handleOpen(parsed.port);
340
494
  } else if (command === 'start') {
341
495
  exitCode = await handleStart(parsed.port);
496
+ } else if (command === 'stop') {
497
+ exitCode = await handleStop(parsed.port);
342
498
  } else if (command === 'status') {
343
499
  exitCode = await handleStatus(parsed.port, parsed.json);
500
+ } else if (command === 'uninstall') {
501
+ exitCode = await handleUninstall();
344
502
  } else if (command === 'doctor') {
345
503
  exitCode = await runIrantiProxy(['doctor', ...rest]);
346
504
  } else if (command === 'upgrade') {