@togglhq/cli 1.7.2 → 1.8.1

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
@@ -290,6 +290,18 @@ TOGGL_DEBUG=1 toggl tasks list
290
290
 
291
291
  Prints schema validation warnings from API responses to stderr.
292
292
 
293
+ ## Privacy & analytics
294
+
295
+ Public builds may send product analytics: command path, the leaf command as `action`, outcome (`success` or `error`), client, session id and package version. Command arguments and output are never collected. Signed-in sessions are identified by your Toggl account id so repeat use is not counted as a new person each time; unauthenticated processes stay anonymous.
296
+
297
+ Disable product analytics:
298
+
299
+ ```bash
300
+ TOGGL_PRODUCT_ANALYTICS=off
301
+ ```
302
+
303
+ Optional error reporting via Sentry is off unless you set `TOGGL_SENTRY=1` and `SENTRY_DSN`.
304
+
293
305
  ## License
294
306
 
295
307
  Proprietary Toggl software. See [LICENSE](LICENSE) and [Toggl legal terms](https://toggl.com/legal/).
package/build/cli.js CHANGED
@@ -1,11 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { n as CommanderError, t as createProgram } from "./program-COtKnkok.js";
2
+ import { a as CommanderError, i as shutdownCliTelemetry, n as captureCliException, r as initCliTelemetry, t as createProgram } from "./program-CH5epPVt.js";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  //#region src/cli.ts
7
+ const CLI_PACKAGE_VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf-8")).version;
8
+ initCliTelemetry(CLI_PACKAGE_VERSION);
4
9
  try {
5
10
  await createProgram({ enableUpdateNotification: true }).parseAsync(process.argv);
6
11
  } catch (error) {
12
+ captureCliException(error);
7
13
  if (error instanceof CommanderError) process.exit(error.exitCode);
8
14
  throw error;
15
+ } finally {
16
+ await shutdownCliTelemetry();
9
17
  }
10
18
  //#endregion
11
19
  export {};
package/build/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { t as createProgram } from "./program-COtKnkok.js";
1
+ import { t as createProgram } from "./program-CH5epPVt.js";
2
2
  export { createProgram };
@@ -10,12 +10,13 @@ import { AsyncLocalStorage, AsyncResource } from "node:async_hooks";
10
10
  import { stripVTControlCharacters, styleText } from "node:util";
11
11
  import * as readline from "node:readline";
12
12
  import * as os from "node:os";
13
- import * as crypto from "node:crypto";
13
+ import * as crypto$1 from "node:crypto";
14
14
  import * as fs from "node:fs/promises";
15
15
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import * as http from "node:http";
18
18
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
19
+ import * as Sentry from "@sentry/node";
19
20
  //#region \0rolldown/runtime.js
20
21
  var __create = Object.create;
21
22
  var __defProp = Object.defineProperty;
@@ -10970,6 +10971,14 @@ async function deleteMyWorkingHours(httpClient, organization_id, data, signal) {
10970
10971
  if ([204, 205].includes(response.status)) return null;
10971
10972
  return response.json();
10972
10973
  }
10974
+ //#endregion
10975
+ //#region ../cli-core/build/focus-client-B2l1BZIW.js
10976
+ /** Request header the API reads to attribute traffic to MCP or CLI. */
10977
+ const TOGGL_CLIENT_HEADER = "X-Toggl-Client";
10978
+ /** Headers that attribute a raw `fetch` to MCP or CLI (FocusClient sets these itself). */
10979
+ function togglClientHeaders(clientSurface) {
10980
+ return { [TOGGL_CLIENT_HEADER]: clientSurface };
10981
+ }
10973
10982
  const customFieldValuesEntry = /* @__PURE__ */ optional$1(/* @__PURE__ */ nullable$1(/* @__PURE__ */ pipe$2(/* @__PURE__ */ record$1(/* @__PURE__ */ string$1(), /* @__PURE__ */ nullable$1(/* @__PURE__ */ union([
10974
10983
  /* @__PURE__ */ array$1(/* @__PURE__ */ number$2()),
10975
10984
  /* @__PURE__ */ string$1(),
@@ -11147,6 +11156,7 @@ var FocusClient = class {
11147
11156
  timeOffMeHttp;
11148
11157
  sharedDataHttp;
11149
11158
  tokenProvider;
11159
+ clientSurface;
11150
11160
  userId;
11151
11161
  organizationIdNum;
11152
11162
  workspaceId;
@@ -11155,6 +11165,7 @@ var FocusClient = class {
11155
11165
  let baseUrl = config.baseUrl.replace(/\/+$/, "");
11156
11166
  if (!baseUrl.endsWith("/api")) baseUrl += "/api";
11157
11167
  this.tokenProvider = config.tokenProvider;
11168
+ this.clientSurface = config.clientSurface;
11158
11169
  this.userId = config.userId;
11159
11170
  this.workspaceId = config.workspaceId;
11160
11171
  const orgParsed = Number.parseInt(config.organizationId, 10);
@@ -11170,6 +11181,7 @@ var FocusClient = class {
11170
11181
  request.headers.set("Authorization", `Bearer ${token}`);
11171
11182
  if (!request.headers.get("Content-Type")?.startsWith("multipart/form-data")) request.headers.set("Content-Type", "application/json");
11172
11183
  request.headers.set("X-Toggl-PostHog-Data", JSON.stringify({ platform_origin: "web" }));
11184
+ request.headers.set(TOGGL_CLIENT_HEADER, this.clientSurface);
11173
11185
  }
11174
11186
  });
11175
11187
  const orgBase = accountsOrgApiBaseUrl(config.accountsApiUrl);
@@ -11181,6 +11193,7 @@ var FocusClient = class {
11181
11193
  request.headers.set("Authorization", `Bearer ${token}`);
11182
11194
  request.headers.set("Content-Type", "application/json");
11183
11195
  request.headers.set("X-Toggl-PostHog-Data", JSON.stringify({ platform_origin: "web" }));
11196
+ request.headers.set(TOGGL_CLIENT_HEADER, this.clientSurface);
11184
11197
  }
11185
11198
  });
11186
11199
  this.timeOffMeHttp = createHttpClient({
@@ -11192,6 +11205,7 @@ var FocusClient = class {
11192
11205
  request.headers.set("Content-Type", "application/json");
11193
11206
  request.headers.set("X-Toggl-Product", "focus");
11194
11207
  request.headers.set("X-Toggl-PostHog-Data", JSON.stringify({ platform_origin: "web" }));
11208
+ request.headers.set(TOGGL_CLIENT_HEADER, this.clientSurface);
11195
11209
  }
11196
11210
  });
11197
11211
  this.sharedDataHttp = createHttpClient({
@@ -11202,6 +11216,7 @@ var FocusClient = class {
11202
11216
  request.headers.set("Authorization", `Bearer ${token}`);
11203
11217
  request.headers.set("Content-Type", "application/json");
11204
11218
  request.headers.set("X-Toggl-PostHog-Data", JSON.stringify({ platform_origin: "web" }));
11219
+ request.headers.set(TOGGL_CLIENT_HEADER, this.clientSurface);
11205
11220
  }
11206
11221
  });
11207
11222
  }
@@ -12254,10 +12269,10 @@ function renderOAuthCallbackPage(kind, detail) {
12254
12269
  </div>`);
12255
12270
  }
12256
12271
  function generateOAuthPKCE() {
12257
- const verifier = crypto.randomBytes(64).toString("base64url");
12272
+ const verifier = crypto$1.randomBytes(64).toString("base64url");
12258
12273
  return {
12259
12274
  verifier,
12260
- challenge: crypto.createHash("sha256").update(verifier, "ascii").digest("base64url")
12275
+ challenge: crypto$1.createHash("sha256").update(verifier, "ascii").digest("base64url")
12261
12276
  };
12262
12277
  }
12263
12278
  function waitForOAuthCallback(port = TOGGL_OAUTH_CALLBACK_PORT, timeoutMs = TOGGL_OAUTH_LOGIN_TIMEOUT_MS) {
@@ -12300,7 +12315,7 @@ function waitForOAuthCallback(port = TOGGL_OAUTH_CALLBACK_PORT, timeoutMs = TOGG
12300
12315
  });
12301
12316
  });
12302
12317
  }
12303
- async function exchangeAuthorizationCode(accountsApiUrl, code, verifier, redirectUri = togglOAuthRedirectUri(), clientId = TOGGL_OAUTH_CLIENT_ID) {
12318
+ async function exchangeAuthorizationCode(accountsApiUrl, code, verifier, clientSurface, redirectUri = togglOAuthRedirectUri(), clientId = TOGGL_OAUTH_CLIENT_ID) {
12304
12319
  const params = new URLSearchParams({
12305
12320
  grant_type: "authorization_code",
12306
12321
  code,
@@ -12308,7 +12323,10 @@ async function exchangeAuthorizationCode(accountsApiUrl, code, verifier, redirec
12308
12323
  client_id: clientId,
12309
12324
  redirect_uri: redirectUri
12310
12325
  });
12311
- const response = await fetch(`${accountsApiUrl}/api/oauth/token?${params}`, { method: "POST" });
12326
+ const response = await fetch(`${accountsApiUrl}/api/oauth/token?${params}`, {
12327
+ method: "POST",
12328
+ headers: togglClientHeaders(clientSurface)
12329
+ });
12312
12330
  if (!response.ok) {
12313
12331
  const text = await response.text();
12314
12332
  throw new Error(`Token exchange failed (${response.status}): ${text}`);
@@ -12408,18 +12426,20 @@ var WorkspaceSelectionRequiredError = class extends Error {
12408
12426
  this.workspaces = workspaces;
12409
12427
  }
12410
12428
  };
12411
- async function fetchAccountsApiMe(accountsApiUrl, token) {
12429
+ async function fetchAccountsApiMe(accountsApiUrl, token, clientSurface) {
12412
12430
  const response = await fetch(`${accountsApiUrl}/api/me`, { headers: {
12413
12431
  Authorization: `Bearer ${token}`,
12414
- "Content-Type": "application/json"
12432
+ "Content-Type": "application/json",
12433
+ ...togglClientHeaders(clientSurface)
12415
12434
  } });
12416
12435
  if (!response.ok) throw new Error(`Failed to fetch user info (${response.status})`);
12417
12436
  return response.json();
12418
12437
  }
12419
- async function fetchAccessibleWorkspaces(accountsApiUrl, token) {
12438
+ async function fetchAccessibleWorkspaces(accountsApiUrl, token, clientSurface) {
12420
12439
  const response = await fetch(`${accountsApiUrl}/org/api/organizations/me`, { headers: {
12421
12440
  Authorization: `Bearer ${token}`,
12422
- "Content-Type": "application/json"
12441
+ "Content-Type": "application/json",
12442
+ ...togglClientHeaders(clientSurface)
12423
12443
  } });
12424
12444
  if (!response.ok) throw new Error(`Failed to fetch organizations (${response.status})`);
12425
12445
  return (await response.json()).flatMap((org) => (org.workspaces ?? []).filter((ws) => (ws.toggl_products ?? []).includes("focus")).map((ws) => {
@@ -12470,11 +12490,11 @@ async function runCliAuthFlow(options = {}) {
12470
12490
  log
12471
12491
  });
12472
12492
  log("Exchanging authorization code...");
12473
- const tokens = await exchangeAuthorizationCode(accountsApiUrl, code, verifier, redirectUri, clientId);
12493
+ const tokens = await exchangeAuthorizationCode(accountsApiUrl, code, verifier, "cli", redirectUri, clientId);
12474
12494
  log("Fetching account info...");
12475
- const user = await fetchAccountsApiMe(accountsApiUrl, tokens.access_token);
12495
+ const user = await fetchAccountsApiMe(accountsApiUrl, tokens.access_token, "cli");
12476
12496
  if (!user.user_account_id) throw new Error("Could not determine user ID from API response.");
12477
- const workspaces = await fetchAccessibleWorkspaces(accountsApiUrl, tokens.access_token);
12497
+ const workspaces = await fetchAccessibleWorkspaces(accountsApiUrl, tokens.access_token, "cli");
12478
12498
  const selectedWorkspace = await resolveAuthenticatedWorkspace(workspaces, {
12479
12499
  explicitWorkspaceId: options.workspaceId,
12480
12500
  selectWorkspace: options.selectWorkspace,
@@ -12520,13 +12540,16 @@ const PUBLIC_CLI_PACKAGE_SPEC = `${PUBLIC_CLI_PACKAGE_NAME}@latest`;
12520
12540
  function npxMcpAuthCommand() {
12521
12541
  return `npx ${PUBLIC_MCP_PACKAGE_NAME} auth`;
12522
12542
  }
12523
- async function refreshTokens(accountsApiUrl, refreshToken, clientId = TOGGL_OAUTH_CLIENT_ID) {
12543
+ async function refreshTokens(accountsApiUrl, refreshToken, clientSurface, clientId = TOGGL_OAUTH_CLIENT_ID) {
12524
12544
  const params = new URLSearchParams({
12525
12545
  grant_type: "refresh_token",
12526
12546
  refresh_token: refreshToken,
12527
12547
  client_id: clientId
12528
12548
  });
12529
- const response = await fetch(`${accountsApiUrl}/api/oauth/token?${params}`, { method: "POST" });
12549
+ const response = await fetch(`${accountsApiUrl}/api/oauth/token?${params}`, {
12550
+ method: "POST",
12551
+ headers: togglClientHeaders(clientSurface)
12552
+ });
12530
12553
  if (!response.ok) {
12531
12554
  const text = await response.text();
12532
12555
  throw new Error(`Token refresh failed (${response.status}): ${text}`);
@@ -12538,7 +12561,7 @@ async function getValidToken(scope = "cli", profile) {
12538
12561
  if (!authConfig) throw new Error(scope === "cli" ? "Not authenticated. Run `toggl auth` to sign in." : `Not authenticated. Run \`${npxMcpAuthCommand()}\` to sign in.`);
12539
12562
  const now = Date.now() / 1e3;
12540
12563
  if (authConfig.expires_at > now + 60) return authConfig.access_token;
12541
- const tokens = await refreshTokens(authConfig.accounts_api_url, authConfig.refresh_token, authConfig.oauth_client_id ?? "000000");
12564
+ const tokens = await refreshTokens(authConfig.accounts_api_url, authConfig.refresh_token, scope, authConfig.oauth_client_id ?? "000000");
12542
12565
  authConfig.access_token = tokens.access_token;
12543
12566
  authConfig.refresh_token = tokens.refresh_token;
12544
12567
  authConfig.expires_at = now + tokens.expires_in;
@@ -12578,7 +12601,7 @@ async function refreshCliWorkspaces(profile) {
12578
12601
  const token = await getValidToken("cli", profile);
12579
12602
  const dev = loadDeveloperToolsConfig();
12580
12603
  const auth = dev.profiles[profileKey];
12581
- auth.workspaces = await fetchAccessibleWorkspaces(auth.accounts_api_url, token);
12604
+ auth.workspaces = await fetchAccessibleWorkspaces(auth.accounts_api_url, token, "cli");
12582
12605
  dev.profiles[profileKey] = auth;
12583
12606
  saveDeveloperToolsConfig(dev);
12584
12607
  return listCachedCliWorkspaces(profile);
@@ -12595,6 +12618,250 @@ function switchCliWorkspace(workspaceId, profile) {
12595
12618
  saveDeveloperToolsConfig(dev);
12596
12619
  return target;
12597
12620
  }
12621
+ const CLI_GENERIC_ERROR_CATEGORY = "cli.command_error";
12622
+ const MCP_GENERIC_ERROR_CATEGORY = "mcp.tool_error";
12623
+ const ERROR_CATEGORY_SET = new Set([
12624
+ "cli.validation_error",
12625
+ "cli.api_error",
12626
+ CLI_GENERIC_ERROR_CATEGORY,
12627
+ "mcp.validation_error",
12628
+ "mcp.api_error",
12629
+ "mcp.auth_error",
12630
+ MCP_GENERIC_ERROR_CATEGORY
12631
+ ]);
12632
+ function isDeclaredErrorCategory(value) {
12633
+ return typeof value === "string" && ERROR_CATEGORY_SET.has(value);
12634
+ }
12635
+ function firstToolResultText(source) {
12636
+ if (!source || typeof source !== "object") return null;
12637
+ const content = source.content;
12638
+ if (!Array.isArray(content)) return null;
12639
+ const first = content[0];
12640
+ if (first?.type === "text" && typeof first.text === "string") return first.text;
12641
+ return null;
12642
+ }
12643
+ function errorTextForClassification(source) {
12644
+ if (source instanceof Error) return source.message;
12645
+ if (typeof source === "string") return source;
12646
+ const toolText = firstToolResultText(source);
12647
+ if (toolText !== null) return toolText;
12648
+ return "";
12649
+ }
12650
+ function classifyCli(message) {
12651
+ if (message.startsWith("Invalid input:") || message.startsWith("Invalid input ")) return "cli.validation_error";
12652
+ if (/^\d{3}:\s/.test(message)) return "cli.api_error";
12653
+ return CLI_GENERIC_ERROR_CATEGORY;
12654
+ }
12655
+ function classifyMcp(message) {
12656
+ if (message.startsWith("Invalid input:") || message.startsWith("Invalid input ")) return "mcp.validation_error";
12657
+ if (message.startsWith("API Error (")) return "mcp.api_error";
12658
+ if (message.startsWith("Authentication failed:") || message.startsWith("Not authenticated.")) return "mcp.auth_error";
12659
+ return MCP_GENERIC_ERROR_CATEGORY;
12660
+ }
12661
+ function classifyErrorCategory(client, source) {
12662
+ const message = errorTextForClassification(source);
12663
+ if (isDeclaredErrorCategory(message) && message.startsWith(`${client}.`)) return message;
12664
+ return client === "cli" ? classifyCli(message) : classifyMcp(message);
12665
+ }
12666
+ /**
12667
+ * Exception messages are the one channel `sendDefaultPii: false` does not cover,
12668
+ * and this repo's errors put user data in them: validation failures embed the
12669
+ * rejected input (valibot renders `issue.received`) and API failures embed the
12670
+ * response body. Sentry also captures uncaught exceptions and `cause` chains
12671
+ * through its own global handlers, which bypass any call-site scrubbing, so the
12672
+ * last word has to be `beforeSend`.
12673
+ *
12674
+ * Shared between the CLI and the public MCP build so the two cannot drift.
12675
+ */
12676
+ /**
12677
+ * Fixed categories are the seven declared `<surface>.<snake_case>` values, not
12678
+ * a `cli|mcp` prefix regex. A prefix or regex test would pass a caught message
12679
+ * like `cli.customer_name`. A real filename message such as
12680
+ * `cli.ts:12: unexpected token "secret"` would also begin with `cli.`.
12681
+ */
12682
+ const REDACTED_SENTRY_MESSAGE = "redacted";
12683
+ function isSafeSentryMessage(value) {
12684
+ return isDeclaredErrorCategory(value);
12685
+ }
12686
+ /**
12687
+ * Replace every exception message that is not a known category, and any top-level
12688
+ * message. Stack frames are left alone: they carry file, function and line, never
12689
+ * payloads.
12690
+ */
12691
+ function redactUnsafeSentryMessages(event) {
12692
+ if (event.message !== void 0 && !isSafeSentryMessage(event.message)) event.message = REDACTED_SENTRY_MESSAGE;
12693
+ for (const value of event.exception?.values ?? []) if (!isSafeSentryMessage(value.value)) value.value = REDACTED_SENTRY_MESSAGE;
12694
+ return event;
12695
+ }
12696
+ const DEFAULT_POSTHOG_HOST = "https://eu.i.posthog.com";
12697
+ /**
12698
+ * The shared Toggl **production** PostHog project — the same token the Track
12699
+ * webapp (`posthogApiKey.production`), the marketing site (`GATSBY_POSTHOG_ID`)
12700
+ * and the Focus mobile app (`EXPO_PUBLIC_POSTHOG_KEY`) send to. MCP/CLI events
12701
+ * therefore land beside product events for the same people, which is why the
12702
+ * `distinct_id` has to be the accounts nanoid and why `$lib` has to separate the
12703
+ * developer tools out again.
12704
+ */
12705
+ const SHARED_TOGGL_PRODUCTION_POSTHOG_TOKEN = "phc_PiFIHfmVYQVICbsScOwEv7D9wBwoNvrUFZ1Fq9WxBrp";
12706
+ /**
12707
+ * PostHog splits usage and billing by `$lib`, so it has to name the sending
12708
+ * client rather than this module: sharing the Toggl production project with the
12709
+ * webapp, marketing site and mobile app means a single library value would leave
12710
+ * MCP and CLI volume indistinguishable in that breakdown.
12711
+ */
12712
+ const POSTHOG_LIB_BY_CLIENT = {
12713
+ mcp: "MCP",
12714
+ cli: "CLI"
12715
+ };
12716
+ const TOOL_INVOCATION_EVENT = "toggl_tool_invocation";
12717
+ /** Bound collector I/O so analytics cannot hang a completed CLI/MCP exit path. */
12718
+ const CAPTURE_TIMEOUT_MS = 2e3;
12719
+ const FLUSH_TIMEOUT_MS = 2e3;
12720
+ /** Actions are catalog identifiers, so anything else is a caller bug, not data. */
12721
+ const ACTION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
12722
+ let enabled = false;
12723
+ let apiKey = null;
12724
+ let host = DEFAULT_POSTHOG_HOST;
12725
+ let sessionId = "";
12726
+ let distinctId = "";
12727
+ let client = "mcp";
12728
+ let packageVersion = "0.0.0";
12729
+ const pending = /* @__PURE__ */ new Set();
12730
+ function envFlagDisabled(raw) {
12731
+ if (raw === void 0 || raw === "") return false;
12732
+ const normalized = raw.trim().toLowerCase();
12733
+ return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
12734
+ }
12735
+ /**
12736
+ * Initialize product analytics for a public MCP/CLI process.
12737
+ * No-ops when TOGGL_PRODUCT_ANALYTICS is 0/false/off, or when the API key is "off".
12738
+ */
12739
+ function initProductAnalytics(context) {
12740
+ if (envFlagDisabled(process.env.TOGGL_PRODUCT_ANALYTICS)) {
12741
+ enabled = false;
12742
+ return false;
12743
+ }
12744
+ const key = (process.env.TOGGL_POSTHOG_API_KEY ?? SHARED_TOGGL_PRODUCTION_POSTHOG_TOKEN).trim();
12745
+ if (!key || key.toLowerCase() === "off") {
12746
+ enabled = false;
12747
+ return false;
12748
+ }
12749
+ apiKey = key;
12750
+ host = (process.env.TOGGL_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST).replace(/\/$/, "");
12751
+ sessionId = context.sessionId ?? crypto.randomUUID();
12752
+ distinctId = context.distinctId ?? `anon:${sessionId}`;
12753
+ client = context.client;
12754
+ packageVersion = context.packageVersion;
12755
+ enabled = true;
12756
+ return true;
12757
+ }
12758
+ function normalizeAction(action) {
12759
+ if (typeof action !== "string") return null;
12760
+ const trimmed = action.trim();
12761
+ return ACTION_PATTERN.test(trimmed) ? trimmed : null;
12762
+ }
12763
+ /** Strict allowlist — never accept args/results. */
12764
+ function captureToolInvocation(event) {
12765
+ if (!enabled || !apiKey) return;
12766
+ const properties = {
12767
+ tool_name: event.toolName,
12768
+ action: normalizeAction(event.action),
12769
+ outcome: event.outcome,
12770
+ success: event.outcome === "success",
12771
+ client,
12772
+ session_id: sessionId,
12773
+ package_version: packageVersion
12774
+ };
12775
+ if (event.outcome === "error") properties.error_category = classifyErrorCategory(client, event.error);
12776
+ const task = sendCapture(TOOL_INVOCATION_EVENT, properties, event.distinctId).finally(() => {
12777
+ pending.delete(task);
12778
+ });
12779
+ pending.add(task);
12780
+ }
12781
+ async function sendCapture(event, properties, eventDistinctId) {
12782
+ if (!apiKey) return;
12783
+ const controller = new AbortController();
12784
+ const timer = setTimeout(() => controller.abort(), CAPTURE_TIMEOUT_MS);
12785
+ try {
12786
+ const response = await fetch(`${host}/i/v0/e/`, {
12787
+ method: "POST",
12788
+ headers: { "Content-Type": "application/json" },
12789
+ body: JSON.stringify({
12790
+ api_key: apiKey,
12791
+ event,
12792
+ distinct_id: eventDistinctId ?? distinctId,
12793
+ properties: {
12794
+ ...properties,
12795
+ $lib: POSTHOG_LIB_BY_CLIENT[client]
12796
+ }
12797
+ }),
12798
+ signal: controller.signal
12799
+ });
12800
+ if (!response.ok) await response.text().catch(() => void 0);
12801
+ } catch {} finally {
12802
+ clearTimeout(timer);
12803
+ }
12804
+ }
12805
+ async function shutdownProductAnalytics() {
12806
+ if (pending.size === 0) {
12807
+ enabled = false;
12808
+ return;
12809
+ }
12810
+ const inflight = [...pending];
12811
+ let flushTimer;
12812
+ try {
12813
+ await Promise.race([Promise.allSettled(inflight), new Promise((resolve) => {
12814
+ flushTimer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
12815
+ })]);
12816
+ } finally {
12817
+ if (flushTimer !== void 0) clearTimeout(flushTimer);
12818
+ pending.clear();
12819
+ enabled = false;
12820
+ }
12821
+ }
12822
+ /**
12823
+ * Accounts mints the base62 nanoid as the JWT `sub`; it is the same value
12824
+ * accounts-be sends to PostHog as `distinct_id`, so it is short and URL-safe.
12825
+ */
12826
+ const NANOID_PATTERN = /^[0-9A-Za-z_-]{6,64}$/;
12827
+ /**
12828
+ * Read `sub` out of a Toggl accounts access token without verifying it.
12829
+ *
12830
+ * Verification would need the accounts JWKS; the value is only used as an
12831
+ * analytics key, and a forged token would only mislabel the forger's own events.
12832
+ */
12833
+ function decodeAccessTokenSubject(token) {
12834
+ if (!token) return null;
12835
+ const payloadSegment = token.split(".")[1];
12836
+ if (!payloadSegment) return null;
12837
+ try {
12838
+ const sub = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8")).sub;
12839
+ if (typeof sub !== "string" || !NANOID_PATTERN.test(sub)) return null;
12840
+ return sub;
12841
+ } catch {
12842
+ return null;
12843
+ }
12844
+ }
12845
+ /**
12846
+ * Stable PostHog identity for a signed-in MCP/CLI process.
12847
+ *
12848
+ * The nanoid comes from the persisted profile, so the same person keeps one
12849
+ * `distinct_id` across process restarts and across MCP and CLI — a per-process
12850
+ * UUID would make every invocation look like a new user and every funnel
12851
+ * one-step-deep.
12852
+ *
12853
+ * The profile's numeric `user_id` is `user_account_id`, which is deliberately
12854
+ * *not* used here: accounts-be, toggl_api and the Track webapp all identify a
12855
+ * person in PostHog by the nanoid, so a bigint key would orphan these events
12856
+ * from every other identity we already have for the same user.
12857
+ */
12858
+ function resolveProductAnalyticsDistinctId(scope) {
12859
+ try {
12860
+ return decodeAccessTokenSubject(loadProfile(scope)?.access_token);
12861
+ } catch {
12862
+ return null;
12863
+ }
12864
+ }
12598
12865
  //#endregion
12599
12866
  //#region src/cli-package-manifest.ts
12600
12867
  const pkgJsonPath = path$1.join(path$1.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
@@ -24752,6 +25019,86 @@ async function runValidatedOperation(operation, options, deps) {
24752
25019
  deps.output(response.data);
24753
25020
  }
24754
25021
  //#endregion
25022
+ //#region src/telemetry.ts
25023
+ function envEnabled(raw) {
25024
+ if (raw === void 0) return false;
25025
+ const normalized = raw.trim().toLowerCase();
25026
+ return normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes";
25027
+ }
25028
+ let sentryEnabled = false;
25029
+ let productAnalyticsEnabled = false;
25030
+ let lastCommandError;
25031
+ function initCliTelemetry(packageVersion) {
25032
+ productAnalyticsEnabled = initProductAnalytics({
25033
+ client: "cli",
25034
+ packageVersion,
25035
+ distinctId: resolveProductAnalyticsDistinctId("cli")
25036
+ });
25037
+ if (!envEnabled(process.env.TOGGL_SENTRY)) {
25038
+ sentryEnabled = false;
25039
+ return;
25040
+ }
25041
+ const dsn = process.env.SENTRY_DSN;
25042
+ if (!dsn || dsn === "off") {
25043
+ console.error("[Telemetry] TOGGL_SENTRY is set but SENTRY_DSN is missing; Sentry left disabled");
25044
+ sentryEnabled = false;
25045
+ return;
25046
+ }
25047
+ Sentry.init({
25048
+ dsn,
25049
+ environment: process.env.NODE_ENV ?? "production",
25050
+ tracesSampleRate: .1,
25051
+ sendDefaultPii: false,
25052
+ enableLogs: false,
25053
+ beforeSend: (event) => redactUnsafeSentryMessages(event)
25054
+ });
25055
+ Sentry.setTag("cli.session.id", crypto.randomUUID());
25056
+ sentryEnabled = true;
25057
+ }
25058
+ /**
25059
+ * `action` mirrors the MCP entity-tool action so one PostHog query can compare
25060
+ * `toggl reports list` with the `reports` tool's `list` action.
25061
+ */
25062
+ function recordCliCommand(commandName, action, success) {
25063
+ if (!productAnalyticsEnabled) return;
25064
+ const error = success ? void 0 : lastCommandError;
25065
+ lastCommandError = void 0;
25066
+ captureToolInvocation({
25067
+ toolName: commandName,
25068
+ action,
25069
+ outcome: success ? "success" : "error",
25070
+ error
25071
+ });
25072
+ }
25073
+ function classifyCliSentryError(error) {
25074
+ return classifyErrorCategory("cli", error);
25075
+ }
25076
+ function toSentrySafeCliError(error) {
25077
+ const category = classifyCliSentryError(error);
25078
+ const safe = new Error(category);
25079
+ safe.name = error instanceof Error ? error.name : "Error";
25080
+ if (error instanceof Error && typeof error.stack === "string") safe.stack = error.message ? error.stack.replaceAll(error.message, category) : error.stack;
25081
+ return safe;
25082
+ }
25083
+ function captureCliException(error) {
25084
+ lastCommandError = error;
25085
+ if (!sentryEnabled) return;
25086
+ const category = classifyCliSentryError(error);
25087
+ Sentry.withScope((scope) => {
25088
+ scope.setTag("cli.error_category", category);
25089
+ Sentry.captureException(toSentrySafeCliError(error));
25090
+ });
25091
+ }
25092
+ async function shutdownCliTelemetry() {
25093
+ const tasks = [];
25094
+ if (productAnalyticsEnabled) tasks.push(shutdownProductAnalytics());
25095
+ if (sentryEnabled) tasks.push(Sentry.close(2e3));
25096
+ if (tasks.length > 0) await Promise.allSettled(tasks);
25097
+ sentryEnabled = false;
25098
+ productAnalyticsEnabled = false;
25099
+ lastCommandError = void 0;
25100
+ }
25101
+ //#endregion
24755
25102
  //#region src/output.ts
24756
25103
  function isRecord(value) {
24757
25104
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -25062,7 +25409,8 @@ function getClient(options) {
25062
25409
  tokenProvider: () => getValidToken("cli", options.profile),
25063
25410
  userId: config.user_id,
25064
25411
  organizationId: String(config.organization_id),
25065
- workspaceId: options.workspaceId ?? String(config.workspace_id)
25412
+ workspaceId: options.workspaceId ?? String(config.workspace_id),
25413
+ clientSurface: "cli"
25066
25414
  });
25067
25415
  }
25068
25416
  async function runOperation(operation, globalOptions, options, rawInput) {
@@ -25099,6 +25447,19 @@ function groupCommand(program, group) {
25099
25447
  for (const part of group) command = command.commands.find((candidate) => candidate.name() === part) ?? command.command(part).description(`${part} operations`);
25100
25448
  return command;
25101
25449
  }
25450
+ function cliCommandPath(command) {
25451
+ const parts = [];
25452
+ let current = command;
25453
+ while (current?.parent) {
25454
+ parts.unshift(current.name());
25455
+ current = current.parent;
25456
+ }
25457
+ return parts.filter(Boolean).join(" ") || command.name();
25458
+ }
25459
+ function recordActionAnalytics(actionCommand) {
25460
+ const failed = typeof process.exitCode === "number" && process.exitCode !== 0;
25461
+ recordCliCommand(cliCommandPath(actionCommand), actionCommand.name() || null, !failed);
25462
+ }
25102
25463
  function createProgram(programOptions = {}) {
25103
25464
  const promptAdapter = programOptions.promptAdapter ?? inquirerPromptAdapter;
25104
25465
  const isTty = programOptions.isTty ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY));
@@ -25150,6 +25511,7 @@ function createProgram(programOptions = {}) {
25150
25511
  const globalOptions = program.opts();
25151
25512
  output(opts.refresh ? await refreshCliWorkspaces(globalOptions.profile) : listCachedCliWorkspaces(globalOptions.profile), globalOptions);
25152
25513
  } catch (error) {
25514
+ captureCliException(error);
25153
25515
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25154
25516
  process.exitCode = 1;
25155
25517
  }
@@ -25169,6 +25531,7 @@ function createProgram(programOptions = {}) {
25169
25531
  organization_name: target.organization_name
25170
25532
  }, globalOptions);
25171
25533
  } catch (error) {
25534
+ captureCliException(error);
25172
25535
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25173
25536
  process.exitCode = 1;
25174
25537
  }
@@ -25178,6 +25541,7 @@ function createProgram(programOptions = {}) {
25178
25541
  try {
25179
25542
  output(listCliProfiles(), program.opts());
25180
25543
  } catch (error) {
25544
+ captureCliException(error);
25181
25545
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25182
25546
  process.exitCode = 1;
25183
25547
  }
@@ -25186,6 +25550,7 @@ function createProgram(programOptions = {}) {
25186
25550
  try {
25187
25551
  output(getCurrentCliProfile(), program.opts());
25188
25552
  } catch (error) {
25553
+ captureCliException(error);
25189
25554
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25190
25555
  process.exitCode = 1;
25191
25556
  }
@@ -25199,6 +25564,7 @@ function createProgram(programOptions = {}) {
25199
25564
  exampleWhenNotInteractive: "Example: toggl profile switch production-12345"
25200
25565
  })), globalOptions);
25201
25566
  } catch (error) {
25567
+ captureCliException(error);
25202
25568
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25203
25569
  process.exitCode = 1;
25204
25570
  }
@@ -25212,6 +25578,7 @@ function createProgram(programOptions = {}) {
25212
25578
  exampleWhenNotInteractive: "Example: toggl profile remove production-12345"
25213
25579
  })), globalOptions);
25214
25580
  } catch (error) {
25581
+ captureCliException(error);
25215
25582
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25216
25583
  process.exitCode = 1;
25217
25584
  }
@@ -25224,6 +25591,7 @@ function createProgram(programOptions = {}) {
25224
25591
  json: globalOptions.json
25225
25592
  });
25226
25593
  } catch (error) {
25594
+ captureCliException(error);
25227
25595
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25228
25596
  process.exitCode = 1;
25229
25597
  }
@@ -25236,6 +25604,7 @@ function createProgram(programOptions = {}) {
25236
25604
  try {
25237
25605
  await runSkillSetup(opts);
25238
25606
  } catch (error) {
25607
+ captureCliException(error);
25239
25608
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25240
25609
  process.exitCode = 1;
25241
25610
  }
@@ -25275,6 +25644,7 @@ Common commands:
25275
25644
  const entityContext = willPrompt && !cmdOptions.dryRun ? { client: getClient(globalOptions) } : void 0;
25276
25645
  await runOperation(operation, globalOptions, cmdOptions, willPrompt ? await promptForOperationInput(operation, rawInput, promptAdapter, entityContext) : rawInput);
25277
25646
  } catch (error) {
25647
+ captureCliException(error);
25278
25648
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25279
25649
  process.exitCode = 1;
25280
25650
  }
@@ -25286,6 +25656,9 @@ Common commands:
25286
25656
  command.addHelpText("after", `\nExamples:\n${exampleLines}`);
25287
25657
  }
25288
25658
  }
25659
+ program.hook("postAction", (_thisCommand, actionCommand) => {
25660
+ recordActionAnalytics(actionCommand);
25661
+ });
25289
25662
  if (programOptions.enableUpdateNotification) program.hook("postAction", (_thisCommand, actionCommand) => {
25290
25663
  maybeNotifyUpdateAvailable({
25291
25664
  exitCode: typeof process.exitCode === "number" ? process.exitCode : 0,
@@ -25298,6 +25671,6 @@ Common commands:
25298
25671
  return program;
25299
25672
  }
25300
25673
  //#endregion
25301
- export { CommanderError as n, createProgram as t };
25674
+ export { CommanderError as a, shutdownCliTelemetry as i, captureCliException as n, initCliTelemetry as r, createProgram as t };
25302
25675
 
25303
- //# sourceMappingURL=program-COtKnkok.js.map
25676
+ //# sourceMappingURL=program-CH5epPVt.js.map
package/build/program.js CHANGED
@@ -1,2 +1,2 @@
1
- import { t as createProgram } from "./program-COtKnkok.js";
1
+ import { t as createProgram } from "./program-CH5epPVt.js";
2
2
  export { createProgram };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@togglhq/cli",
3
- "version": "1.7.2",
3
+ "version": "1.8.1",
4
4
  "description": "Toggl CLI for Toggl 2.0 API operations.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -50,6 +50,7 @@
50
50
  "node": ">=20"
51
51
  },
52
52
  "dependencies": {
53
+ "@sentry/node": "10.47.0",
53
54
  "skills": "1.5.2"
54
55
  },
55
56
  "devDependencies": {