@polderlabs/openkan 0.4.1 → 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.
Files changed (2) hide show
  1. package/dist/bin/openkan.js +177 -1
  2. package/package.json +1 -1
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync, writeFileSync, rmSync, appendFileSync, statSync } from "node:fs";
4
4
  import { join, dirname, resolve, basename } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { spawn } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
7
7
  import { startOrAttach } from "../kanban/server.js";
8
8
  import { addProject, setActiveProject } from "../kanban/projects.js";
9
9
  import { initBoard, getBoard, setProjectRoot } from "../kanban/board.js";
@@ -44,6 +44,33 @@ const AGENT_CAPABILITIES = Object.freeze({
44
44
  function configPath() {
45
45
  return join(process.cwd(), ".ok", "openkan.json");
46
46
  }
47
+ // Resolve the running package's package.json so version/installed-from stays
48
+ // accurate even when bin/openkan.mjs is the entrypoint and the .ts/.js lives
49
+ // one or two levels below the package root (src vs dist).
50
+ function installedPackageJson() {
51
+ const here = __dirname;
52
+ for (const dir of [here, join(here, ".."), join(here, "..", "..")]) {
53
+ const candidate = join(dir, "package.json");
54
+ if (existsSync(candidate)) {
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
57
+ if (typeof parsed?.name === "string" && typeof parsed?.version === "string") {
58
+ return { name: parsed.name, version: parsed.version };
59
+ }
60
+ }
61
+ catch { /* fall through to next candidate */ }
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ function printInstalledVersion() {
67
+ const pkg = installedPackageJson();
68
+ if (!pkg) {
69
+ console.log("openkan: version unavailable (no package.json found above the entrypoint)");
70
+ return;
71
+ }
72
+ console.log(`${pkg.name} ${pkg.version}`);
73
+ }
47
74
  function loadConfig() {
48
75
  const p = configPath();
49
76
  if (!existsSync(p))
@@ -628,6 +655,141 @@ async function cmdReset(ctx, argv) {
628
655
  removeDir(dir);
629
656
  console.log("Reset complete.");
630
657
  }
658
+ // ─── Subcommand: update ────────────────────────────────────────────────────────
659
+ async function cmdUpdate(positionals, flags) {
660
+ // Reject unknown flags instead of silently forwarding them to npm — keeps
661
+ // the surface small and predictable.
662
+ const KNOWN_FLAGS = new Set(["check", "yes", "version", "help", "h"]);
663
+ if (flags.help === true || flags.h === true) {
664
+ console.log("Usage: openkan update [--check] [--yes] [--version <semver>]");
665
+ console.log("");
666
+ console.log(" --check Report installed vs latest, exit non-zero if outdated, do not install.");
667
+ console.log(" --yes Skip the interactive confirmation prompt.");
668
+ console.log(" --version <v> Pin the upgrade to a specific semver instead of `latest`.");
669
+ console.log(" -h, --help Show this help.");
670
+ return;
671
+ }
672
+ for (const flag of Object.keys(flags)) {
673
+ if (!KNOWN_FLAGS.has(flag)) {
674
+ console.error(`openkan update: unknown flag --${flag} (known: ${[...KNOWN_FLAGS].map(f => `--${f}`).join(", ")})`);
675
+ process.exit(2);
676
+ }
677
+ }
678
+ if (positionals.length > 0) {
679
+ console.error(`openkan update: unexpected positional ${positionals[0]} (this command takes no arguments)`);
680
+ process.exit(2);
681
+ }
682
+ const pkg = installedPackageJson();
683
+ if (!pkg) {
684
+ console.error("openkan update: cannot determine the installed package (no package.json found above the entrypoint)");
685
+ process.exit(1);
686
+ }
687
+ // --version <semver> lets scripted callers pin to a known target (e.g.
688
+ // nightly); skip the registry query in that case.
689
+ const pinned = typeof flags.version === "string" ? flags.version : null;
690
+ let target = pinned;
691
+ if (!target) {
692
+ // Query the registry for the latest version on the `latest` dist-tag.
693
+ // We intentionally do not cache or background this; `openkan update`
694
+ // is explicit, infrequent, and the user wants to see the decision.
695
+ target = await npmLatestVersion(pkg.name);
696
+ if (!target) {
697
+ console.error(`openkan update: failed to query the latest version of ${pkg.name} from npm`);
698
+ process.exit(1);
699
+ }
700
+ }
701
+ if (target === pkg.version) {
702
+ console.log(`${pkg.name} ${pkg.version} is already up to date.`);
703
+ return;
704
+ }
705
+ console.log(`${pkg.name}: installed ${pkg.version}, latest ${target}`);
706
+ if (flags.check) {
707
+ // Just report; the user can re-run without --check to actually upgrade.
708
+ process.exitCode = 1;
709
+ return;
710
+ }
711
+ // Skip the confirmation prompt when --yes was passed (CI / scripted use).
712
+ if (!flags.yes) {
713
+ const proceed = await confirm(`Install ${pkg.name}@${target} now? [Y/n] `);
714
+ if (!proceed) {
715
+ console.log("Cancelled.");
716
+ return;
717
+ }
718
+ }
719
+ console.log(`Running: npm install -g ${pkg.name}@${target}`);
720
+ const result = spawnSync("npm", ["install", "-g", `${pkg.name}@${target}`], { stdio: "inherit" });
721
+ process.exit(result.status ?? 1);
722
+ }
723
+ async function npmLatestVersion(name) {
724
+ // `npm view <pkg> dist-tags.latest` prints the bare semver string; with
725
+ // --json it wraps that single value in a JSON array. Handle both shapes
726
+ // because some npm versions (and some package fields) emit array output
727
+ // even for scalar fields.
728
+ const stdout = await new Promise((resolve) => {
729
+ const child = spawn("npm", ["view", name, "dist-tags.latest"], { stdio: ["ignore", "pipe", "pipe"] });
730
+ let out = "";
731
+ child.stdout.on("data", (d) => { out += d.toString(); });
732
+ child.on("error", () => resolve(""));
733
+ child.on("close", (code) => { if (code === 0)
734
+ resolve(out);
735
+ else
736
+ resolve(""); });
737
+ }).then(async (text) => {
738
+ if (text.trim())
739
+ return text;
740
+ // Fall back to --json and parse, in case the bare call failed (older
741
+ // npm prints only via --json for dotted paths).
742
+ return await new Promise((resolve) => {
743
+ const child = spawn("npm", ["view", name, "dist-tags.latest", "--json"], { stdio: ["ignore", "pipe", "pipe"] });
744
+ let out = "";
745
+ child.stdout.on("data", (d) => { out += d.toString(); });
746
+ child.on("error", () => resolve(""));
747
+ child.on("close", () => resolve(out));
748
+ });
749
+ });
750
+ const trimmed = stdout.trim();
751
+ if (!trimmed)
752
+ return null;
753
+ // Accept `"0.4.1"` (bare) or `["0.4.1"]` (json-wrapped).
754
+ if (trimmed.startsWith("[")) {
755
+ try {
756
+ const parsed = JSON.parse(trimmed);
757
+ if (Array.isArray(parsed) && typeof parsed[0] === "string")
758
+ return parsed[0];
759
+ }
760
+ catch {
761
+ return null;
762
+ }
763
+ return null;
764
+ }
765
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
766
+ try {
767
+ return JSON.parse(trimmed);
768
+ }
769
+ catch {
770
+ return null;
771
+ }
772
+ }
773
+ return trimmed;
774
+ }
775
+ async function confirm(question) {
776
+ if (!process.stdin.isTTY) {
777
+ // No TTY → assume yes in the rare case the user piped `openkan update`
778
+ // without --yes; the alternative is silent failure which surprises more.
779
+ return true;
780
+ }
781
+ process.stdout.write(question);
782
+ return await new Promise((resolve) => {
783
+ const onData = (data) => {
784
+ const answer = data.toString().trim().toLowerCase();
785
+ process.stdin.removeListener("data", onData);
786
+ process.stdin.pause();
787
+ resolve(answer === "" || answer === "y" || answer === "yes");
788
+ };
789
+ process.stdin.resume();
790
+ process.stdin.once("data", onData);
791
+ });
792
+ }
631
793
  // ─── URL opener ────────────────────────────────────────────────────────────────
632
794
  function openUrl(url) {
633
795
  const openCmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
@@ -647,6 +809,7 @@ function printHelp(cmd) {
647
809
  stop: "stop Stop the running server",
648
810
  status: "status Show server status, port, pid, uptime",
649
811
  open: "open Open the kanban UI in browser",
812
+ update: "update [--check] [--yes] [--version <v>] Upgrade to the latest @polderlabs/openkan from npm",
650
813
  config: "config list|get <key>|set <key> <value> Manage config",
651
814
  logs: "logs [--tail N] [--follow] Print server logs",
652
815
  api: "api <path> [--method M] [--data JSON|--data-file FILE] Call any local OpenKan REST feature",
@@ -669,6 +832,7 @@ function printHelp(cmd) {
669
832
  console.log("Usage: openkan <command> [args...]\n");
670
833
  Object.values(msgs).forEach(m => console.log(` ${m}`));
671
834
  console.log("\nFlags: --flag=value or --flag value, can appear before or after positionals.");
835
+ console.log("\nVersion: `openkan -v` or `openkan --version` prints the installed package name and version.");
672
836
  }
673
837
  }
674
838
  export async function main(argv = process.argv.slice(2)) {
@@ -676,6 +840,13 @@ export async function main(argv = process.argv.slice(2)) {
676
840
  printHelp(argv[0] === "-h" || argv[0] === "--help" ? argv[1] : undefined);
677
841
  return;
678
842
  }
843
+ // `openkan -v` / `openkan --version` — print the installed package's name
844
+ // and version from its own package.json so the user always sees the truth,
845
+ // even when the shim is rebuilt separately from the TS source.
846
+ if (argv[0] === "-v" || argv[0] === "--version") {
847
+ printInstalledVersion();
848
+ return;
849
+ }
679
850
  const { cmd, positionals, flags } = parseArgs(argv);
680
851
  if (["task", "plan", "prd", "goal", "progress", "doctor", "index", "migrate-from-openkan"].includes(cmd)) {
681
852
  // Help and bare invocations: print the command's help line instead of
@@ -726,6 +897,11 @@ export async function main(argv = process.argv.slice(2)) {
726
897
  return;
727
898
  }
728
899
  // Resolve nested invocations without creating a second workspace.
900
+ // `openkan update` runs npm itself; it must NOT chdir into the nearest
901
+ // .ok/ workspace, must NOT initialise the board, and must NOT need a
902
+ // running server. Handle it before the project-resolution / initBoard paths.
903
+ if (cmd === 'update')
904
+ return cmdUpdate(positionals, flags);
729
905
  if (cmd !== 'init') {
730
906
  let directory = process.cwd();
731
907
  while (!existsSync(join(directory, '.ok')) && dirname(directory) !== directory)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/openkan",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Local-first MDX kanban with native Claude Code control plane, .ok/ planning system, and embedded chat sidebar.",
5
5
  "type": "module",
6
6
  "license": "MIT",