pipe-kan 0.30.0 → 0.31.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
@@ -12,7 +12,7 @@ npx pipe-kan
12
12
 
13
13
  Opens `http://127.0.0.1:5173`. No clone, no `bun install`. `bunx` / `npx` installs the published package into a cache and runs it.
14
14
 
15
- First paint is the Fixture. If `jira` is on PATH, the process then Refresh-es from your existing `jira init`. If not, Refresh, Move, Create, and Edit use the in-process store.
15
+ If `--plane` is set, first paint is live Refresh. A failed live Refresh stays in Plane mode and keeps the last live Board, with the error on the Board. If live Refresh never succeeded, the Fixture is not shown. If `jira` is on PATH and `--plane` is off, first paint is the Fixture, then Refresh from your existing `jira init`. If `jira` is missing and `--plane` is off, first paint is the Fixture and Refresh, Move, Create, and Edit use the in-process store.
16
16
 
17
17
  The published CLI is on npm. From a clone: `bun run build && bun dist/pipe-kan.js`.
18
18
 
@@ -57,7 +57,7 @@ bunx pipe-kan --plane --projects APH,PULSE,PKAN,PUI,DEC
57
57
  | `PLANE_API_KEY` | (required in Plane mode) | personal access token (`X-API-Key`) |
58
58
  | `PLANE_HOST` | `https://plane.tail48fe8.ts.net` | Plane origin; API is `{host}/api/v1` |
59
59
 
60
- Same `--projects` style as Jira: comma-separated, trimmed, uppercased. The header **Scope flags** field accepts the same string, then **Refresh**.
60
+ Same `--projects` style as Jira: comma-separated, trimmed, uppercased. The header **Scope flags** field accepts the same string, then **Refresh**. In Plane mode a header **Workspace** pill (beside the project-identifier chip) types a slug, writes `--workspace` into Scope flags, and Refresh-confirms all Epics. Jira omits the pill. Next launch still uses argv `--workspace`, else `PLANE_WORKSPACE`, else `personal`.
61
61
 
62
62
  On Refresh:
63
63
 
@@ -68,6 +68,8 @@ On Refresh:
68
68
 
69
69
  Create and Edit use Plane REST when `--plane` is set. Pulse, Plane Pro, scraping, and SQL workspace moves are out of scope.
70
70
 
71
+ A Plane `429 RATE_LIMIT_EXCEEDED` does not fall back to Fake Jira. Plane waits the rate-limit window, then continues.
72
+
71
73
  ## Multiple projects
72
74
 
73
75
  Default Scope is one Project — jira-cli's `jira init` project. To list more than one Project key:
