@togglhq/cli 1.7.2 → 1.8.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
@@ -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-B6EuV2vO.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-B6EuV2vO.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,204 @@ function switchCliWorkspace(workspaceId, profile) {
12595
12618
  saveDeveloperToolsConfig(dev);
12596
12619
  return target;
12597
12620
  }
12621
+ /**
12622
+ * Exception messages are the one channel `sendDefaultPii: false` does not cover,
12623
+ * and this repo's errors put user data in them: validation failures embed the
12624
+ * rejected input (valibot renders `issue.received`) and API failures embed the
12625
+ * response body. Sentry also captures uncaught exceptions and `cause` chains
12626
+ * through its own global handlers, which bypass any call-site scrubbing, so the
12627
+ * last word has to be `beforeSend`.
12628
+ *
12629
+ * Shared between the CLI and the public MCP build so the two cannot drift.
12630
+ */
12631
+ /**
12632
+ * Fixed categories are `<surface>.<snake_case>` — anchored at both ends, because
12633
+ * a prefix test would pass anything that merely *starts* that way. A real message
12634
+ * like `cli.ts:12: unexpected token "secret"` begins with `cli.` and would
12635
+ * otherwise sail through unredacted.
12636
+ */
12637
+ const SAFE_CATEGORY_PATTERN = /^(?:cli|mcp)\.[a-z][a-z0-9_]*$/;
12638
+ const REDACTED_SENTRY_MESSAGE = "redacted";
12639
+ function isSafeSentryMessage(value) {
12640
+ return typeof value === "string" && SAFE_CATEGORY_PATTERN.test(value);
12641
+ }
12642
+ /**
12643
+ * Replace every exception message that is not a known category, and any top-level
12644
+ * message. Stack frames are left alone: they carry file, function and line, never
12645
+ * payloads.
12646
+ */
12647
+ function redactUnsafeSentryMessages(event) {
12648
+ if (event.message !== void 0 && !isSafeSentryMessage(event.message)) event.message = REDACTED_SENTRY_MESSAGE;
12649
+ for (const value of event.exception?.values ?? []) if (!isSafeSentryMessage(value.value)) value.value = REDACTED_SENTRY_MESSAGE;
12650
+ return event;
12651
+ }
12652
+ const DEFAULT_POSTHOG_HOST = "https://eu.i.posthog.com";
12653
+ /**
12654
+ * The shared Toggl **production** PostHog project — the same token the Track
12655
+ * webapp (`posthogApiKey.production`), the marketing site (`GATSBY_POSTHOG_ID`)
12656
+ * and the Focus mobile app (`EXPO_PUBLIC_POSTHOG_KEY`) send to. MCP/CLI events
12657
+ * therefore land beside product events for the same people, which is why the
12658
+ * `distinct_id` has to be the accounts nanoid and why `$lib` has to separate the
12659
+ * developer tools out again.
12660
+ */
12661
+ const SHARED_TOGGL_PRODUCTION_POSTHOG_TOKEN = "phc_PiFIHfmVYQVICbsScOwEv7D9wBwoNvrUFZ1Fq9WxBrp";
12662
+ /**
12663
+ * PostHog splits usage and billing by `$lib`, so it has to name the sending
12664
+ * client rather than this module: sharing the Toggl production project with the
12665
+ * webapp, marketing site and mobile app means a single library value would leave
12666
+ * MCP and CLI volume indistinguishable in that breakdown.
12667
+ */
12668
+ const POSTHOG_LIB_BY_CLIENT = {
12669
+ mcp: "MCP",
12670
+ cli: "CLI"
12671
+ };
12672
+ const TOOL_INVOCATION_EVENT = "toggl_tool_invocation";
12673
+ /** Bound collector I/O so analytics cannot hang a completed CLI/MCP exit path. */
12674
+ const CAPTURE_TIMEOUT_MS = 2e3;
12675
+ const FLUSH_TIMEOUT_MS = 2e3;
12676
+ /** Actions are catalog identifiers, so anything else is a caller bug, not data. */
12677
+ const ACTION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
12678
+ let enabled = false;
12679
+ let apiKey = null;
12680
+ let host = DEFAULT_POSTHOG_HOST;
12681
+ let sessionId = "";
12682
+ let distinctId = "";
12683
+ let client = "mcp";
12684
+ let packageVersion = "0.0.0";
12685
+ const pending = /* @__PURE__ */ new Set();
12686
+ function envFlagDisabled(raw) {
12687
+ if (raw === void 0 || raw === "") return false;
12688
+ const normalized = raw.trim().toLowerCase();
12689
+ return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
12690
+ }
12691
+ /**
12692
+ * Initialize product analytics for a public MCP/CLI process.
12693
+ * No-ops when TOGGL_PRODUCT_ANALYTICS is 0/false/off, or when the API key is "off".
12694
+ */
12695
+ function initProductAnalytics(context) {
12696
+ if (envFlagDisabled(process.env.TOGGL_PRODUCT_ANALYTICS)) {
12697
+ enabled = false;
12698
+ return false;
12699
+ }
12700
+ const key = (process.env.TOGGL_POSTHOG_API_KEY ?? SHARED_TOGGL_PRODUCTION_POSTHOG_TOKEN).trim();
12701
+ if (!key || key.toLowerCase() === "off") {
12702
+ enabled = false;
12703
+ return false;
12704
+ }
12705
+ apiKey = key;
12706
+ host = (process.env.TOGGL_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST).replace(/\/$/, "");
12707
+ sessionId = context.sessionId ?? crypto.randomUUID();
12708
+ distinctId = context.distinctId ?? `anon:${sessionId}`;
12709
+ client = context.client;
12710
+ packageVersion = context.packageVersion;
12711
+ enabled = true;
12712
+ return true;
12713
+ }
12714
+ function normalizeAction(action) {
12715
+ if (typeof action !== "string") return null;
12716
+ const trimmed = action.trim();
12717
+ return ACTION_PATTERN.test(trimmed) ? trimmed : null;
12718
+ }
12719
+ /** Strict allowlist — never accept args/results. */
12720
+ function captureToolInvocation(event) {
12721
+ if (!enabled || !apiKey) return;
12722
+ const task = sendCapture(TOOL_INVOCATION_EVENT, {
12723
+ tool_name: event.toolName,
12724
+ action: normalizeAction(event.action),
12725
+ outcome: event.outcome,
12726
+ success: event.outcome === "success",
12727
+ client,
12728
+ session_id: sessionId,
12729
+ package_version: packageVersion
12730
+ }, event.distinctId).finally(() => {
12731
+ pending.delete(task);
12732
+ });
12733
+ pending.add(task);
12734
+ }
12735
+ async function sendCapture(event, properties, eventDistinctId) {
12736
+ if (!apiKey) return;
12737
+ const controller = new AbortController();
12738
+ const timer = setTimeout(() => controller.abort(), CAPTURE_TIMEOUT_MS);
12739
+ try {
12740
+ const response = await fetch(`${host}/i/v0/e/`, {
12741
+ method: "POST",
12742
+ headers: { "Content-Type": "application/json" },
12743
+ body: JSON.stringify({
12744
+ api_key: apiKey,
12745
+ event,
12746
+ distinct_id: eventDistinctId ?? distinctId,
12747
+ properties: {
12748
+ ...properties,
12749
+ $lib: POSTHOG_LIB_BY_CLIENT[client]
12750
+ }
12751
+ }),
12752
+ signal: controller.signal
12753
+ });
12754
+ if (!response.ok) await response.text().catch(() => void 0);
12755
+ } catch {} finally {
12756
+ clearTimeout(timer);
12757
+ }
12758
+ }
12759
+ async function shutdownProductAnalytics() {
12760
+ if (pending.size === 0) {
12761
+ enabled = false;
12762
+ return;
12763
+ }
12764
+ const inflight = [...pending];
12765
+ let flushTimer;
12766
+ try {
12767
+ await Promise.race([Promise.allSettled(inflight), new Promise((resolve) => {
12768
+ flushTimer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
12769
+ })]);
12770
+ } finally {
12771
+ if (flushTimer !== void 0) clearTimeout(flushTimer);
12772
+ pending.clear();
12773
+ enabled = false;
12774
+ }
12775
+ }
12776
+ /**
12777
+ * Accounts mints the base62 nanoid as the JWT `sub`; it is the same value
12778
+ * accounts-be sends to PostHog as `distinct_id`, so it is short and URL-safe.
12779
+ */
12780
+ const NANOID_PATTERN = /^[0-9A-Za-z_-]{6,64}$/;
12781
+ /**
12782
+ * Read `sub` out of a Toggl accounts access token without verifying it.
12783
+ *
12784
+ * Verification would need the accounts JWKS; the value is only used as an
12785
+ * analytics key, and a forged token would only mislabel the forger's own events.
12786
+ */
12787
+ function decodeAccessTokenSubject(token) {
12788
+ if (!token) return null;
12789
+ const payloadSegment = token.split(".")[1];
12790
+ if (!payloadSegment) return null;
12791
+ try {
12792
+ const sub = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8")).sub;
12793
+ if (typeof sub !== "string" || !NANOID_PATTERN.test(sub)) return null;
12794
+ return sub;
12795
+ } catch {
12796
+ return null;
12797
+ }
12798
+ }
12799
+ /**
12800
+ * Stable PostHog identity for a signed-in MCP/CLI process.
12801
+ *
12802
+ * The nanoid comes from the persisted profile, so the same person keeps one
12803
+ * `distinct_id` across process restarts and across MCP and CLI — a per-process
12804
+ * UUID would make every invocation look like a new user and every funnel
12805
+ * one-step-deep.
12806
+ *
12807
+ * The profile's numeric `user_id` is `user_account_id`, which is deliberately
12808
+ * *not* used here: accounts-be, toggl_api and the Track webapp all identify a
12809
+ * person in PostHog by the nanoid, so a bigint key would orphan these events
12810
+ * from every other identity we already have for the same user.
12811
+ */
12812
+ function resolveProductAnalyticsDistinctId(scope) {
12813
+ try {
12814
+ return decodeAccessTokenSubject(loadProfile(scope)?.access_token);
12815
+ } catch {
12816
+ return null;
12817
+ }
12818
+ }
12598
12819
  //#endregion
12599
12820
  //#region src/cli-package-manifest.ts
12600
12821
  const pkgJsonPath = path$1.join(path$1.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
@@ -24752,6 +24973,83 @@ async function runValidatedOperation(operation, options, deps) {
24752
24973
  deps.output(response.data);
24753
24974
  }
24754
24975
  //#endregion
24976
+ //#region src/telemetry.ts
24977
+ function envEnabled(raw) {
24978
+ if (raw === void 0) return false;
24979
+ const normalized = raw.trim().toLowerCase();
24980
+ return normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes";
24981
+ }
24982
+ let sentryEnabled = false;
24983
+ let productAnalyticsEnabled = false;
24984
+ function initCliTelemetry(packageVersion) {
24985
+ productAnalyticsEnabled = initProductAnalytics({
24986
+ client: "cli",
24987
+ packageVersion,
24988
+ distinctId: resolveProductAnalyticsDistinctId("cli")
24989
+ });
24990
+ if (!envEnabled(process.env.TOGGL_SENTRY)) {
24991
+ sentryEnabled = false;
24992
+ return;
24993
+ }
24994
+ const dsn = process.env.SENTRY_DSN;
24995
+ if (!dsn || dsn === "off") {
24996
+ console.error("[Telemetry] TOGGL_SENTRY is set but SENTRY_DSN is missing; Sentry left disabled");
24997
+ sentryEnabled = false;
24998
+ return;
24999
+ }
25000
+ Sentry.init({
25001
+ dsn,
25002
+ environment: process.env.NODE_ENV ?? "production",
25003
+ tracesSampleRate: .1,
25004
+ sendDefaultPii: false,
25005
+ enableLogs: false,
25006
+ beforeSend: (event) => redactUnsafeSentryMessages(event)
25007
+ });
25008
+ Sentry.setTag("cli.session.id", crypto.randomUUID());
25009
+ sentryEnabled = true;
25010
+ }
25011
+ /**
25012
+ * `action` mirrors the MCP entity-tool action so one PostHog query can compare
25013
+ * `toggl reports list` with the `reports` tool's `list` action.
25014
+ */
25015
+ function recordCliCommand(commandName, action, success) {
25016
+ if (!productAnalyticsEnabled) return;
25017
+ captureToolInvocation({
25018
+ toolName: commandName,
25019
+ action,
25020
+ outcome: success ? "success" : "error"
25021
+ });
25022
+ }
25023
+ function classifyCliSentryError(error) {
25024
+ const message = error instanceof Error ? error.message : String(error);
25025
+ if (message.startsWith("Invalid input:") || message.startsWith("Invalid input ")) return "cli.validation_error";
25026
+ if (/^\d{3}:\s/.test(message)) return "cli.api_error";
25027
+ return "cli.command_error";
25028
+ }
25029
+ function toSentrySafeCliError(error) {
25030
+ const category = classifyCliSentryError(error);
25031
+ const safe = new Error(category);
25032
+ safe.name = error instanceof Error ? error.name : "Error";
25033
+ if (error instanceof Error && typeof error.stack === "string") safe.stack = error.message ? error.stack.replaceAll(error.message, category) : error.stack;
25034
+ return safe;
25035
+ }
25036
+ function captureCliException(error) {
25037
+ if (!sentryEnabled) return;
25038
+ const category = classifyCliSentryError(error);
25039
+ Sentry.withScope((scope) => {
25040
+ scope.setTag("cli.error_category", category);
25041
+ Sentry.captureException(toSentrySafeCliError(error));
25042
+ });
25043
+ }
25044
+ async function shutdownCliTelemetry() {
25045
+ const tasks = [];
25046
+ if (productAnalyticsEnabled) tasks.push(shutdownProductAnalytics());
25047
+ if (sentryEnabled) tasks.push(Sentry.close(2e3));
25048
+ if (tasks.length > 0) await Promise.allSettled(tasks);
25049
+ sentryEnabled = false;
25050
+ productAnalyticsEnabled = false;
25051
+ }
25052
+ //#endregion
24755
25053
  //#region src/output.ts
24756
25054
  function isRecord(value) {
24757
25055
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -25062,7 +25360,8 @@ function getClient(options) {
25062
25360
  tokenProvider: () => getValidToken("cli", options.profile),
25063
25361
  userId: config.user_id,
25064
25362
  organizationId: String(config.organization_id),
25065
- workspaceId: options.workspaceId ?? String(config.workspace_id)
25363
+ workspaceId: options.workspaceId ?? String(config.workspace_id),
25364
+ clientSurface: "cli"
25066
25365
  });
25067
25366
  }
