@ikenga/cli 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +19 -1
  2. package/dist/index.js +283 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,11 +26,29 @@ ikenga update <pkg> # update one
26
26
  ikenga update --all # update everything outdated
27
27
  ikenga remove com.ikenga.hello # by manifest id, or...
28
28
  ikenga remove @ikenga/pkg-hello # ...by npm name
29
+ ikenga dev ./my-pkg # hot-mount into running shell
29
30
  ```
30
31
 
31
- Installs land in the shell's pkgs directory (overridable with `IKENGA_APP_DATA_DIR`). The shell registers them on next boot. The CLI does not currently talk to a running shell over IPC; that's a planned enhancement.
32
+ `list / add / update / remove` mutate the shell's pkgs directory (overridable with `IKENGA_APP_DATA_DIR`); the shell registers them on next boot.
33
+
34
+ ### `ikenga dev <path>` — hot-mount for development
35
+
36
+ Different shape: `dev` talks to a **running** shell over its localhost iyke bridge instead of touching disk. Registers the pkg with `source.kind = "dev"` (auto-trusted, regardless of id namespace) and spawns a manifest watcher in the kernel so edits to `manifest.json` or any `restart_when_changed` glob trip an in-place reload — no shell restart.
37
+
38
+ Requires the shell to be running. The CLI discovers its port + bearer token from `control.json` in the shell's local data dir.
39
+
40
+ ```bash
41
+ ikenga dev /home/me/code/my-pkg
42
+ # → mounted as com.example.my-pkg v0.1.0
43
+ # → Routes: /pkg/com.example.my-pkg/
44
+ # Edit manifest.json or watched src/ files; reload fires automatically.
45
+ # Ctrl-C to unregister.
46
+ ```
47
+
48
+ Iframe code changes flow through your dev server's HMR (Vite, Next, …); sidecar / MCP source edits respawn via the supervisor watcher; manifest edits trigger a full pkg reload that emits a `pkg-reloaded` event the shell's iframe + webview hosts listen for. See [`docs/pkg-patterns/07-dev-mode.md`](https://github.com/Royalti-io/ikenga/blob/main/docs/pkg-patterns/07-dev-mode.md) for the kernel semantics.
32
49
 
33
50
  ## Versioning
34
51
 
52
+ `v0.3.0` — adds `ikenga dev <path>` for hot-mounting pkgs into a running shell via the iyke localhost bridge. Watcher-driven manifest reload + clean `Ctrl-C` unregister. Requires the corresponding shell-side dev-mode kernel (lands in shell `v0.0.5+`).
35
53
  `v0.2.0` — JS-source npm distribution; requires Bun on `$PATH`.
36
54
  `v0.1.x` — bun-compiled standalone binaries (deprecated; available on the GitHub Releases page until the next archive sweep).
package/dist/index.js CHANGED
@@ -9208,20 +9208,277 @@ function parseSpec(spec) {
9208
9208
  return { name: spec.slice(0, idx), version: spec.slice(idx + 1) || undefined };
9209
9209
  }
9210
9210
 
9211
- // src/lib/installed.ts
9212
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, statSync } from "fs";
9211
+ // src/commands/dev.ts
9212
+ import { existsSync as existsSync3, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
9213
+ import { resolve } from "path";
9214
+
9215
+ // src/lib/iyke-bridge.ts
9216
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync, unlinkSync } from "fs";
9217
+ import { homedir as homedir2, platform as platform2 } from "os";
9213
9218
  import { join as join3 } from "path";
9219
+ var APP_IDENTIFIER = "app.ikenga";
9220
+ var STALE_THRESHOLD_SECS = 5 * 60;
9221
+ function appLocalDataDir() {
9222
+ const override = process.env.IKENGA_APP_LOCAL_DATA_DIR;
9223
+ if (override)
9224
+ return override;
9225
+ const home = homedir2();
9226
+ switch (platform2()) {
9227
+ case "darwin":
9228
+ return join3(home, "Library", "Application Support", APP_IDENTIFIER);
9229
+ case "win32": {
9230
+ const local = process.env.LOCALAPPDATA;
9231
+ if (local)
9232
+ return join3(local, APP_IDENTIFIER);
9233
+ return join3(home, "AppData", "Local", APP_IDENTIFIER);
9234
+ }
9235
+ default: {
9236
+ const xdg = process.env.XDG_DATA_HOME;
9237
+ if (xdg)
9238
+ return join3(xdg, APP_IDENTIFIER);
9239
+ return join3(home, ".local", "share", APP_IDENTIFIER);
9240
+ }
9241
+ }
9242
+ }
9243
+ function controlPath() {
9244
+ return join3(appLocalDataDir(), "control.json");
9245
+ }
9246
+ function isPidAlive(pid) {
9247
+ if (platform2() === "win32")
9248
+ return true;
9249
+ try {
9250
+ process.kill(pid, 0);
9251
+ return true;
9252
+ } catch (e) {
9253
+ const err2 = e;
9254
+ return err2.code === "EPERM";
9255
+ }
9256
+ }
9257
+ function loadControl() {
9258
+ const path = controlPath();
9259
+ if (!existsSync2(path)) {
9260
+ return { kind: "missing" };
9261
+ }
9262
+ const raw = readFileSync2(path, "utf8");
9263
+ const cf = JSON.parse(raw);
9264
+ if (cf.schema_version !== 1) {
9265
+ throw new Error(`unsupported control.json schema_version: ${cf.schema_version} (CLI built for v1)`);
9266
+ }
9267
+ if (isPidAlive(cf.pid)) {
9268
+ return { kind: "ok", cf };
9269
+ }
9270
+ const nowMs = Date.now();
9271
+ const ageMs = Math.max(0, nowMs - cf.started_at_unix_ms);
9272
+ const ageSecs = Math.floor(ageMs / 1000);
9273
+ if (ageSecs >= STALE_THRESHOLD_SECS) {
9274
+ try {
9275
+ unlinkSync(path);
9276
+ } catch {}
9277
+ return { kind: "stale_removed" };
9278
+ }
9279
+ return { kind: "stale_young", age_secs: ageSecs };
9280
+ }
9281
+
9282
+ class IykeClient {
9283
+ base;
9284
+ token;
9285
+ constructor(cf) {
9286
+ this.base = `http://127.0.0.1:${cf.port}`;
9287
+ this.token = cf.token;
9288
+ }
9289
+ async post(path, body) {
9290
+ const res = await fetch(`${this.base}${path}`, {
9291
+ method: "POST",
9292
+ headers: {
9293
+ "Content-Type": "application/json",
9294
+ Authorization: `Bearer ${this.token}`
9295
+ },
9296
+ body: JSON.stringify(body)
9297
+ });
9298
+ if (!res.ok) {
9299
+ const text2 = await res.text().catch(() => "");
9300
+ throw new Error(`POST ${path} failed: ${res.status} ${text2}`);
9301
+ }
9302
+ const text = await res.text();
9303
+ return text ? JSON.parse(text) : {};
9304
+ }
9305
+ async get(path) {
9306
+ const res = await fetch(`${this.base}${path}`, {
9307
+ method: "GET",
9308
+ headers: { Authorization: `Bearer ${this.token}` }
9309
+ });
9310
+ if (!res.ok) {
9311
+ const text = await res.text().catch(() => "");
9312
+ throw new Error(`GET ${path} failed: ${res.status} ${text}`);
9313
+ }
9314
+ return await res.json();
9315
+ }
9316
+ }
9317
+ function connectOrThrow() {
9318
+ const outcome = loadControl();
9319
+ switch (outcome.kind) {
9320
+ case "ok":
9321
+ return new IykeClient(outcome.cf);
9322
+ case "missing":
9323
+ throw new Error(`Ikenga shell is not running.
9324
+ Expected control.json at ${controlPath()}
9325
+ Start the shell, then re-run this command.`);
9326
+ case "stale_removed":
9327
+ throw new Error(`Ikenga shell control file was stale (crashed shell) \u2014 cleaned up.
9328
+ Start the shell and re-run this command.`);
9329
+ case "stale_young":
9330
+ throw new Error(`Ikenga shell control file is ${outcome.age_secs}s old but the PID is dead.
9331
+ Likely a launch race. Retry in a moment or remove ${controlPath()} manually.`);
9332
+ }
9333
+ }
9334
+
9335
+ // src/commands/dev.ts
9336
+ async function devCommand(rawPath) {
9337
+ const path = resolve(rawPath);
9338
+ if (!existsSync3(path)) {
9339
+ process.stderr.write(`error: ${path} does not exist
9340
+ `);
9341
+ return 1;
9342
+ }
9343
+ if (!statSync2(path).isDirectory()) {
9344
+ process.stderr.write(`error: ${path} is not a directory
9345
+ `);
9346
+ return 1;
9347
+ }
9348
+ const manifestPath = `${path}/manifest.json`;
9349
+ if (!existsSync3(manifestPath)) {
9350
+ process.stderr.write(`error: no manifest.json at ${manifestPath}
9351
+ `);
9352
+ return 1;
9353
+ }
9354
+ let manifest;
9355
+ try {
9356
+ manifest = JSON.parse(readFileSync3(manifestPath, "utf8"));
9357
+ } catch (err2) {
9358
+ process.stderr.write(`error: failed to parse manifest.json: ${err2.message}
9359
+ `);
9360
+ return 1;
9361
+ }
9362
+ if (!manifest.id || !manifest.name || !manifest.version || !manifest.ikenga_api) {
9363
+ process.stderr.write(`error: manifest missing required fields (id, name, version, ikenga_api)
9364
+ `);
9365
+ return 1;
9366
+ }
9367
+ let client;
9368
+ try {
9369
+ client = connectOrThrow();
9370
+ } catch (err2) {
9371
+ process.stderr.write(`error: ${err2.message}
9372
+ `);
9373
+ return 1;
9374
+ }
9375
+ process.stdout.write(`\u2192 registering ${manifest.id}@${manifest.version} from ${path}
9376
+ `);
9377
+ let registered;
9378
+ try {
9379
+ registered = await client.post("/iyke/pkg/dev/register", {
9380
+ install_path: path
9381
+ });
9382
+ } catch (err2) {
9383
+ process.stderr.write(`error: ${err2.message}
9384
+ `);
9385
+ return 1;
9386
+ }
9387
+ process.stdout.write(`
9388
+ \u2713 mounted as ${registered.installed.id} v${registered.installed.version}
9389
+ `);
9390
+ printPkgSurface(manifest);
9391
+ process.stdout.write(`
9392
+ Watching manifest.json + restart_when_changed globs.
9393
+ `);
9394
+ process.stdout.write(` Edit the manifest to trigger a reload (250ms debounce).
9395
+ `);
9396
+ process.stdout.write(` Sidecar / MCP code changes restart via the supervisor watcher.
9397
+ `);
9398
+ process.stdout.write(` Iframe code changes flow through your dev server's HMR.
9399
+ `);
9400
+ process.stdout.write(`
9401
+ Ctrl-C to unregister and exit.
9402
+ `);
9403
+ const pkgId = registered.installed.id;
9404
+ return new Promise((resolveExit) => {
9405
+ let unregistering = false;
9406
+ const cleanup = async () => {
9407
+ if (unregistering)
9408
+ return;
9409
+ unregistering = true;
9410
+ process.stdout.write(`
9411
+ \u2192 unregistering ${pkgId}
9412
+ `);
9413
+ try {
9414
+ await client.post("/iyke/pkg/dev/unregister", { pkg_id: pkgId });
9415
+ process.stdout.write(`\u2713 done
9416
+ `);
9417
+ resolveExit(0);
9418
+ } catch (err2) {
9419
+ process.stderr.write(`error: ${err2.message}
9420
+ `);
9421
+ resolveExit(1);
9422
+ }
9423
+ };
9424
+ process.on("SIGINT", () => {
9425
+ cleanup();
9426
+ });
9427
+ process.on("SIGTERM", () => {
9428
+ cleanup();
9429
+ });
9430
+ setInterval(() => {}, 60000);
9431
+ });
9432
+ }
9433
+ function printPkgSurface(m2) {
9434
+ if (m2.ui?.routes?.length) {
9435
+ process.stdout.write(`
9436
+ Routes:
9437
+ `);
9438
+ for (const r of m2.ui.routes) {
9439
+ process.stdout.write(` /pkg/${m2.id}${r.path} (${r.kind}: ${r.source})
9440
+ `);
9441
+ }
9442
+ }
9443
+ if (m2.mcp?.length) {
9444
+ process.stdout.write(`
9445
+ MCP servers:
9446
+ `);
9447
+ for (const s3 of m2.mcp) {
9448
+ process.stdout.write(` ${s3.name}
9449
+ `);
9450
+ }
9451
+ }
9452
+ if (m2.sidecars?.length) {
9453
+ process.stdout.write(`
9454
+ Sidecars:
9455
+ `);
9456
+ for (const s3 of m2.sidecars) {
9457
+ process.stdout.write(` ${s3.name}
9458
+ `);
9459
+ }
9460
+ }
9461
+ if (m2.engine?.agentId) {
9462
+ process.stdout.write(`
9463
+ Engine: ${m2.engine.agentId}
9464
+ `);
9465
+ }
9466
+ }
9467
+
9468
+ // src/lib/installed.ts
9469
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4, statSync as statSync3 } from "fs";
9470
+ import { join as join4 } from "path";
9214
9471
  function listInstalled() {
9215
9472
  const root = pkgsDir();
9216
- if (!existsSync2(root))
9473
+ if (!existsSync4(root))
9217
9474
  return [];
9218
9475
  const out = [];
9219
9476
  for (const name of readdirSync(root)) {
9220
9477
  if (name.startsWith("."))
9221
9478
  continue;
9222
- const dir = join3(root, name);
9479
+ const dir = join4(root, name);
9223
9480
  try {
9224
- if (!statSync(dir).isDirectory())
9481
+ if (!statSync3(dir).isDirectory())
9225
9482
  continue;
9226
9483
  } catch {
9227
9484
  continue;
@@ -9247,11 +9504,11 @@ function findInstalled(pkgId) {
9247
9504
  return listInstalled().find((p2) => p2.id === pkgId) ?? null;
9248
9505
  }
9249
9506
  function readManifest(dir) {
9250
- const path = join3(dir, "manifest.json");
9251
- if (!existsSync2(path))
9507
+ const path = join4(dir, "manifest.json");
9508
+ if (!existsSync4(path))
9252
9509
  return null;
9253
9510
  try {
9254
- const json = JSON.parse(readFileSync2(path, "utf8"));
9511
+ const json = JSON.parse(readFileSync4(path, "utf8"));
9255
9512
  if (typeof json?.id !== "string" || typeof json?.version !== "string") {
9256
9513
  return null;
9257
9514
  }
@@ -9442,7 +9699,7 @@ function npmNameToPkgId2(npmName) {
9442
9699
  }
9443
9700
 
9444
9701
  // src/index.ts
9445
- var SUBCOMMANDS = ["list", "add", "update", "remove"];
9702
+ var SUBCOMMANDS = ["list", "add", "update", "remove", "dev"];
9446
9703
  function usage() {
9447
9704
  return `ikenga \u2014 pkg manager for the Ikenga shell
9448
9705
 
@@ -9451,6 +9708,7 @@ Usage:
9451
9708
  ikenga add <pkg>[@<version>] [--dry-run]
9452
9709
  ikenga update [<pkg> | --all] [--dry-run]
9453
9710
  ikenga remove <pkg>
9711
+ ikenga dev <path>
9454
9712
 
9455
9713
  Examples:
9456
9714
  ikenga list # what's installed locally
@@ -9460,9 +9718,15 @@ Examples:
9460
9718
  ikenga update --all # update everything outdated
9461
9719
  ikenga remove com.ikenga.hello # by manifest id, or...
9462
9720
  ikenga remove @ikenga/pkg-hello # ...by npm name
9721
+ ikenga dev ./my-pkg # hot-mount into running shell
9463
9722
 
9464
9723
  Installs land in the shell's pkgs directory (overridable with
9465
9724
  IKENGA_APP_DATA_DIR). The shell registers them on next boot.
9725
+
9726
+ \`ikenga dev <path>\` is different \u2014 it talks to a running shell over its
9727
+ localhost iyke bridge, registers the pkg with hot-reload semantics
9728
+ (manifest edits trigger an in-place reload, no shell restart), and
9729
+ unregisters cleanly on Ctrl-C. Requires the shell to be running.
9466
9730
  `;
9467
9731
  }
9468
9732
  async function main() {
@@ -9473,7 +9737,7 @@ async function main() {
9473
9737
  return 0;
9474
9738
  }
9475
9739
  if (sub === "--version" || sub === "-V") {
9476
- process.stdout.write(`ikenga ${"0.2.0"}
9740
+ process.stdout.write(`ikenga ${"0.3.0"}
9477
9741
  `);
9478
9742
  return 0;
9479
9743
  }
@@ -9514,6 +9778,15 @@ async function main() {
9514
9778
  }
9515
9779
  return removeCommand(spec);
9516
9780
  }
9781
+ case "dev": {
9782
+ const path = rest.find((a) => !a.startsWith("-"));
9783
+ if (!path) {
9784
+ process.stderr.write(`usage: ikenga dev <path>
9785
+ `);
9786
+ return 1;
9787
+ }
9788
+ return devCommand(path);
9789
+ }
9517
9790
  }
9518
9791
  }
9519
9792
  var code = await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikenga/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Command-line tool for installing, updating, and managing Ikenga pkgs.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {