From 8b991d6dffecffbd7ed3a4cb32b9ec5bb48e6493 Mon Sep 17 00:00:00 2001 From: Lyu Date: Sat, 25 Jul 2026 20:47:13 -0700 Subject: [PATCH] feat: telemetry opt-out command; remove hidden records commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry opt-out (usage analytics kill switch): - New `insforge telemetry status|enable|disable` command. `disable` persists telemetry_disabled in ~/.insforge/config.json; status reports the effective state and what decides it (env var, config, default). - isTelemetryDisabled() in src/lib/analytics.ts honors, in order: DO_NOT_TRACK (consoledonottrack.com convention), INSFORGE_TELEMETRY_DISABLED, then the persisted config flag. It gates the PostHog client AND the legacy reportCliUsage path, so one switch covers every usage-tracking emitter. A corrupt config file reads as enabled — telemetry handling must never break the CLI. - The telemetry command itself deliberately emits no analytics events. - Documented in README (command + env vars) and DEVELOPMENT.md §2, which now requires new telemetry emitters to check the kill switch. Records removal: - Delete the hidden `records` command group (list/create/update/delete). It was never supported for direct use; table data goes through `db query`. README note updated. Co-Authored-By: Claude Fable 5 --- DEVELOPMENT.md | 7 +++ README.md | 21 ++++++- src/commands/records/create.ts | 53 ----------------- src/commands/records/delete.ts | 46 -------------- src/commands/records/list.ts | 63 -------------------- src/commands/records/update.ts | 60 ------------------- src/commands/telemetry.test.ts | 91 ++++++++++++++++++++++++++++ src/commands/telemetry.ts | 106 +++++++++++++++++++++++++++++++++ src/index.ts | 13 +--- src/lib/analytics.test.ts | 84 ++++++++++++++++++++++++++ src/lib/analytics.ts | 29 ++++++++- src/lib/skills.ts | 4 ++ src/types.ts | 2 + 13 files changed, 341 insertions(+), 238 deletions(-) delete mode 100644 src/commands/records/create.ts delete mode 100644 src/commands/records/delete.ts delete mode 100644 src/commands/records/list.ts delete mode 100644 src/commands/records/update.ts create mode 100644 src/commands/telemetry.test.ts create mode 100644 src/commands/telemetry.ts create mode 100644 src/lib/analytics.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 26923d60..4a6d6aef 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -54,6 +54,13 @@ truth for product telemetry — do not add alternative analytics systems. - **Build-time key.** `POSTHOG_API_KEY` is injected at build time by `tsup.config.ts` via `define`. Local builds without the env var become a no-op automatically — the CLI itself stays functional. +- **Opt-out.** Users can disable usage analytics persistently with + `insforge telemetry disable` (stored as `telemetry_disabled` in + `~/.insforge/config.json`) or per run via the `DO_NOT_TRACK` / + `INSFORGE_TELEMETRY_DISABLED` env vars. The kill switch is + `isTelemetryDisabled()` in `src/lib/analytics.ts`; it gates the PostHog + client and the legacy `reportCliUsage` path. Any new telemetry emitter + MUST check it, or the opt-out silently stops being true. **Do not** use `reportCliUsage` for new commands — that legacy OSS telemetry path has been removed from `create`, `link`, and `docs`. PostHog is the path diff --git a/README.md b/README.md index 4f878f67..e70c710a 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,10 @@ when you want to link a directory directly to a known project. ## Commands -> The `orgs`, `projects`, and `records` command groups are registered but hidden +> The `orgs` and `projects` command groups are registered but hidden > (`hidden: true` in `src/index.ts`) and are intentionally excluded from this -> reference. Use `npx @insforge/cli list` instead of `orgs`/`projects`; `records` -> is internal and not supported for direct use. +> reference. Use `npx @insforge/cli list` instead of `orgs`/`projects`. For +> table data, use `db query` — there is no separate records API. ### Top-Level @@ -100,6 +100,19 @@ npx @insforge/cli list npx @insforge/cli list --json ``` +#### `npx @insforge/cli telemetry ` + +Manage anonymous usage analytics (command usage metadata only — never SQL, +file contents, credentials, or free text). `disable` persists the opt-out in +`~/.insforge/config.json`; the `DO_NOT_TRACK` and `INSFORGE_TELEMETRY_DISABLED` +environment variables are also honored for per-run or CI opt-out. + +```bash +npx @insforge/cli telemetry status +npx @insforge/cli telemetry disable +npx @insforge/cli telemetry enable +``` + #### `npx @insforge/cli create` Create a new InsForge project interactively. @@ -1192,6 +1205,8 @@ If you build the CLI from source without setting `POSTHOG_API_KEY` at build time | `INSFORGE_API_URL` | Override the Platform API URL | | `INSFORGE_EMAIL` | Email for non-interactive login | | `INSFORGE_PASSWORD` | Password for non-interactive login | +| `INSFORGE_TELEMETRY_DISABLED` | Disable anonymous usage analytics for this run | +| `DO_NOT_TRACK` | Universal opt-out convention; also disables analytics | ## Non-Interactive / CI Usage diff --git a/src/commands/records/create.ts b/src/commands/records/create.ts deleted file mode 100644 index 713f9741..00000000 --- a/src/commands/records/create.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Command } from 'commander'; -import { ossFetch } from '../../lib/api/oss.js'; -import { requireAuth } from '../../lib/credentials.js'; -import { handleError, getRootOpts, CLIError } from '../../lib/errors.js'; -import { outputJson, outputSuccess } from '../../lib/output.js'; -import { trackCommandUsage } from '../../lib/command-telemetry.js'; - -export function registerRecordsCreateCommand(recordsCmd: Command): void { - recordsCmd - .command('create ') - .description('Create record(s) in a table') - .option('--data ', 'JSON data to insert (object or array of objects)') - .action(async (table: string, opts, cmd) => { - const { json } = getRootOpts(cmd); - try { - await requireAuth(); - - if (!opts.data) { - throw new CLIError('--data is required. Example: --data \'{"name":"John"}\''); - } - - let records: unknown[]; - try { - const parsed = JSON.parse(opts.data) as unknown; - records = Array.isArray(parsed) ? parsed : [parsed]; - } catch { - throw new CLIError('Invalid JSON in --data. Provide a JSON object or array.'); - } - - const res = await ossFetch( - `/api/database/records/${encodeURIComponent(table)}?return=representation`, - { - method: 'POST', - body: JSON.stringify(records), - }, - ); - - const data = await res.json() as { data?: unknown[] }; - - await trackCommandUsage('records', 'create', true); - - if (json) { - outputJson(data); - } else { - const created = data.data ?? []; - outputSuccess(`Created ${created.length || records.length} record(s) in "${table}".`); - } - } catch (err) { - await trackCommandUsage('records', 'create', false, {}, err); - handleError(err, json); - } - }); -} diff --git a/src/commands/records/delete.ts b/src/commands/records/delete.ts deleted file mode 100644 index f3dde133..00000000 --- a/src/commands/records/delete.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Command } from 'commander'; -import { ossFetch } from '../../lib/api/oss.js'; -import { requireAuth } from '../../lib/credentials.js'; -import { handleError, getRootOpts, CLIError } from '../../lib/errors.js'; -import { outputJson, outputSuccess } from '../../lib/output.js'; -import { trackCommandUsage } from '../../lib/command-telemetry.js'; - -export function registerRecordsDeleteCommand(recordsCmd: Command): void { - recordsCmd - .command('delete
') - .description('Delete records from a table matching a filter') - .option('--filter ', 'Filter expression (e.g. "id=eq.123")') - .action(async (table: string, opts, cmd) => { - const { json } = getRootOpts(cmd); - try { - await requireAuth(); - - if (!opts.filter) { - throw new CLIError('--filter is required to prevent accidental deletion of all rows.'); - } - - const params = new URLSearchParams(); - params.set(opts.filter.split('=')[0], opts.filter.split('=').slice(1).join('=')); - params.set('return', 'representation'); - - const res = await ossFetch( - `/api/database/records/${encodeURIComponent(table)}?${params}`, - { method: 'DELETE' }, - ); - - const data = await res.json() as { data?: unknown[] }; - - await trackCommandUsage('records', 'delete', true); - - if (json) { - outputJson(data); - } else { - const deleted = data.data ?? []; - outputSuccess(`Deleted ${deleted.length} record(s) from "${table}".`); - } - } catch (err) { - await trackCommandUsage('records', 'delete', false, {}, err); - handleError(err, json); - } - }); -} diff --git a/src/commands/records/list.ts b/src/commands/records/list.ts deleted file mode 100644 index d7c55c5e..00000000 --- a/src/commands/records/list.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Command } from 'commander'; -import { ossFetch } from '../../lib/api/oss.js'; -import { requireAuth } from '../../lib/credentials.js'; -import { handleError, getRootOpts } from '../../lib/errors.js'; -import { outputJson, outputTable } from '../../lib/output.js'; -import { trackCommandUsage } from '../../lib/command-telemetry.js'; - -export function registerRecordsCommands(recordsCmd: Command): void { - recordsCmd - .command('list
') - .description('List records from a table') - .option('--select ', 'Columns to select (comma-separated)') - .option('--filter ', 'Filter expression (e.g. "name=eq.John")') - .option('--order ', 'Order by (e.g. "created_at.desc")') - .option('--limit ', 'Limit number of records', parseInt) - .option('--offset ', 'Offset for pagination', parseInt) - .action(async (table: string, opts, cmd) => { - const { json } = getRootOpts(cmd); - try { - await requireAuth(); - - const params = new URLSearchParams(); - if (opts.select) params.set('select', opts.select); - if (opts.filter) params.set(opts.filter.split('=')[0], opts.filter.split('=').slice(1).join('=')); - if (opts.order) params.set('order', opts.order); - if (opts.limit) params.set('limit', String(opts.limit)); - if (opts.offset) params.set('offset', String(opts.offset)); - - const query = params.toString(); - const path = `/api/database/records/${encodeURIComponent(table)}${query ? `?${query}` : ''}`; - const res = await ossFetch(path); - const data = await res.json() as { data?: Record[] }; - const records = data.data ?? []; - - await trackCommandUsage('records', 'list', true, { - result_count: records.length, - }); - - if (json) { - outputJson(data); - } else { - if (records.length === 0) { - console.log('No records found.'); - return; - } - const headers = Object.keys(records[0]); - outputTable( - headers, - records.map((r) => headers.map((h) => { - const val = r[h]; - if (val === null || val === undefined) return ''; - if (typeof val === 'object') return JSON.stringify(val); - return String(val); - })), - ); - console.log(`${records.length} record(s).`); - } - } catch (err) { - await trackCommandUsage('records', 'list', false, {}, err); - handleError(err, json); - } - }); -} diff --git a/src/commands/records/update.ts b/src/commands/records/update.ts deleted file mode 100644 index 4e3b4ec1..00000000 --- a/src/commands/records/update.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Command } from 'commander'; -import { ossFetch } from '../../lib/api/oss.js'; -import { requireAuth } from '../../lib/credentials.js'; -import { handleError, getRootOpts, CLIError } from '../../lib/errors.js'; -import { outputJson, outputSuccess } from '../../lib/output.js'; -import { trackCommandUsage } from '../../lib/command-telemetry.js'; - -export function registerRecordsUpdateCommand(recordsCmd: Command): void { - recordsCmd - .command('update
') - .description('Update records in a table matching a filter') - .option('--filter ', 'Filter expression (e.g. "id=eq.123")') - .option('--data ', 'JSON data to update') - .action(async (table: string, opts, cmd) => { - const { json } = getRootOpts(cmd); - try { - await requireAuth(); - - if (!opts.filter) { - throw new CLIError('--filter is required to prevent accidental updates to all rows.'); - } - if (!opts.data) { - throw new CLIError('--data is required. Example: --data \'{"name":"Jane"}\''); - } - - let body: unknown; - try { - body = JSON.parse(opts.data) as unknown; - } catch { - throw new CLIError('Invalid JSON in --data.'); - } - - const params = new URLSearchParams(); - params.set(opts.filter.split('=')[0], opts.filter.split('=').slice(1).join('=')); - params.set('return', 'representation'); - - const res = await ossFetch( - `/api/database/records/${encodeURIComponent(table)}?${params}`, - { - method: 'PATCH', - body: JSON.stringify(body), - }, - ); - - const data = await res.json() as { data?: unknown[] }; - - await trackCommandUsage('records', 'update', true); - - if (json) { - outputJson(data); - } else { - const updated = data.data ?? []; - outputSuccess(`Updated ${updated.length} record(s) in "${table}".`); - } - } catch (err) { - await trackCommandUsage('records', 'update', false, {}, err); - handleError(err, json); - } - }); -} diff --git a/src/commands/telemetry.test.ts b/src/commands/telemetry.test.ts new file mode 100644 index 00000000..f52347b4 --- /dev/null +++ b/src/commands/telemetry.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { Command } from 'commander'; +import { registerTelemetryCommand } from './telemetry.js'; + +vi.mock('../lib/config.js', () => ({ + getGlobalConfig: vi.fn(), + saveGlobalConfig: vi.fn(), +})); + +function makeProgram() { + const program = new Command().exitOverride(); + program.option('--json'); + registerTelemetryCommand(program); + return program; +} + +async function run(argv: string[]): Promise { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await makeProgram().parseAsync(argv, { from: 'user' }); + return logSpy.mock.calls.flat().join('\n'); + } finally { + logSpy.mockRestore(); + } +} + +describe('telemetry command', () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.stubEnv('DO_NOT_TRACK', ''); + vi.stubEnv('INSFORGE_TELEMETRY_DISABLED', ''); + const { getGlobalConfig } = await import('../lib/config.js'); + (getGlobalConfig as Mock).mockReturnValue({ platform_api_url: 'https://api.insforge.dev' }); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('status reports enabled by default', async () => { + const out = await run(['telemetry', 'status', '--json']); + expect(JSON.parse(out)).toEqual({ enabled: true, source: 'default' }); + }); + + it('status reports a config opt-out with its source', async () => { + const { getGlobalConfig } = await import('../lib/config.js'); + (getGlobalConfig as Mock).mockReturnValue({ + platform_api_url: 'https://api.insforge.dev', + telemetry_disabled: true, + }); + const out = await run(['telemetry', 'status', '--json']); + expect(JSON.parse(out)).toEqual({ enabled: false, source: 'config' }); + }); + + it('status reports an env-var override', async () => { + vi.stubEnv('DO_NOT_TRACK', '1'); + const out = await run(['telemetry', 'status', '--json']); + expect(JSON.parse(out)).toEqual({ enabled: false, source: 'DO_NOT_TRACK' }); + }); + + it('disable persists telemetry_disabled in the global config', async () => { + const { saveGlobalConfig } = await import('../lib/config.js'); + await run(['telemetry', 'disable', '--json']); + expect((saveGlobalConfig as Mock).mock.calls[0][0]).toMatchObject({ telemetry_disabled: true }); + }); + + it('enable removes the flag from the global config', async () => { + const { getGlobalConfig, saveGlobalConfig } = await import('../lib/config.js'); + (getGlobalConfig as Mock).mockReturnValue({ + platform_api_url: 'https://api.insforge.dev', + telemetry_disabled: true, + }); + await run(['telemetry', 'enable', '--json']); + const saved = (saveGlobalConfig as Mock).mock.calls[0][0]; + expect('telemetry_disabled' in saved).toBe(false); + }); + + it('enable errors when an env var still forces telemetry off', async () => { + vi.stubEnv('INSFORGE_TELEMETRY_DISABLED', '1'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as never); + try { + await expect(run(['telemetry', 'enable'])).rejects.toThrow('exit'); + expect(errSpy.mock.calls.flat().join('\n')).toContain('INSFORGE_TELEMETRY_DISABLED'); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts new file mode 100644 index 00000000..66c5c041 --- /dev/null +++ b/src/commands/telemetry.ts @@ -0,0 +1,106 @@ +import type { Command } from 'commander'; +import { getGlobalConfig, saveGlobalConfig } from '../lib/config.js'; +import { CLIError, handleError, getRootOpts } from '../lib/errors.js'; +import { outputJson, outputSuccess, outputInfo } from '../lib/output.js'; + +// This command deliberately emits NO analytics events itself — an opt-out +// flow that phones home undermines the point. + +/** Mirrors the env checks in analytics.ts isTelemetryDisabled(). */ +function envOverride(): string | null { + const flag = (value: string | undefined): boolean => + value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false'; + if (flag(process.env.DO_NOT_TRACK)) return 'DO_NOT_TRACK'; + if (flag(process.env.INSFORGE_TELEMETRY_DISABLED)) return 'INSFORGE_TELEMETRY_DISABLED'; + return null; +} + +interface TelemetryStatus { + enabled: boolean; + /** What decides the current state: an env var name, 'config', or 'default'. */ + source: string; +} + +function resolveStatus(): TelemetryStatus { + const env = envOverride(); + if (env) return { enabled: false, source: env }; + const disabled = getGlobalConfig().telemetry_disabled === true; + return { enabled: !disabled, source: disabled ? 'config' : 'default' }; +} + +export function registerTelemetryCommand(program: Command): void { + const telemetryCmd = program + .command('telemetry') + .description( + 'Manage anonymous usage analytics. InsForge collects command usage metadata ' + + '(never SQL, file contents, credentials, or free text) to improve the CLI. ' + + 'Also honored: DO_NOT_TRACK=1 and INSFORGE_TELEMETRY_DISABLED=1.', + ); + + telemetryCmd + .command('status') + .description('Show whether anonymous usage analytics is enabled and why') + .action((_opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + const status = resolveStatus(); + if (json) { + outputJson(status); + } else { + outputInfo(`Telemetry is ${status.enabled ? 'enabled' : 'disabled'} (source: ${status.source}).`); + if (status.enabled) { + outputInfo('Disable with: npx @insforge/cli telemetry disable'); + } + } + } catch (err) { + handleError(err, json); + } + }); + + telemetryCmd + .command('disable') + .description('Persistently opt out of anonymous usage analytics') + .action((_opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + const config = getGlobalConfig(); + config.telemetry_disabled = true; + saveGlobalConfig(config); + if (json) { + outputJson({ enabled: false, source: 'config' }); + } else { + outputSuccess('Telemetry disabled. No usage analytics will be sent.'); + } + } catch (err) { + handleError(err, json); + } + }); + + telemetryCmd + .command('enable') + .description('Re-enable anonymous usage analytics') + .action((_opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + const config = getGlobalConfig(); + delete config.telemetry_disabled; + saveGlobalConfig(config); + + // Config no longer disables it, but an env var still can — say so + // instead of claiming telemetry is back on when it is not. + const env = envOverride(); + if (env) { + throw new CLIError( + `Config updated, but telemetry stays disabled while ${env} is set in the environment.`, + ); + } + if (json) { + outputJson({ enabled: true, source: 'default' }); + } else { + outputSuccess('Telemetry enabled.'); + } + } catch (err) { + handleError(err, json); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 41220a53..ebe17fc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,10 +29,6 @@ import { registerDbExportCommand } from './commands/db/export.js'; import { registerDbImportCommand } from './commands/db/import.js'; import { registerDbMigrationsCommand } from './commands/db/migrations.js'; import { registerDbConnectionStringCommand } from './commands/db/connection-string.js'; -import { registerRecordsCommands } from './commands/records/list.js'; -import { registerRecordsCreateCommand } from './commands/records/create.js'; -import { registerRecordsUpdateCommand } from './commands/records/update.js'; -import { registerRecordsDeleteCommand } from './commands/records/delete.js'; import { registerFunctionsCommands } from './commands/functions/list.js'; import { registerFunctionsDeployCommand } from './commands/functions/deploy.js'; import { registerFunctionsInvokeCommand } from './commands/functions/invoke.js'; @@ -57,6 +53,7 @@ import { registerDeploymentsSlugCommand } from './commands/deployments/slug.js'; import { registerDocsCommand } from './commands/docs.js'; import { registerFeedbackCommand } from './commands/feedback.js'; +import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerSecretsListCommand } from './commands/secrets/list.js'; import { registerSecretsGetCommand } from './commands/secrets/get.js'; import { registerSecretsAddCommand } from './commands/secrets/add.js'; @@ -154,6 +151,7 @@ registerContextCommand(program); registerListCommand(program); registerDocsCommand(program); registerFeedbackCommand(program); +registerTelemetryCommand(program); registerProjectLinkCommand(program); // Orgs commands @@ -183,13 +181,6 @@ registerDbImportCommand(dbCmd); registerDbMigrationsCommand(dbCmd); registerDbConnectionStringCommand(dbCmd); -// Records commands (hidden — do not use for now) -const recordsCmd = program.command('records', { hidden: true }).description('CRUD operations on table records'); -registerRecordsCommands(recordsCmd); -registerRecordsCreateCommand(recordsCmd); -registerRecordsUpdateCommand(recordsCmd); -registerRecordsDeleteCommand(recordsCmd); - // Functions commands const functionsCmd = program.command('functions').description('Manage edge functions'); registerFunctionsCommands(functionsCmd); diff --git a/src/lib/analytics.test.ts b/src/lib/analytics.test.ts new file mode 100644 index 00000000..17b3ac8c --- /dev/null +++ b/src/lib/analytics.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +const captureMock = vi.fn(); +// A class, not vi.fn(() => ...): analytics.ts calls `new PostHog(...)`, and +// an arrow-function mock implementation is not constructable. +vi.mock('posthog-node', () => ({ + PostHog: class { + capture = captureMock; + shutdown = vi.fn(); + }, +})); +vi.mock('./config.js', () => ({ + FAKE_PROJECT_ID: 'fa4e0000-1234-5678-90ab-cd1234567890', + getGlobalConfig: vi.fn(() => ({ platform_api_url: 'https://api.insforge.dev' })), +})); + +async function loadAnalytics() { + // Fresh module per test: POSTHOG_API_KEY is read at module load and the + // PostHog client is cached in module state. + vi.resetModules(); + return await import('./analytics.js'); +} + +describe('telemetry opt-out', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('POSTHOG_API_KEY', 'test-key'); + vi.stubEnv('DO_NOT_TRACK', ''); + vi.stubEnv('INSFORGE_TELEMETRY_DISABLED', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('sends events by default', async () => { + const { captureEvent } = await loadAnalytics(); + captureEvent('p1', 'cli_test_event', { command: 'x' }); + expect(captureMock).toHaveBeenCalledOnce(); + }); + + it('respects DO_NOT_TRACK', async () => { + vi.stubEnv('DO_NOT_TRACK', '1'); + const { captureEvent, isTelemetryDisabled } = await loadAnalytics(); + expect(isTelemetryDisabled()).toBe(true); + captureEvent('p1', 'cli_test_event'); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('respects INSFORGE_TELEMETRY_DISABLED', async () => { + vi.stubEnv('INSFORGE_TELEMETRY_DISABLED', 'true'); + const { captureEvent, isTelemetryDisabled } = await loadAnalytics(); + expect(isTelemetryDisabled()).toBe(true); + captureEvent('p1', 'cli_test_event'); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('treats explicit falsy env values as not opting out', async () => { + vi.stubEnv('DO_NOT_TRACK', '0'); + vi.stubEnv('INSFORGE_TELEMETRY_DISABLED', 'false'); + const { isTelemetryDisabled } = await loadAnalytics(); + expect(isTelemetryDisabled()).toBe(false); + }); + + it('respects the persistent config opt-out', async () => { + const { getGlobalConfig } = await import('./config.js'); + (getGlobalConfig as Mock).mockReturnValue({ + platform_api_url: 'https://api.insforge.dev', + telemetry_disabled: true, + }); + const { captureEvent, isTelemetryDisabled } = await loadAnalytics(); + expect(isTelemetryDisabled()).toBe(true); + captureEvent('p1', 'cli_test_event'); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('stays enabled when the config file is unreadable', async () => { + const { getGlobalConfig } = await import('./config.js'); + (getGlobalConfig as Mock).mockImplementation(() => { + throw new Error('corrupt json'); + }); + const { isTelemetryDisabled } = await loadAnalytics(); + expect(isTelemetryDisabled()).toBe(false); + }); +}); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 73f93e11..4fd244f0 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,14 +1,39 @@ import { PostHog } from 'posthog-node'; import type { ProjectConfig } from '../types.js'; -import { FAKE_PROJECT_ID } from './config.js'; +import { FAKE_PROJECT_ID, getGlobalConfig } from './config.js'; const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY; const POSTHOG_HOST = process.env.POSTHOG_HOST || 'https://us.i.posthog.com'; +/** True when the env var is set to anything except an explicit falsy value. */ +function envFlag(value: string | undefined): boolean { + if (value === undefined || value === '') return false; + return value !== '0' && value.toLowerCase() !== 'false'; +} + +/** + * Usage-tracking kill switch, honored by every telemetry emitter (PostHog + * here, `reportCliUsage` in skills.ts). Resolution order: + * 1. `DO_NOT_TRACK` — the cross-tool convention (consoledonottrack.com) + * 2. `INSFORGE_TELEMETRY_DISABLED` — per-run / CI override + * 3. `telemetry_disabled` in ~/.insforge/config.json — persistent opt-out + * managed by `insforge telemetry enable|disable|status` + * A corrupt config file must never break the CLI, so it reads as "enabled". + */ +export function isTelemetryDisabled(): boolean { + if (envFlag(process.env.DO_NOT_TRACK)) return true; + if (envFlag(process.env.INSFORGE_TELEMETRY_DISABLED)) return true; + try { + return getGlobalConfig().telemetry_disabled === true; + } catch { + return false; + } +} + let client: PostHog | null = null; function getClient(): PostHog | null { - if (!POSTHOG_API_KEY) return null; + if (!POSTHOG_API_KEY || isTelemetryDisabled()) return null; if (!client) { client = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST }); } diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 64b858d8..71481eaa 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { promisify } from 'node:util'; import * as clack from '@clack/prompts'; import { writeLocalAgentsMd } from './agents-md.js'; +import { isTelemetryDisabled } from './analytics.js'; import { getProjectConfig } from './config.js'; const execAsync = promisify(exec); @@ -178,6 +179,9 @@ export async function reportCliUsage( maxRetries = 1, explicitConfig?: { oss_host: string; api_key: string }, ): Promise { + // Honor the same opt-out as PostHog analytics (`insforge telemetry disable`, + // DO_NOT_TRACK, INSFORGE_TELEMETRY_DISABLED) — this is usage tracking too. + if (isTelemetryDisabled()) return; let config: { oss_host: string; api_key: string } | null | undefined = explicitConfig; if (!config) { try { diff --git a/src/types.ts b/src/types.ts index 4fcaebdc..f4576c84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,6 +114,8 @@ export interface GlobalConfig { default_org_id?: string; platform_api_url: string; oauth_client_id?: string; + /** Persistent opt-out from anonymous usage analytics, set via `insforge telemetry disable`. */ + telemetry_disabled?: boolean; } // Project config (local .insforge/project.json)