25068
25367
  async function runOperation(operation, globalOptions, options, rawInput) {
@@ -25099,6 +25398,19 @@ function groupCommand(program, group) {
25099
25398
  for (const part of group) command = command.commands.find((candidate) => candidate.name() === part) ?? command.command(part).description(`${part} operations`);
25100
25399
  return command;
25101
25400
  }
25401
+ function cliCommandPath(command) {
25402
+ const parts = [];
25403
+ let current = command;
25404
+ while (current?.parent) {
25405
+ parts.unshift(current.name());
25406
+ current = current.parent;
25407
+ }
25408
+ return parts.filter(Boolean).join(" ") || command.name();
25409
+ }
25410
+ function recordActionAnalytics(actionCommand) {
25411
+ const failed = typeof process.exitCode === "number" && process.exitCode !== 0;
25412
+ recordCliCommand(cliCommandPath(actionCommand), actionCommand.name() || null, !failed);
25413
+ }
25102
25414
  function createProgram(programOptions = {}) {
25103
25415
  const promptAdapter = programOptions.promptAdapter ?? inquirerPromptAdapter;
25104
25416
  const isTty = programOptions.isTty ?? (() => Boolean(process.stdin.isTTY && process.stdout.isTTY));
@@ -25150,6 +25462,7 @@ function createProgram(programOptions = {}) {
25150
25462
  const globalOptions = program.opts();
25151
25463
  output(opts.refresh ? await refreshCliWorkspaces(globalOptions.profile) : listCachedCliWorkspaces(globalOptions.profile), globalOptions);
25152
25464
  } catch (error) {
25465
+ captureCliException(error);
25153
25466
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25154
25467
  process.exitCode = 1;
25155
25468
  }
@@ -25169,6 +25482,7 @@ function createProgram(programOptions = {}) {
25169
25482
  organization_name: target.organization_name
25170
25483
  }, globalOptions);
25171
25484
  } catch (error) {
25485
+ captureCliException(error);
25172
25486
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25173
25487
  process.exitCode = 1;
25174
25488
  }
