agendex-cli 0.15.0 → 0.17.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 +10 -0
  2. package/dist/cli.js +457 -51
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -57,6 +57,16 @@ AGENDEX_DEV=1 agendex sync
57
57
 
58
58
  In dev mode the default OAuth site (when you do not pass `--url` and do not set `AGENDEX_SITE_URL`) points at the local EE app URL used for development.
59
59
 
60
+ ## Sync Provenance
61
+
62
+ `agendex sync` and the daemon include sync provenance in cloud payload metadata so the web app can show where a plan was synced from. This includes the device ID, hostname, and the host machine's local IP address when one is available.
63
+
64
+ You can disable local IP address collection from Account settings in the cloud app. Managed or non-interactive environments can also omit the local IP address from sync payloads by setting:
65
+
66
+ ```bash
67
+ AGENDEX_DISABLE_LOCAL_IP=1 agendex sync
68
+ ```
69
+
60
70
  ## Daemon Cleanup
61
71
 
62
72
  `agendex cleanup` manages registered daemon devices in the cloud.
package/dist/cli.js CHANGED
@@ -1058,10 +1058,10 @@ var init_cleanup = __esm(() => {
1058
1058
  });
1059
1059
 
1060
1060
  // src/cli.ts
1061
- import { spawn as spawn3 } from "node:child_process";
1061
+ import { spawn as spawn4 } from "node:child_process";
1062
1062
  import { existsSync as existsSync11, statSync as statSync2, writeSync } from "node:fs";
1063
- import { resolve as resolve7 } from "node:path";
1064
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1063
+ import { resolve as resolve8 } from "node:path";
1064
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1065
1065
 
1066
1066
  // ../shared/src/adapters/catalog.ts
1067
1067
  import { homedir as homedir7 } from "node:os";
