@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 +12 -0
- package/build/cli.js +9 -1
- package/build/index.js +1 -1
- package/build/{program-COtKnkok.js → program-B6EuV2vO.js} +343 -19
- package/build/program.js +1 -1
- package/package.json +2 -1
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 {
|
|
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-
|
|
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}`, {
|
|
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}`, {
|
|
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-
|
|
25627
|
+
//# sourceMappingURL=program-B6EuV2vO.js.map
|
package/build/program.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as createProgram } from "./program-
|
|
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.
|
|
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": {
|