@@ -25178,6 +25492,7 @@ function createProgram(programOptions = {}) {
25178
25492
  try {
25179
25493
  output(listCliProfiles(), program.opts());
25180
25494
  } catch (error) {
25495
+ captureCliException(error);
25181
25496
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25182
25497
  process.exitCode = 1;
25183
25498
  }
@@ -25186,6 +25501,7 @@ function createProgram(programOptions = {}) {
25186
25501
  try {
25187
25502
  output(getCurrentCliProfile(), program.opts());
25188
25503
  } catch (error) {
25504
+ captureCliException(error);
25189
25505
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25190
25506
  process.exitCode = 1;
25191
25507
  }
@@ -25199,6 +25515,7 @@ function createProgram(programOptions = {}) {
25199
25515
  exampleWhenNotInteractive: "Example: toggl profile switch production-12345"
25200
25516
  })), globalOptions);
25201
25517
  } catch (error) {
25518
+ captureCliException(error);
25202
25519
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25203
25520
  process.exitCode = 1;
25204
25521
  }
@@ -25212,6 +25529,7 @@ function createProgram(programOptions = {}) {
25212
25529
  exampleWhenNotInteractive: "Example: toggl profile remove production-12345"
25213
25530
  })), globalOptions);
25214
25531
  } catch (error) {
25532
+ captureCliException(error);
25215
25533
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25216
25534
  process.exitCode = 1;
25217
25535
  }