@@ -2797,12 +2797,14 @@ function normalizeStoredConfig(raw) {
2797
2797
  const cloudToken = typeof raw.cloudToken === "string" && raw.cloudToken.trim() ? raw.cloudToken : undefined;
2798
2798
  const convexUrl = typeof raw.convexUrl === "string" && raw.convexUrl.trim() ? raw.convexUrl : undefined;
2799
2799
  const deviceId = typeof raw.deviceId === "string" && raw.deviceId.trim() ? raw.deviceId : undefined;
2800
+ const collectLocalIpAddress = typeof raw.collectLocalIpAddress === "boolean" ? raw.collectLocalIpAddress : undefined;
2800
2801
  return {
2801
2802
  configVersion: 3,
2802
2803
  token,
2803
2804
  cloudToken,
2804
2805
  convexUrl,
2805
2806
  deviceId,
2807
+ collectLocalIpAddress,
2806
2808
  enabledAdapters: normalizeAdapterIds(raw.enabledAdapters),
2807
2809
  customPlanDirs: normalizeCustomPlanDirs(raw.customPlanDirs)
2808
2810
  };
@@ -2818,6 +2820,7 @@ function saveConfig(config) {
2818
2820
  cloudToken: config.cloudToken,
2819
2821
  convexUrl: config.convexUrl,
2820
2822
  deviceId: config.deviceId,
2823
+ collectLocalIpAddress: config.collectLocalIpAddress,
2821
2824
  enabledAdapters: sanitizeEnabledAdapterIds(config.enabledAdapters),
2822
2825
  customPlanDirs: normalizeCustomPlanDirs(config.customPlanDirs)
2823
2826
  };
@@ -3150,7 +3153,7 @@ function looksLikeExecutionReport(normalized) {
3150
3153
  const hasPastCompletion = /\b(?:fixed|pushed|committed|completed|done|implemented|updated|changed|patched|merged|deployed|passed|failed|resolved|reverted)\b/i.test(normalized);
3151
3154
  const hasReportSection = /^\s*(?:summary|result|results|changes|verification|status)\s*:/im.test(normalized);
3152
3155
  const hasReviewReportMarker = /\b(?:review findings?|review issues?|review comments?)\b/i.test(normalized);
3153
- const hasCommandMarker = /::[a-z0-9_-]+(?:\{|\[|\s*$)/im.test(normalized) || /`[^`]*(?:bun|npm|pnpm|yarn|git|tsc|biome)[^`]*`/i.test(normalized) || /\b(?:git\s+(?:stage|commit|push|status)|bunx?\s+|npm\s+|pnpm\s+|yarn\s+)\b/i.test(normalized);
3156
+ const hasCommandMarker = /::[a-z0-9_-]+(?:\{|\[|\s*$)/im.test(normalized) || /`[^`]*(?:bun|npm|pnpm|yarn|git|tsc|oxfmt|oxlint|biome)[^`]*`/i.test(normalized) || /\b(?:git\s+(?:stage|commit|push|status)|bunx?\s+|npm\s+|pnpm\s+|yarn\s+)\b/i.test(normalized);
3154
3157
  return hasPastCompletion && (hasReportSection || hasCommandMarker || hasReviewReportMarker);
3155
3158
  }
3156
3159
  function lowValueAssessment(reasons, signals) {
@@ -3882,7 +3885,7 @@ async function refreshStoredToken(currentToken, convexUrl) {
3882
3885
  }
3883
3886
  return refreshed.token;
3884
3887
  }
3885
- async function sendHeartbeat() {
3888
+ async function sendHeartbeat(ipAddress) {
3886
3889
  try {
3887
3890
  const { token, convexUrl } = getCloudConfig();
3888
3891
  const pidInfo = readPidInfo();
@@ -3891,7 +3894,8 @@ async function sendHeartbeat() {
3891
3894
  deviceId: cachedDeviceId,
3892
3895
  hostname: pidInfo?.hostname ?? osHostname(),
3893
3896
  startedAtMs: pidInfo?.startedAtMs,
3894
- pid: pidInfo?.pid
3897
+ pid: pidInfo?.pid,
3898
+ ipAddress: ipAddress ?? null
3895
3899
  });
3896
3900
  let activeToken = token;
3897
3901
  let res = await requestText(`${convexUrl}/api/cli/heartbeat`, {
@@ -3946,6 +3950,42 @@ async function refreshToken(currentToken, convexUrl) {
3946
3950
  return null;
3947
3951
  return { token: body.token, expiresAt: body.expiresAt ?? 0 };
3948
3952
  }
3953
+ async function fetchCliPreferences() {
3954
+ try {
3955
+ const { token, convexUrl } = getCloudConfig();
3956
+ let activeToken = token;
3957
+ let res = await requestText(`${convexUrl}/api/cli/preferences`, {
3958
+ method: "GET",
3959
+ headers: authHeaders(activeToken)
3960
+ });
3961
+ if (res.status === 401) {
3962
+ const refreshed = await refreshStoredToken(activeToken, convexUrl);
3963
+ if (refreshed) {
3964
+ activeToken = refreshed;
3965
+ res = await requestText(`${convexUrl}/api/cli/preferences`, {
3966
+ method: "GET",
3967
+ headers: authHeaders(activeToken)
3968
+ });
3969
+ }
3970
+ }
3971
+ if (res.status < 200 || res.status >= 300)
3972
+ return null;
3973
+ const body = JSON.parse(res.body);
3974
+ if (typeof body.collectLocalIpAddress !== "boolean")
3975
+ return null;
3976
+ const config = loadConfig();
3977
+ if (config) {
3978
+ saveConfig({
3979
+ ...config,
3980
+ collectLocalIpAddress: body.collectLocalIpAddress
3981
+ });
3982
+ }
3983
+ return { collectLocalIpAddress: body.collectLocalIpAddress };
3984
+ } catch {
3985
+ return null;
3986
+ }
3987
+ }
3988
+ var REQUEST_TIMEOUT_MS2 = Number.parseInt(process.env.AGENDEX_HTTP_TIMEOUT_MS ?? "", 10) || 1e4;
3949
3989
  function requestText(urlString, options) {
3950
3990
  const url = new URL(urlString);
3951
3991
  const request = url.protocol === "https:" ? httpsRequest : httpRequest;
@@ -3957,7 +3997,8 @@ function requestText(urlString, options) {
3957
3997
  const req = request(url, {
3958
3998
  agent: false,
3959
3999
  headers,
3960
- method: options.method
4000
+ method: options.method,
4001
+ timeout: REQUEST_TIMEOUT_MS2
3961
4002
  }, (res) => {
3962
4003
  let body = "";
3963
4004
  res.setEncoding("utf8");
@@ -3973,6 +4014,9 @@ function requestText(urlString, options) {
3973
4014
  res.on("error", reject);
3974
4015
  });
3975
4016
  req.on("error", reject);
4017
+ req.on("timeout", () => {
4018
+ req.destroy(new Error(`Request to ${url.host} timed out after ${REQUEST_TIMEOUT_MS2}ms`));
4019
+ });
3976
4020
  if (options.body) {
3977
4021
  req.write(options.body);
3978
4022
  }
@@ -4107,7 +4151,7 @@ async function deleteDaemons(deviceIds) {
4107
4151
  import { spawn } from "node:child_process";
4108
4152
  import { createServer } from "node:http";
4109
4153
  var PROD_SITE_URL = "https://app.agendex.dev";
4110
- var DEV_SITE_URL = "http://app.agendex.local:5174";
4154
+ var DEV_SITE_URL = "http://app.agendex.localhost:5174";
4111
4155
  function getDefaultSiteUrl() {
4112
4156
  if (process.env.AGENDEX_SITE_URL)
4113
4157
  return process.env.AGENDEX_SITE_URL;
@@ -4306,6 +4350,7 @@ function spawnBrowser(command, args, options = {}) {
4306
4350
 
4307
4351
  // src/daemon.ts
4308
4352
  import { spawn as spawn2 } from "node:child_process";
4353
+ import { hostname as osHostname2 } from "node:os";
4309
4354
  import { resolve as resolve6 } from "node:path";
4310
4355
  import { fileURLToPath } from "node:url";
4311
4356
 
@@ -4335,24 +4380,141 @@ function resolveCliAdapterIds(config) {
4335
4380
  return ids;
4336
4381
  }
4337
4382
 
4383
+ // src/network.ts
4384
+ import { execFileSync } from "node:child_process";
4385
+ import { networkInterfaces } from "node:os";
4386
+ import { platform } from "node:process";
4387
+ var DISABLE_LOCAL_IP_ENV = "AGENDEX_DISABLE_LOCAL_IP";
4388
+ function shouldCollectLocalIpAddress(env = process.env) {
4389
+ const value = env[DISABLE_LOCAL_IP_ENV]?.trim().toLowerCase();
4390
+ return !["1", "true", "yes", "on"].includes(value ?? "");
4391
+ }
4392
+ function getLocalIpAddress(options = {}) {
4393
+ const interfaces = options.interfaces ?? networkInterfaces();
4394
+ const route = options.defaultRoute === undefined ? getDefaultIpv4Route() : options.defaultRoute;
4395
+ const routedAddress = route ? getAddressForRoute(interfaces, route) : undefined;
4396
+ return routedAddress ?? findFirstAddress(interfaces, "IPv4") ?? findFirstAddress(interfaces, "IPv6");
4397
+ }
4398
+ function getAddressForRoute(interfaces, route) {
4399
+ if (route.sourceAddress && isUsableIpv4Address(route.sourceAddress))
4400
+ return route.sourceAddress;
4401
+ if (route.interfaceName)
4402
+ return findFirstAddress(interfaces, "IPv4", route.interfaceName);
4403
+ return;
4404
+ }
4405
+ function findFirstAddress(interfaces, family, interfaceName) {
4406
+ const entries = interfaceName ? [[interfaceName, interfaces[interfaceName]]] : Object.entries(interfaces).sort(([left], [right]) => left.localeCompare(right));
4407
+ for (const [, addrs] of entries) {
4408
+ if (!addrs)
4409
+ continue;
4410
+ for (const addr of addrs) {
4411
+ if (addr.internal)
4412
+ continue;
4413
+ if (addr.family === family)
4414
+ return addr.address;
4415
+ }
4416
+ }
4417
+ return;
4418
+ }
4419
+ function getDefaultIpv4Route() {
4420
+ switch (platform) {
4421
+ case "linux":
4422
+ return parseLinuxRoute(readRouteCommand("ip", ["route", "get", "1.1.1.1"]));
4423
+ case "darwin":
4424
+ case "freebsd":
4425
+ case "netbsd":
4426
+ case "openbsd":
4427
+ return parseBsdRoute(readRouteCommand("route", ["-n", "get", "1.1.1.1"]));
4428
+ case "win32":
4429
+ return getWindowsDefaultRouteAddress();
4430
+ default:
4431
+ return;
4432
+ }
4433
+ }
4434
+ function readRouteCommand(command, args) {
4435
+ try {
4436
+ return execFileSync(command, args, {
4437
+ encoding: "utf8",
4438
+ stdio: ["ignore", "pipe", "ignore"],
4439
+ timeout: 1000
4440
+ });
4441
+ } catch {
4442
+ return;
4443
+ }
4444
+ }
4445
+ function parseLinuxRoute(output) {
4446
+ if (!output)
4447
+ return;
4448
+ return buildRoute({
4449
+ interfaceName: output.match(/\bdev\s+(\S+)/)?.[1],
4450
+ sourceAddress: output.match(/\bsrc\s+(\d{1,3}(?:\.\d{1,3}){3})\b/)?.[1]
4451
+ });
4452
+ }
4453
+ function parseBsdRoute(output) {
4454
+ if (!output)
4455
+ return;
4456
+ return buildRoute({
4457
+ interfaceName: output.match(/^\s*interface:\s+(\S+)/m)?.[1]
4458
+ });
4459
+ }
4460
+ function getWindowsDefaultRouteAddress() {
4461
+ const script = [
4462
+ "$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0'",
4463
+ "| Sort-Object -Property @{ Expression = { $_.RouteMetric + $_.InterfaceMetric } }",
4464
+ "| Select-Object -First 1;",
4465
+ "if ($route) {",
4466
+ "Get-NetIPAddress -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex",
4467
+ "| Where-Object { $_.IPAddress -notlike '169.254.*' }",
4468
+ "| Select-Object -First 1 -ExpandProperty IPAddress",
4469
+ "}"
4470
+ ].join(" ");
4471
+ const output = readRouteCommand("powershell.exe", [
4472
+ "-NoProfile",
4473
+ "-NonInteractive",
4474
+ "-Command",
4475
+ script
4476
+ ]);
4477
+ const sourceAddress = output?.split(/\r?\n/).map((line) => line.trim()).find(isUsableIpv4Address);
4478
+ return buildRoute({ sourceAddress });
4479
+ }
4480
+ function buildRoute(route) {
4481
+ return route.interfaceName || route.sourceAddress ? route : undefined;
4482
+ }
4483
+ function isUsableIpv4Address(address) {
4484
+ if (!isIpv4Address(address))
4485
+ return false;
4486
+ return address !== "0.0.0.0" && !address.startsWith("127.");
4487
+ }
4488
+ function isIpv4Address(address) {
4489
+ const octets = address.split(".");
4490
+ return octets.length === 4 && octets.every((octet) => {
4491
+ if (!/^\d+$/.test(octet))
4492
+ return false;
4493
+ const value = Number(octet);
4494
+ return value >= 0 && value <= 255;
4495
+ });
4496
+ }
4497
+
4338
4498
  // src/payload.ts
4339
4499
  var SYNC_METADATA_KEY = "agendexSync";
4340
4500
  function isRecord4(value) {
4341
4501
  return typeof value === "object" && value !== null;
4342
4502
  }
4343
- function withSyncDeviceMetadata(metadata, deviceId) {
4344
- if (!deviceId)
4503
+ function withSyncDeviceMetadata(metadata, deviceId, hostname2, ipAddress) {
4504
+ if (!deviceId && !hostname2 && !ipAddress)
4345
4505
  return metadata;
4346
4506
  const existing = isRecord4(metadata[SYNC_METADATA_KEY]) ? metadata[SYNC_METADATA_KEY] : {};
4347
4507
  return {
4348
4508
  ...metadata,
4349
4509
  [SYNC_METADATA_KEY]: {
4350
4510
  ...existing,
4351
- deviceId
4511
+ ...deviceId !== undefined && { deviceId },
4512
+ ...hostname2 !== undefined && { hostname: hostname2 },
4513
+ ...ipAddress !== undefined && { ipAddress }
4352
4514
  }
4353
4515
  };
4354
4516
  }
4355
- function planToSyncPayload(plan, deviceId) {
4517
+ function planToSyncPayload(plan, deviceId, hostname2, ipAddress) {
4356
4518
  return {
4357
4519
  localPlanId: plan.id,
4358
4520
  agent: plan.agent,
@@ -4361,7 +4523,7 @@ function planToSyncPayload(plan, deviceId) {
4361
4523
  format: plan.format,
4362
4524
  filePath: plan.filePath,
4363
4525
  workspace: plan.workspace,
4364
- metadata: withSyncDeviceMetadata(plan.metadata, deviceId),
4526
+ metadata: withSyncDeviceMetadata(plan.metadata, deviceId, hostname2, ipAddress),
4365
4527
  createdAt: plan.createdAt.getTime(),
4366
4528
  updatedAt: plan.updatedAt.getTime()
4367
4529
  };
@@ -4415,6 +4577,16 @@ function computePayloadHash(payload) {
4415
4577
  return createHash2("sha256").update(canonical).digest("hex").slice(0, 20);
4416
4578
  }
4417
4579
 
4580
+ // src/sync-privacy.ts
4581
+ async function shouldIncludeLocalIpAddressInSync() {
4582
+ if (!shouldCollectLocalIpAddress())
4583
+ return false;
4584
+ const prefs = await fetchCliPreferences();
4585
+ if (prefs)
4586
+ return prefs.collectLocalIpAddress;
4587
+ return loadConfig()?.collectLocalIpAddress ?? true;
4588
+ }
4589
+
4418
4590
  // src/writeback-delivery-cache.ts
4419
4591
  import { existsSync as existsSync9, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
4420
4592
  import { join as join13 } from "node:path";
@@ -4472,15 +4644,25 @@ var PLANNOTATOR_WRITEBACK_EXPIRED_ERROR = "Write-back expired before delivery.";
4472
4644
  var PLANNOTATOR_WRITEBACK_FAILED_ERROR = "No live Plannotator session accepted the request-changes payload.";
4473
4645
  async function runWorker() {
4474
4646
  const config = await loadOrInitConfig();
4647
+ const hostname2 = osHostname2();
4475
4648
  const adapterIds = resolveCliAdapterIds(config);
4476
4649
  const adapters = resolveAdapters(adapterIds);
4477
4650
  setActiveAdapters(adapters);
4478
- console.log(`[agendex] daemon starting with ${adapterIds.length} adapters`);
4479
- await sendHeartbeat();
4480
4651
  const syncCache = loadSyncCache();
4481
4652
  const syncQueue = [];
4482
4653
  const pendingWritebackReports = loadPendingWritebackReports();
4483
4654
  let syncing = false;
4655
+ let cachedIpAddress;
4656
+ async function getSyncIpAddress() {
4657
+ if (!await shouldIncludeLocalIpAddressInSync()) {
4658
+ cachedIpAddress = undefined;
4659
+ return;
4660
+ }
4661
+ cachedIpAddress ??= getLocalIpAddress();
4662
+ return cachedIpAddress;
4663
+ }
4664
+ console.log(`[agendex] daemon starting with ${adapterIds.length} adapters`);
4665
+ await sendHeartbeat(await getSyncIpAddress());
4484
4666
  async function tryRefreshToken() {
4485
4667
  const cfg = loadConfig();
4486
4668
  if (!cfg?.cloudToken || !cfg.convexUrl)
@@ -4595,8 +4777,9 @@ async function runWorker() {
4595
4777
  });
4596
4778
  if (ok) {
4597
4779
  const updatedPlan = getById(job.localPlanId);
4598
- if (updatedPlan)
4599
- syncQueue.push(planToSyncPayload(updatedPlan, config.deviceId));
4780
+ if (updatedPlan) {
4781
+ syncQueue.push(planToSyncPayload(updatedPlan, config.deviceId, hostname2, await getSyncIpAddress()));
4782
+ }
4600
4783
  pendingWritebackReports.set(job._id, "sent");
4601
4784
  persistPendingWritebackReports();
4602
4785
  await reportPendingWriteback(job._id);
@@ -4636,8 +4819,9 @@ async function runWorker() {
4636
4819
  let initialSkipped = 0;
4637
4820
  let initialQueuedSyncable = 0;
4638
4821
  let initialQueuedLowValue = 0;
4822
+ const initialIpAddress = await getSyncIpAddress();
4639
4823
  for (const plan of plans) {
4640
- const payload = planToSyncPayload(plan, config.deviceId);
4824
+ const payload = planToSyncPayload(plan, config.deviceId, hostname2, initialIpAddress);
4641
4825
  const hash = computePayloadHash(payload);
4642
4826
  if (syncCache[plan.id] === hash) {
4643
4827
  initialSkipped++;
@@ -4659,16 +4843,25 @@ async function runWorker() {
4659
4843
  const lowValueSuffix = lowValuePlanCount > 0 ? `, ${lowValuePlanCount} low-value hidden/pruned` : "";
4660
4844
  console.log(`[agendex] syncing ${initialQueuedSyncable} plans${lowValueSuffix} (${initialQueuedLowValue} low-value queued, ${initialSkipped} unchanged)...`);
4661
4845
  await processSyncQueue();
4662
- setInterval(() => void sendHeartbeat(), CLI_DAEMON_HEARTBEAT_INTERVAL_MS);
4846
+ setInterval(() => {
4847
+ (async () => {
4848
+ await sendHeartbeat(await getSyncIpAddress());
4849
+ })().catch(() => {});
4850
+ }, CLI_DAEMON_HEARTBEAT_INTERVAL_MS);
4663
4851
  if (shouldEnablePlannotatorSync(config)) {
4664
4852
  setInterval(() => void pollPlannotatorWritebacks(), PLANNOTATOR_WRITEBACK_POLL_INTERVAL_MS);
4665
4853
  pollPlannotatorWritebacks();
4666
4854
  }
4667
4855
  startWatching((changedPlans) => {
4668
- for (const plan of changedPlans) {
4669
- syncQueue.push(planToSyncPayload(plan, config.deviceId));
4670
- }
4671
- processSyncQueue();
4856
+ (async () => {
4857
+ const ipAddress = await getSyncIpAddress();
4858
+ for (const plan of changedPlans) {
4859
+ syncQueue.push(planToSyncPayload(plan, config.deviceId, hostname2, ipAddress));
4860
+ }
4861
+ processSyncQueue();
4862
+ })().catch((err) => {
4863
+ console.error("[agendex] failed to queue changed plans:", err);
4864
+ });
4672
4865
  });
4673
4866
  console.log(`[agendex] daemon running. Watching for file changes...`);
4674
4867
  async function gracefulShutdown() {
@@ -4729,8 +4922,11 @@ async function startSupervisor() {
4729
4922
  }
4730
4923
 
4731
4924
  // src/sync.ts
4925
+ import { hostname as osHostname3 } from "node:os";
4732
4926
  async function syncAll(force = false) {
4733
4927
  const config = await loadOrInitConfig();
4928
+ const hostname2 = osHostname3();
4929
+ const ipAddress = await shouldIncludeLocalIpAddressInSync() ? getLocalIpAddress() : undefined;
4734
4930
  const adapterIds = resolveCliAdapterIds(config);
4735
4931
  const adapters = resolveAdapters(adapterIds);
4736
4932
  setActiveAdapters(adapters);
@@ -4750,7 +4946,7 @@ async function syncAll(force = false) {
4750
4946
  let failed = 0;
4751
4947
  for (const plan of [...syncablePlans, ...lowValuePlans]) {
4752
4948
  activePlanIds.add(plan.id);
4753
- const payload = planToSyncPayload(plan, config.deviceId);
4949
+ const payload = planToSyncPayload(plan, config.deviceId, hostname2, ipAddress);
4754
4950
  const hash = computePayloadHash(payload);
4755
4951
  if (!force && cache[plan.id] === hash) {
4756
4952
  skipped++;
@@ -4780,6 +4976,12 @@ async function syncAll(force = false) {
4780
4976
  console.log(`[agendex] Sync complete: ${synced} synced${lowValueSuffix}, ${skipped} unchanged, ${failed} failed`);
4781
4977
  }
4782
4978
 
4979
+ // src/upgrade.ts
4980
+ import { spawn as spawn3, spawnSync } from "node:child_process";
4981
+ import { realpathSync as realpathSync2 } from "node:fs";
4982
+ import { dirname as dirname4, resolve as resolve7, sep as sep4 } from "node:path";
4983
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
4984
+
4783
4985
  // src/version.ts
4784
4986
  import { existsSync as existsSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
4785
4987
  import { tmpdir } from "node:os";
@@ -4787,19 +4989,18 @@ import { join as join14 } from "node:path";
4787
4989
  // package.json
4788
4990
  var package_default = {
4789
4991
  name: "agendex-cli",
4790
- version: "0.15.0",
4992
+ version: "0.17.0",
4791
4993
  description: "Agendex CLI for login, sync, and daemon workflows",
4792
4994
  homepage: "https://github.com/Tyru5/Agendex#readme",
4995
+ bugs: {
4996
+ url: "https://github.com/Tyru5/Agendex/issues"
4997
+ },
4998
+ license: "AGPL-3.0-only",
4793
4999
  repository: {
4794
5000
  type: "git",
4795
5001
  url: "git+https://github.com/Tyru5/Agendex.git",
4796
5002
  directory: "packages/cli"
4797
5003
  },
4798
- bugs: {
4799
- url: "https://github.com/Tyru5/Agendex/issues"
4800
- },
4801
- type: "module",
4802
- license: "AGPL-3.0-only",
4803
5004
  bin: {
4804
5005
  agendex: "./dist/cli.js"
4805
5006
  },
@@ -4808,12 +5009,10 @@ var package_default = {
4808
5009
  "README.md",
4809
5010
  "LICENSE"
4810
5011
  ],
5012
+ type: "module",
4811
5013
  publishConfig: {
4812
5014
  access: "public"
4813
5015
  },
4814
- engines: {
4815
- node: ">=20"
4816
- },
4817
5016
  scripts: {
4818
5017
  build: "node ./scripts/build-release.mjs --dist-only",
4819
5018
  "build:release": "node ./scripts/build-release.mjs",
@@ -4826,6 +5025,9 @@ var package_default = {
4826
5025
  devDependencies: {
4827
5026
  "@agendex/shared": "workspace:*",
4828
5027
  "@types/bun": "^1.3.9"
5028
+ },
5029
+ engines: {
5030
+ node: ">=20"
4829
5031
  }
4830
5032
  };
4831
5033
 
@@ -4851,11 +5053,13 @@ function writeCache(result) {
4851
5053
  writeFileSync5(CACHE_FILE, JSON.stringify({ result, ts: Date.now() }));
4852
5054
  } catch {}
4853
5055
  }
4854
- async function checkForUpdate() {
5056
+ async function checkForUpdate(options = {}) {
4855
5057
  const current = CLI_VERSION;
4856
- const cached = readCache(current);
4857
- if (cached)
4858
- return cached;
5058
+ if (!options.forceRefresh) {
5059
+ const cached = readCache(current);
5060
+ if (cached)
5061
+ return cached;
5062
+ }
4859
5063
  try {
4860
5064
  const controller = new AbortController;
4861
5065
  const timeout = setTimeout(() => controller.abort(), 3000);
@@ -4863,21 +5067,27 @@ async function checkForUpdate() {
4863
5067
  signal: controller.signal
4864
5068
  }).finally(() => clearTimeout(timeout));
4865
5069
  if (!res.ok) {
4866
- return { updateAvailable: false, current, latest: current };
5070
+ return { checked: false, updateAvailable: false, current, latest: current };
4867
5071
  }
4868
5072
  const data = await res.json();
4869
5073
  const latest = data.version;
4870
- const result = { updateAvailable: isNewer(latest, current), current, latest };
5074
+ const result = {
5075
+ checked: true,
5076
+ updateAvailable: isNewer(latest, current),
5077
+ current,
5078
+ latest
5079
+ };
4871
5080
  writeCache(result);
4872
5081
  return result;
4873
5082
  } catch {
4874
- return { updateAvailable: false, current, latest: current };
5083
+ return { checked: false, updateAvailable: false, current, latest: current };
4875
5084
  }
4876
5085
  }
4877
5086
  function normalizeResult(result, current) {
4878
5087
  if (typeof result.latest !== "string")
4879
5088
  return null;
4880
5089
  return {
5090
+ checked: true,
4881
5091
  updateAvailable: isNewer(result.latest, current),
4882
5092
  current,
4883
5093
  latest: result.latest
@@ -4897,6 +5107,195 @@ function isNewer(latest, current) {
4897
5107
  return false;
4898
5108
  }
4899
5109
 
5110
+ // src/upgrade.ts
5111
+ var PACKAGE_NAME = "agendex-cli";
5112
+ var moduleDir = dirname4(fileURLToPath2(import.meta.url));
5113
+ function getPackageRoot() {
5114
+ try {
5115
+ return realpathSync2(resolve7(moduleDir, ".."));
5116
+ } catch {
5117
+ return resolve7(moduleDir, "..");
5118
+ }
5119
+ }
5120
+ function detectPackageManager(packageRoot) {
5121
+ const userAgent = process.env.npm_config_user_agent ?? "";
5122
+ const execpath = process.env.npm_execpath ?? "";
5123
+ if (userAgent.startsWith("bun/") || execpath.includes("bun"))
5124
+ return "bun";
5125
+ if (userAgent.startsWith("pnpm/") || execpath.includes("pnpm"))
5126
+ return "pnpm";
5127
+ if (userAgent.startsWith("yarn/") || execpath.includes("yarn"))
5128
+ return "yarn";
5129
+ const lower = packageRoot.toLowerCase();
5130
+ if (lower.includes(`${sep4}.bun${sep4}`) || lower.includes("/.bun/"))
5131
+ return "bun";
5132
+ if (lower.includes(`${sep4}pnpm${sep4}`) || lower.includes("/pnpm/"))
5133
+ return "pnpm";
5134
+ if (lower.includes(`${sep4}yarn${sep4}`) || lower.includes("/yarn/"))
5135
+ return "yarn";
5136
+ if (typeof globalThis.Bun !== "undefined" || process.versions.bun) {
5137
+ return "bun";
5138
+ }
5139
+ return "npm";
5140
+ }
5141
+ function readYarnVersion() {
5142
+ const result = spawnSync("yarn", ["--version"], {
5143
+ encoding: "utf8",
5144
+ shell: process.platform === "win32"
5145
+ });
5146
+ if (result.error || result.status !== 0)
5147
+ return null;
5148
+ return result.stdout.trim() || null;
5149
+ }
5150
+ function parseMajorVersion(version) {
5151
+ const match = version.match(/^(\d+)/);
5152
+ if (!match)
5153
+ return null;
5154
+ const major = Number(match[1]);
5155
+ return Number.isFinite(major) ? major : null;
5156
+ }
5157
+ function buildGlobalInstallCommand(pm) {
5158
+ const pkgSpec = `${PACKAGE_NAME}@latest`;
5159
+ switch (pm) {
5160
+ case "bun":
5161
+ return {
5162
+ supported: true,
5163
+ command: { bin: "bun", args: ["add", "-g", pkgSpec], display: `bun add -g ${pkgSpec}` }
5164
+ };
5165
+ case "pnpm":
5166
+ return {
5167
+ supported: true,
5168
+ command: {
5169
+ bin: "pnpm",
5170
+ args: ["add", "-g", pkgSpec],
5171
+ display: `pnpm add -g ${pkgSpec}`
5172
+ }
5173
+ };
5174
+ case "yarn": {
5175
+ const yarnVersion = readYarnVersion();
5176
+ const yarnMajorVersion = yarnVersion ? parseMajorVersion(yarnVersion) : null;
5177
+ if (yarnMajorVersion !== null && yarnMajorVersion >= 2) {
5178
+ return {
5179
+ supported: false,
5180
+ reason: `automatic upgrade with Yarn only supports Yarn Classic (v1); detected Yarn v${yarnVersion}.`,
5181
+ manualCommand: `npm install -g ${pkgSpec}`
5182
+ };
5183
+ }
5184
+ return {
5185
+ supported: true,
5186
+ command: {
5187
+ bin: "yarn",
5188
+ args: ["global", "add", pkgSpec],
5189
+ display: `yarn global add ${pkgSpec}`
5190
+ }
5191
+ };
5192
+ }
5193
+ default:
5194
+ return {
5195
+ supported: true,
5196
+ command: {
5197
+ bin: "npm",
5198
+ args: ["install", "-g", pkgSpec],
5199
+ display: `npm install -g ${pkgSpec}`
5200
+ }
5201
+ };
5202
+ }
5203
+ }
5204
+ function isLikelyGlobalInstall(packageRoot) {
5205
+ const normalized = packageRoot.replace(/\\/g, "/");
5206
+ if (!normalized.includes("/node_modules/")) {
5207
+ return false;
5208
+ }
5209
+ const globalMarkers = [
5210
+ "/lib/node_modules/",
5211
+ "/pnpm/global/",
5212
+ "/yarn/global/",
5213
+ "/.bun/install/global/",
5214
+ "/.bun/install/",
5215
+ "/.config/yarn/global/",
5216
+ "/AppData/Roaming/npm/",
5217
+ "/AppData/Local/Yarn/",
5218
+ "/AppData/Local/pnpm/"
5219
+ ];
5220
+ return globalMarkers.some((marker) => normalized.includes(marker));
5221
+ }
5222
+ async function runUpgrade(opts) {
5223
+ const packageRoot = getPackageRoot();
5224
+ const pm = detectPackageManager(packageRoot);
5225
+ const isGlobal = isLikelyGlobalInstall(packageRoot);
5226
+ if (!isGlobal) {
5227
+ process.stderr.write(`[agendex] this CLI appears to be running from a local checkout or linked install:
5228
+ ` + `[agendex] ${packageRoot}
5229
+ ` + `[agendex] automatic upgrade only supports global installs.
5230
+ ` + `[agendex] reinstall globally (e.g. \`npm install -g ${PACKAGE_NAME}@latest\`) or update the source repo manually.
5231
+ `);
5232
+ return 1;
5233
+ }
5234
+ const { checked, updateAvailable, current, latest } = await checkForUpdate({
5235
+ forceRefresh: true
5236
+ });
5237
+ if (checked && !updateAvailable && !opts.force) {
5238
+ process.stdout.write(`[agendex] already up to date (v${current})
5239
+ `);
5240
+ return 0;
5241
+ }
5242
+ if (!checked) {
5243
+ process.stderr.write(`[agendex] could not verify the latest version; attempting upgrade anyway...
5244
+ `);
5245
+ }
5246
+ const commandResult = buildGlobalInstallCommand(pm);
5247
+ if (!commandResult.supported) {
5248
+ process.stderr.write(`[agendex] ${commandResult.reason}
5249
+ `);
5250
+ process.stderr.write(`[agendex] install the latest CLI manually, e.g. \`${commandResult.manualCommand}\`.
5251
+ `);
5252
+ return 1;
5253
+ }
5254
+ const cmd = commandResult.command;
5255
+ if (checked && updateAvailable) {
5256
+ process.stdout.write(`[agendex] upgrading: v${current} → v${latest}
5257
+ `);
5258
+ } else if (opts.force) {
5259
+ process.stdout.write(`[agendex] reinstalling v${CLI_VERSION} (forced)
5260
+ `);
5261
+ }
5262
+ process.stdout.write(`[agendex] running: ${cmd.display}
5263
+ `);
5264
+ return await new Promise((resolveExit) => {
5265
+ const child = spawn3(cmd.bin, cmd.args, {
5266
+ stdio: "inherit",
5267
+ shell: process.platform === "win32",
5268
+ env: process.env
5269
+ });
5270
+ let didError = false;
5271
+ child.on("error", (err) => {
5272
+ didError = true;
5273
+ process.stderr.write(`[agendex] failed to run ${cmd.bin}: ${err instanceof Error ? err.message : String(err)}
5274
+ `);
5275
+ process.stderr.write(`[agendex] you can run it manually: ${cmd.display}
5276
+ `);
5277
+ resolveExit(1);
5278
+ });
5279
+ child.on("close", (code) => {
5280
+ if (didError)
5281
+ return;
5282
+ if (code === 0) {
5283
+ process.stdout.write(`[agendex] upgrade complete.
5284
+ `);
5285
+ process.stdout.write(`[agendex] note: if the daemon is running, restart it: \`agendex stop && agendex start\`
5286
+ `);
5287
+ resolveExit(0);
5288
+ return;
5289
+ }
5290
+ process.stderr.write(`[agendex] upgrade failed with exit code ${code ?? "unknown"}
5291
+ `);
5292
+ process.stderr.write(`[agendex] you can run it manually: ${cmd.display}
5293
+ `);
5294
+ resolveExit(code ?? 1);
5295
+ });
5296
+ });
5297
+ }
5298
+
4900
5299
  // src/web.ts
4901
5300
  async function openAgendexWeb(siteUrlOverride) {
4902
5301
  const base = siteUrlOverride ?? getDefaultSiteUrl();
@@ -4934,7 +5333,7 @@ function firstCommandToken(argv) {
4934
5333
  return;
4935
5334
  }
4936
5335
  var command = firstCommandToken(args) ?? "start";
4937
- var cliEntry = resolve7(process.argv[1] ?? fileURLToPath2(import.meta.url));
5336
+ var cliEntry = resolve8(process.argv[1] ?? fileURLToPath3(import.meta.url));
4938
5337
  async function main() {
4939
5338
  const isInternal = args.includes("--daemon") || args.includes("--worker");
4940
5339
  if (command === "--version" || command === "-v") {
@@ -4952,16 +5351,16 @@ async function main() {
4952
5351
  "add-dir",
4953
5352
  "remove-dir",
4954
5353
  "list-dirs",
5354
+ "upgrade",
4955
5355
  "help",
4956
5356
  "--help",
4957
5357
  "-h"
4958
5358
  ].includes(command);
4959
5359
  if (!isInternal && !isPassthrough) {
4960
- const { updateAvailable, current, latest } = await checkForUpdate();
4961
- if (updateAvailable) {
4962
- writeStderr(`[agendex] update required: v${current} → v${latest}`);
4963
- writeStderr(`[agendex] run: npm i -g agendex-cli`);
4964
- return 1;
5360
+ const { checked, updateAvailable, current, latest } = await checkForUpdate();
5361
+ if (checked && updateAvailable) {
5362
+ writeStderr(`[agendex] update available: v${current} → v${latest}`);
5363
+ writeStderr(`[agendex] run: agendex upgrade`);
4965
5364
  }
4966
5365
  }
4967
5366
  switch (command) {
@@ -5000,7 +5399,7 @@ async function main() {
5000
5399
  const daemonArgs = [cliEntry, "start", "--daemon"];
5001
5400
  if (devFlag)
5002
5401
  daemonArgs.push("--dev");
5003
- const child = spawn3(process.execPath, daemonArgs, {
5402
+ const child = spawn4(process.execPath, daemonArgs, {
5004
5403
  detached: true,
5005
5404
  stdio: "ignore",
5006
5405
  env: { ...process.env, ...devFlag ? { AGENDEX_DEV: "1" } : {} }
@@ -5153,7 +5552,7 @@ async function main() {
5153
5552
  const { request } = await import("node:http");
5154
5553
  const body = JSON.stringify({ path: resolved });
5155
5554
  try {
5156
- const res = await new Promise((resolve8, reject) => {
5555
+ const res = await new Promise((resolve9, reject) => {
5157
5556
  const req = request(`http://localhost:${port}/api/v1/plan-sources`, {
5158
5557
  method: "POST",
5159
5558
  headers: {
@@ -5167,7 +5566,7 @@ async function main() {
5167
5566
  res2.on("data", (chunk) => {
5168
5567
  data += chunk;
5169
5568
  });
5170
- res2.on("end", () => resolve8({ status: res2.statusCode ?? 0, body: data }));
5569
+ res2.on("end", () => resolve9({ status: res2.statusCode ?? 0, body: data }));
5171
5570
  res2.on("error", reject);
5172
5571
  });
5173
5572
  req.on("error", reject);
@@ -5277,8 +5676,10 @@ async function main() {
5277
5676
  const uptimeStr = device.startedAtMs != null ? formatDuration(now - device.startedAtMs) : "~";
5278
5677
  const pidStr = device.pid != null ? String(device.pid) : "~";
5279
5678
  const hostnameStr = device.hostname ?? "~";
5679
+ const ipStr = device.ipAddress ?? "~";
5280
5680
  const isLocal = localDeviceId && device.deviceId === localDeviceId;
5281
5681
  writeStdout(`- hostname: ${hostnameStr}${isLocal ? " (this machine)" : ""}
5682
+ ip: ${ipStr}
5282
5683
  pid: ${pidStr}
5283
5684
  uptime: ${uptimeStr}
5284
5685
  status: ${status}`);
@@ -5295,6 +5696,9 @@ async function main() {
5295
5696
  }
5296
5697
  return 0;
5297
5698
  }
5699
+ case "upgrade": {
5700
+ return await runUpgrade({ force: args.includes("--force") });
5701
+ }
5298
5702
  case "help":
5299
5703
  case "--help":
5300
5704
  case "-h": {
@@ -5321,6 +5725,8 @@ Usage:
5321
5725
  agendex cleanup Interactively remove cloud daemons
5322
5726
  agendex cleanup --stale Auto-remove all stale daemons
5323
5727
  agendex status Show current config state + daemon status
5728
+ agendex upgrade Upgrade the globally installed CLI to the latest version
5729
+ agendex upgrade --force Reinstall latest even if already up to date
5324
5730
  agendex help Show this help message
5325
5731
  agendex --version Print CLI version
5326
5732
  agendex -v Print CLI version
@@ -5349,13 +5755,13 @@ function flushStream(stream) {
5349
5755
  if (stream.destroyed || !stream.writable) {
5350
5756
  return Promise.resolve();
5351
5757
  }
5352
- return new Promise((resolve8, reject) => {
5758
+ return new Promise((resolve9, reject) => {
5353
5759
  stream.write("", (error) => {
5354
5760
  if (error) {
5355
5761
  reject(error);
5356
5762
  return;
5357
5763
  }
5358
- resolve8();
5764
+ resolve9();
5359
5765
  });
5360
5766
  });
5361
5767
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agendex-cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Agendex CLI for login, sync, and daemon workflows",
5
5
  "homepage": "https://github.com/Tyru5/Agendex#readme",
6
6
  "repository": {