package/dist/pipe-kan.js CHANGED
@@ -1231,6 +1231,8 @@ function createApp(opts) {
1231
1231
  let childrenRaw = [];
1232
1232
  let hasChildrenCache = false;
1233
1233
  let childrenError;
1234
+ let refreshError;
1235
+ let live = false;
1234
1236
  let targetEndFieldId = readTargetEndFieldMap(fieldMapPath).targetEnd ?? targetEndFieldIdFromJiraConfig(safeRead(jiraConfigPath) ?? "");
1235
1237
  let workflowStates;
1236
1238
  function boardOpts() {
@@ -1330,11 +1332,13 @@ function createApp(opts) {
1330
1332
  childrenRaw = mergeSelectedChildren(childrenRaw, hydrated, keys);
1331
1333
  hasChildrenCache = true;
1332
1334
  childrenError = undefined;
1335
+ refreshError = undefined;
1333
1336
  return app.board();
1334
1337
  } catch (err) {
1335
1338
  const message = err instanceof Error ? err.message : "Refresh failed";
1339
+ refreshError = message;
1336
1340
  console.error("Refresh failed", message);
1337
- return { ...app.board(), error: message };
1341
+ return app.board();
1338
1342
  }
1339
1343
  }
1340
1344
  async function tryMove(key, status) {
@@ -1350,12 +1354,21 @@ function createApp(opts) {
1350
1354
  return flags;
1351
1355
  },
1352
1356
  board() {
1357
+ const error = refreshError ?? childrenError;
1358
+ if (!live && (refreshError || opts.liveBackend)) {
1359
+ const empty = toBoard([]);
1360
+ return {
1361
+ columns: empty.columns,
1362
+ epics: [],
1363
+ ...error ? { error } : {}
1364
+ };
1365
+ }
1353
1366
  const board = toBoard(payload);
1354
1367
  return {
1355
1368
  columns: board.columns,
1356
1369
  epics: mergeEpics(toBoard(epicsPayload).epics, board.epics),
1357
1370
  ...hasChildrenCache ? { children: columnsOf(childrenRaw, targetEndFieldId) } : {},
1358
- ...childrenError ? { error: childrenError } : {}
1371
+ ...error ? { error } : {}
1359
1372
  };
1360
1373
  },
1361
1374
  hydrate(raw, hydrateOpts) {
@@ -1364,6 +1377,8 @@ function createApp(opts) {
1364
1377
  childrenRaw = [];
1365
1378
  hasChildrenCache = false;
1366
1379
  childrenError = undefined;
1380
+ refreshError = undefined;
1381
+ live = false;
1367
1382
  if (hydrateOpts?.fromStore)
1368
1383
  cacheFromStore();
1369
1384
  rememberTargetEndId([...payload, ...epicsPayload, ...childrenRaw]);
@@ -1417,11 +1432,14 @@ function createApp(opts) {
1417
1432
  childrenRaw = nextHasCache ? await hydrateRaw(nextChildren) : nextChildren;
1418
1433
  hasChildrenCache = nextHasCache;
1419
1434
  childrenError = nextError;
1435
+ refreshError = undefined;
1436
+ live = true;
1420
1437
  return app.board();
1421
1438
  } catch (err) {
1422
1439
  const message = err instanceof Error ? err.message : "Refresh failed";
1440
+ refreshError = message;
1423
1441
  console.error("Refresh failed", message);
1424
- return { ...app.board(), error: message };
1442
+ return app.board();
1425
1443
  }
1426
1444
  },
1427
1445
  async children(epic) {
@@ -1486,6 +1504,12 @@ function createApp(opts) {
1486
1504
  error: err instanceof Error ? err.message : "jira issue view failed"
1487
1505
  };
1488
1506
  }
1507
+ },
1508
+ async listWorkspaces() {
1509
+ return cli.listWorkspaces?.() ?? [];
1510
+ },
1511
+ async listProjectIdentifiers(workspace) {
1512
+ return cli.listProjectIdentifiers?.(workspace) ?? [];
1489
1513
  }
1490
1514
  };
1491
1515
  return app;
@@ -1495,7 +1519,6 @@ function createApp(opts) {
1495
1519
  var DEFAULT_PLANE_HOST = "https://plane.tail48fe8.ts.net";
1496
1520
  var DEFAULT_PLANE_WORKSPACE = "personal";
1497
1521
  var STATE_GROUPS = ["backlog", "unstarted", "started", "completed", "cancelled"];
1498
- var RETRY_LIMIT2 = 4;
1499
1522
  function planeHost(env = process.env) {
1500
1523
  return (env.PLANE_HOST ?? DEFAULT_PLANE_HOST).replace(/\/$/, "");
1501
1524
  }
@@ -1696,6 +1719,30 @@ function queryPath(path, query) {
1696
1719
  return path;
1697
1720
  return `${path}${path.includes("?") ? "&" : "?"}${encoded}`;
1698
1721
  }
1722
+ function rateLimitWaitMs(headers, nowMs, on429) {
1723
+ const retryAfter = headers.get("Retry-After");
1724
+ if (retryAfter) {
1725
+ const seconds = Number(retryAfter.trim());
1726
+ if (Number.isFinite(seconds) && seconds >= 0)
1727
+ return seconds * 1000;
1728
+ const parsed = Date.parse(retryAfter);
1729
+ if (!Number.isNaN(parsed))
1730
+ return Math.max(0, parsed - nowMs);
1731
+ }
1732
+ const reset = headers.get("X-RateLimit-Reset");
1733
+ if (reset) {
1734
+ const n = Number(reset.trim());
1735
+ if (Number.isFinite(n)) {
1736
+ const resetMs = n < 1000000000000 ? n * 1000 : n;
1737
+ return Math.max(0, resetMs - nowMs);
1738
+ }
1739
+ }
1740
+ if (on429)
1741
+ return 60000;
1742
+ if (headers.get("X-RateLimit-Remaining")?.trim() === "0")
1743
+ return 60000;
1744
+ return 0;
1745
+ }
1699
1746
  function errorMessage(status, body) {
1700
1747
  try {
1701
1748
  const parsed = JSON.parse(body);
@@ -1718,33 +1765,63 @@ function createPlaneCli(opts) {
1718
1765
  const apiBase = planeApiBase(host);
1719
1766
  const defaultFlags = opts.flags ?? DEFAULT_FLAGS;
1720
1767
  const fetchFn = opts.fetch ?? fetch;
1721
- const retryDelayMs = opts.retryDelayMs ?? 1000;
1768
+ const nowFn = opts.now ?? Date.now;
1769
+ const sleepFn = opts.sleep ?? ((ms) => ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms)));
1722
1770
  let lastFlags = defaultFlags;
1723
1771
  let workItemResource = "work-items";
1724
1772
  let catalog;
1725
1773
  function workspaceOf(flags) {
1726
1774
  return planeWorkspace(flags || defaultFlags);
1727
1775
  }
1728
- async function request(path, init = {}, attempt = 0) {
1729
- const headers = new Headers(init.headers);
1730
- headers.set("X-API-Key", opts.apiKey);
1731
- if (init.body && !headers.has("content-type")) {
1732
- headers.set("content-type", "application/json");
1733
- }
1734
- const response = await fetchFn(joinUrl(apiBase, path), { ...init, headers });
1735
- const body = await response.text();
1736
- if (response.status === 429 && attempt < RETRY_LIMIT2) {
1737
- const delay = retryDelayMs * 2 ** attempt;
1738
- if (retryDelayMs)
1739
- await new Promise((resolve) => setTimeout(resolve, delay));
1740
- return request(path, init, attempt + 1);
1741
- }
1742
- if (!response.ok) {
1743
- const error = new Error(errorMessage(response.status, body));
1744
- error.status = response.status;
1745
- throw error;
1746
- }
1747
- return { status: response.status, body };
1776
+ let notBefore = 0;
1777
+ let gate = Promise.resolve();
1778
+ function locked(fn) {
1779
+ const run = gate.then(fn, fn);
1780
+ gate = run.then(() => {
1781
+ return;
1782
+ }, () => {
1783
+ return;
1784
+ });
1785
+ return run;
1786
+ }
1787
+ function fail(status, body) {
1788
+ const error = new Error(errorMessage(status, body));
1789
+ error.status = status;
1790
+ throw error;
1791
+ }
1792
+ function noteHeaders(headers, status) {
1793
+ const remaining = headers.get("X-RateLimit-Remaining")?.trim();
1794
+ if (status !== 429 && remaining !== "0")
1795
+ return;
1796
+ const wait = rateLimitWaitMs(headers, nowFn(), status === 429);
1797
+ notBefore = Math.max(notBefore, nowFn() + wait);
1798
+ }
1799
+ async function request(path, init = {}) {
1800
+ return locked(async () => {
1801
+ let retried = false;
1802
+ while (true) {
1803
+ const headers = new Headers(init.headers);
1804
+ headers.set("X-API-Key", opts.apiKey);
1805
+ if (init.body && !headers.has("content-type")) {
1806
+ headers.set("content-type", "application/json");
1807
+ }
1808
+ const delay = notBefore - nowFn();
1809
+ if (delay > 0)
1810
+ await sleepFn(delay);
1811
+ const response = await fetchFn(joinUrl(apiBase, path), { ...init, headers });
1812
+ const body = await response.text();
1813
+ noteHeaders(response.headers, response.status);
1814
+ if (response.status === 429) {
1815
+ if (retried)
1816
+ fail(response.status, body);
1817
+ retried = true;
1818
+ continue;
1819
+ }
1820
+ if (!response.ok)
1821
+ fail(response.status, body);
1822
+ return { status: response.status, body };
1823
+ }
1824
+ });
1748
1825
  }
1749
1826
  async function requestJson(path, init) {
1750
1827
  const { body } = await request(path, init);
@@ -1806,6 +1883,19 @@ function createPlaneCli(opts) {
1806
1883
  return;
1807
1884
  return { id, identifier, name: asString(row?.name) ?? identifier };
1808
1885
  }
1886
+ function parseWorkspace(raw) {
1887
+ const row = asRecord(raw);
1888
+ const slug = asString(row?.slug);
1889
+ if (!slug)
1890
+ return;
1891
+ return { id: asString(row?.id) ?? slug, name: asString(row?.name) ?? slug, slug };
1892
+ }
1893
+ function parseWorkspaceList(raw) {
1894
+ const rows = Array.isArray(raw) ? raw : asRecord(raw)?.results;
1895
+ if (!Array.isArray(rows))
1896
+ return;
1897
+ return rows.map(parseWorkspace).filter((row) => !!row);
1898
+ }
1809
1899
  function parseState(raw) {
1810
1900
  const row = asRecord(raw);
1811
1901
  const id = asString(row?.id);
@@ -1853,14 +1943,12 @@ function createPlaneCli(opts) {
1853
1943
  const modules = new Map;
1854
1944
  const columnStates = [];
1855
1945
  for (const project of projects) {
1856
- const [stateRows, labelRows, moduleRows, workRows] = await Promise.all([
1857
- paginate(`/workspaces/${workspace}/projects/${project.id}/states/`),
1858
- paginate(`/workspaces/${workspace}/projects/${project.id}/labels/`),
1859
- paginate(`/workspaces/${workspace}/projects/${project.id}/modules/`),
1860
- withResource((resource) => paginate(queryPath(`/workspaces/${workspace}/projects/${project.id}/${resource}/`, {
1861
- expand: "assignees,labels,state,module"
1862
- })))
1863
- ]);
1946
+ const stateRows = await paginate(`/workspaces/${workspace}/projects/${project.id}/states/`);
1947
+ const labelRows = await paginate(`/workspaces/${workspace}/projects/${project.id}/labels/`);
1948
+ const moduleRows = await paginate(`/workspaces/${workspace}/projects/${project.id}/modules/`);
1949
+ const workRows = await withResource((resource) => paginate(queryPath(`/workspaces/${workspace}/projects/${project.id}/${resource}/`, {
1950
+ expand: "assignees,labels,state,module"
1951
+ })));
1864
1952
  const stateById = new Map;
1865
1953
  for (const row of stateRows) {
1866
1954
  const state = parseState(row);
@@ -1891,11 +1979,11 @@ function createPlaneCli(opts) {
1891
1979
  const missingModules = moduleById.size > 0 && workItems.some((item) => item.module_ids == null && item.modules == null && item.module == null);
1892
1980
  const issueByModule = new Map;
1893
1981
  if (missingModules && moduleById.size) {
1894
- await Promise.all([...moduleById.keys()].map(async (moduleId) => {
1982
+ for (const moduleId of moduleById.keys()) {
1895
1983
  const rows = await paginate(`/workspaces/${workspace}/projects/${project.id}/modules/${moduleId}/module-issues/`);
1896
1984
  const ids = rows.map((row) => asId(asRecord(row)?.issue) ?? asId(row)).filter((id) => !!id);
1897
1985
  issueByModule.set(moduleId, ids);
1898
- }));
1986
+ }
1899
1987
  }
1900
1988
  for (const raw of workItems) {
1901
1989
  const item = raw;
@@ -2023,6 +2111,25 @@ function createPlaneCli(opts) {
2023
2111
  const loaded = await loadCatalog(flags || defaultFlags);
2024
2112
  return loaded.columns;
2025
2113
  },
2114
+ async listWorkspaces() {
2115
+ for (const path of ["/users/me/workspaces/", "/workspaces/"]) {
2116
+ try {
2117
+ return parseWorkspaceList(await requestJson(path)) ?? [];
2118
+ } catch (err) {
2119
+ const status = err.status;
2120
+ if (status === 404 && path === "/users/me/workspaces/")
2121
+ continue;
2122
+ return [];
2123
+ }
2124
+ }
2125
+ return [];
2126
+ },
2127
+ async listProjectIdentifiers(workspace) {
2128
+ const slug = workspace.trim();
2129
+ if (!slug)
2130
+ return [];
2131
+ return (await paginate(`/workspaces/${slug}/projects/`)).map(parseProject).filter((row) => !!row).map((row) => row.identifier);
2132
+ },
2026
2133
  async move(key, status) {
2027
2134
  try {
2028
2135
  const module = (await loadCatalog(lastFlags)).modules.get(key);
@@ -2146,7 +2253,8 @@ async function createBoardApp(opts) {
2146
2253
  store,
2147
2254
  cli,
2148
2255
  flags,
2149
- jiraConfigPath: jiraCliConfigPath(env)
2256
+ jiraConfigPath: jiraCliConfigPath(env),
2257
+ liveBackend: plane
2150
2258
  });
2151
2259
  if (opts.piped) {
2152
2260
  app.hydrate(opts.raw);
@@ -2189,11 +2297,46 @@ function reply(req, res, work) {
2189
2297
  json(res, 500, { error: message });
2190
2298
  });
2191
2299
  }
2192
- function handleAppApi(req, res, app) {
2300
+ async function boardEnvelope(body, kind, flags, env, app) {
2301
+ if (kind !== "plane")
2302
+ return { ...body, kind };
2303
+ const envelope = { ...body, kind, workspace: planeWorkspace(flags, env) };
2304
+ try {
2305
+ const workspaces = await app.listWorkspaces?.();
2306
+ if (workspaces && workspaces.length)
2307
+ return { ...envelope, workspaces };
2308
+ } catch {}
2309
+ return envelope;
2310
+ }
2311
+ function handleAppApi(req, res, app, opts = {}) {
2312
+ const kind = opts.kind ?? "store";
2313
+ const env = opts.env ?? process.env;
2193
2314
  const url = pathOf(req);
2194
2315
  const method = (req.method ?? "GET").toUpperCase();
2195
2316
  if (url.pathname === "/api/board" && method === "GET") {
2196
- json(res, 200, { ...app.board(), flags: app.flags });
2317
+ reply(req, res, async () => {
2318
+ json(res, 200, await boardEnvelope({ ...app.board(), flags: app.flags }, kind, app.flags, env, app));
2319
+ });
2320
+ return true;
2321
+ }
2322
+ if (url.pathname === "/api/projects" && method === "GET") {
2323
+ reply(req, res, async () => {
2324
+ if (kind !== "plane") {
2325
+ json(res, 404, { error: "not plane" });
2326
+ return;
2327
+ }
2328
+ const workspace = (url.searchParams.get("workspace") ?? "").trim();
2329
+ if (!workspace) {
2330
+ json(res, 400, { error: "workspace required" });
2331
+ return;
2332
+ }
2333
+ try {
2334
+ json(res, 200, { projects: await app.listProjectIdentifiers?.(workspace) ?? [] });
2335
+ } catch (err) {
2336
+ const message = err instanceof Error ? err.message : "catalog failed";
2337
+ json(res, 409, { error: message });
2338
+ }
2339
+ });
2197
2340
  return true;
2198
2341
  }
2199
2342
  if (url.pathname === "/api/refresh" && method === "POST") {
@@ -2205,10 +2348,10 @@ function handleAppApi(req, res, app) {
2205
2348
  json(res, 400, { error: "selected Refresh requires epicKeys" });
2206
2349
  return;
2207
2350
  }
2208
- json(res, 200, await app.refresh(undefined, { scope: "selected", epicKeys }));
2351
+ json(res, 200, await boardEnvelope(await app.refresh(undefined, { scope: "selected", epicKeys }), kind, app.flags, env, app));
2209
2352
  return;
2210
2353
  }
2211
- json(res, 200, await app.refresh(body.flags));
2354
+ json(res, 200, await boardEnvelope(await app.refresh(body.flags), kind, app.flags, env, app));
2212
2355
  });
2213
2356
  return true;
2214
2357
  }
@@ -13237,7 +13380,12 @@ function handleFakeJira(req, res, store) {
13237
13380
 
13238
13381
  // src/http.ts
13239
13382
  function handleRequest(req, res, ctx) {
13240
- return handleAppApi(req, res, ctx.app) || handleAgentApi(req, res, ctx.app) || handleFakeJira(req, res, ctx.store);
13383
+ if (handleAppApi(req, res, ctx.app, { kind: ctx.kind }) || handleAgentApi(req, res, ctx.app)) {
13384
+ return true;
13385
+ }
13386
+ if (ctx.kind === "plane")
13387
+ return false;
13388
+ return handleFakeJira(req, res, ctx.store);
13241
13389
  }
13242
13390
 
13243
13391
  // src/jira-config.ts
@@ -13356,7 +13504,7 @@ async function runServer(opts) {
13356
13504
  flags
13357
13505
  });
13358
13506
  const server = createServer((req, res) => {
13359
- if (handleRequest(req, res, { app, store }))
13507
+ if (handleRequest(req, res, { app, store, kind }))
13360
13508
  return;
13361
13509
  if (opts.fallback) {
13362
13510
  opts.fallback(req, res);
@@ -13367,20 +13515,30 @@ async function runServer(opts) {
13367
13515
  res.end("pipe-kan UI is missing. Run bun run build.");
13368
13516
  }
13369
13517
  });
13518
+ if (kind === "plane") {
13519
+ try {
13520
+ await refreshFromJira(app, kind, { piped: Boolean(piped) });
13521
+ } catch (err) {
13522
+ console.error(`${kind} Refresh failed`, err);
13523
+ }
13524
+ const error = app.board().error;
13525
+ if (error)
13526
+ console.error(`${kind} Refresh failed; keeping last live Board`, error);
13527
+ }
13370
13528
  const { host, port } = resolveListen();
13371
13529
  await bindListen(server, host, port);
13372
13530
  const origin = `http://127.0.0.1:${port}`;
13373
- const fakeConfig = writeJiraConfig(join10(tmpdir3(), "pipe-kan"), origin);
13374
13531
  announce(`pipe-kan http://${host}:${port}`);
13375
13532
  if (kind === "plane") {
13376
13533
  announce(`cli plane ${planeHost()} ${planeWorkspace(flags)}`);
13377
13534
  announce(`Plane host default ${DEFAULT_PLANE_HOST}`);
13378
13535
  } else {
13536
+ const fakeConfig = writeJiraConfig(join10(tmpdir3(), "pipe-kan"), origin);
13379
13537
  announce(`cli ${kind === "jira" ? resolveJiraBin() : "store"}`);
13380
13538
  announce(`Fake Jira ${origin}/rest/api/2/search`);
13381
13539
  announce(`Fake Jira config ${fakeConfig}`);
13382
13540
  }
13383
- if (kind === "jira" || kind === "plane") {
13541
+ if (kind === "jira") {
13384
13542
  try {
13385
13543
  await refreshFromJira(app, kind, { piped: Boolean(piped) });
13386
13544
  } catch (err) {