@@ -25224,6 +25542,7 @@ function createProgram(programOptions = {}) {
25224
25542
  json: globalOptions.json
25225
25543
  });
25226
25544
  } catch (error) {
25545
+ captureCliException(error);
25227
25546
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25228
25547
  process.exitCode = 1;
25229
25548
  }
@@ -25236,6 +25555,7 @@ function createProgram(programOptions = {}) {
25236
25555
  try {
25237
25556
  await runSkillSetup(opts);
25238
25557
  } catch (error) {
25558
+ captureCliException(error);
25239
25559
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25240
25560
  process.exitCode = 1;
25241
25561
  }
@@ -25275,6 +25595,7 @@ Common commands:
25275
25595
  const entityContext = willPrompt && !cmdOptions.dryRun ? { client: getClient(globalOptions) } : void 0;
25276
25596
  await runOperation(operation, globalOptions, cmdOptions, willPrompt ? await promptForOperationInput(operation, rawInput, promptAdapter, entityContext) : rawInput);
25277
25597
  } catch (error) {
25598
+ captureCliException(error);
25278
25599
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
25279
25600
  process.exitCode = 1;
25280
25601
  }
@@ -25286,6 +25607,9 @@ Common commands:
25286
25607
  command.addHelpText("after", `\nExamples:\n${exampleLines}`);
25287
25608
  }
25288
25609
  }
25610
+ program.hook("postAction", (_thisCommand, actionCommand) => {
25611
+ recordActionAnalytics(actionCommand);
25612
+ });
25289
25613
  if (programOptions.enableUpdateNotification) program.hook("postAction", (_thisCommand, actionCommand) => {
25290
25614
  maybeNotifyUpdateAvailable({
25291
25615
  exitCode: typeof process.exitCode === "number" ? process.exitCode : 0,
@@ -25298,6 +25622,6 @@ Common commands:
25298
25622
  return program;
25299
25623
  }
25300
25624
  //#endregion
25301
- export { CommanderError as n, createProgram as t };
25625
+ export { CommanderError as a, shutdownCliTelemetry as i, captureCliException as n, initCliTelemetry as r, createProgram as t };
25302
25626
 
25303
- //# sourceMappingURL=program-COtKnkok.js.map
25627
+ //# sourceMappingURL=program-B6EuV2vO.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-B6EuV2vO.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.0",
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": {