mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
c2324f052d
## Why
Teams that must not send usage data to a third party (a
strict-compliance customer asked) had no way to turn telemetry off for a
repository. `telemetry.enabled` was global-only
(`~/.argent/config.json`), so a committed local install (`init --local`)
still defaulted every teammate to **on** — `--no-telemetry` only opted
out the machine that ran `init`.
## What
- **`telemetry.enabled` accepts the project scope.** `consent.ts` now
reads both `~/.argent/config.json` and
`<project-root>/.argent/config.json` (root resolved from `cwd`, per-file
cache keyed by path) and merges **restrictively**: `false` in either
document wins. A committed project opt-out holds on every clone, and a
project file can never re-enable a global opt-out. Env overrides
(`DO_NOT_TRACK`, `ARGENT_TELEMETRY`) still outrank both.
- **`argent telemetry enable|disable --scope global|project`** (default
`global`). `status` names the document that decided (`config.json
(project)`, `(global)`, `(project and global)`). `enable` at one scope
while the other still opts out reports that telemetry stays disabled
instead of claiming success. Unknown scope / argument → exit 2, nothing
written.
- **`argent init --local --no-telemetry`** also writes
`telemetry.enabled: false` to `.argent/config.json`, and the "commit
these files" checklist lists it. Global mode is unchanged.
- `argent config set/unset telemetry.enabled` stays delegated to `argent
telemetry` (`manageCommand`), so the lifecycle work (draining the live
client) is still done by the dedicated command.
## Tests
Unit: `telemetry` (+8 project-scope cases: single-scope opt-out, both
restrictive directions, both-off, env precedence, live re-read of the
project file, malformed project file), `configuration-core` (schema
scopes), new `argent-cli/test/telemetry-command.test.ts` (real-fs, 6
cases), `argent-installer` — all green; eslint, prettier, knip,
`docusaurus build` green.
Live, from a real `npm pack` tarball of this branch (sandboxed `HOME`):
1. Fresh project → `init --local --yes --no-telemetry --from <tgz>`:
writes `.argent/config.json` `{telemetry:{enabled:false}}` + global
opt-out; summary lists `.argent/config.json` to commit.
2. `telemetry status` → `disabled`, source `config.json (project and
global)`. `telemetry enable` (global) → "stays disabled: config.json
(project) still opts out"; from `~` (no project) → `enabled`. `enable
--scope project` → enabled; `disable --scope project` → disabled.
`--scope team` → exit 2.
3. **Teammate simulation**: copied only the committed files to a fresh
clone with a fresh `HOME` (no global opt-out), bundle built with a dummy
OTLP token, `ARGENT_TELEMETRY_DEBUG=1`, drove `argent mcp` (initialize →
tools/list → `list-devices` call). Control clone without
`.argent/config.json`: tool-server emitted `toolserver:start`,
`tool:invoke`, `tool:complete`, `toolserver:stop`. Clone with the
committed opt-out: **0 events**, and the first-run telemetry notice is
not printed.
## Caveat (documented)
The project file is resolved from the working directory of the process.
Editors start the MCP server in the workspace root, so it and the
tool-server it launches read it. The tool-server is shared by every
editor on the machine and reads the project file of the workspace that
started it; the global opt-out or `DO_NOT_TRACK` still covers the
machine-wide case.
## Docs
Updated in this PR: `reference/configuration.mdx` (scopes + merge
wording), `reference/telemetry.mdx` (new "Opt out for a whole
repository" section), `reference/cli.mdx` (`init`, `telemetry` rows),
`fundamentals/installation.mdx` (`--no-telemetry` in local mode).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BkkAihA8Kp26hHS4uhkNGu
126 lines
4.8 KiB
TypeScript
126 lines
4.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { telemetry } from "../src/telemetry.js";
|
|
import { getConsentState, _resetConsentCacheForTest } from "@argent/telemetry";
|
|
|
|
// Real-filesystem test of `argent telemetry enable|disable [--scope]`: a
|
|
// sandboxed HOME (global scope) and a tmp project root (`.git` marker), so the
|
|
// restrictive project/global merge is exercised through the real command.
|
|
// Only the network client is stubbed — no events must leave the test.
|
|
|
|
vi.mock("@argent/telemetry", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@argent/telemetry")>();
|
|
return {
|
|
...actual,
|
|
init: vi.fn(),
|
|
shutdown: vi.fn(async () => undefined),
|
|
};
|
|
});
|
|
|
|
class ExitError extends Error {
|
|
constructor(public code: number) {
|
|
super(`process.exit(${code})`);
|
|
}
|
|
}
|
|
|
|
let homeDir: string;
|
|
let projectDir: string;
|
|
let originalHome: string | undefined;
|
|
let originalUserProfile: string | undefined;
|
|
let originalCwd: string;
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-tel-home-")));
|
|
projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-tel-proj-")));
|
|
fs.mkdirSync(path.join(projectDir, ".git"));
|
|
originalHome = process.env.HOME;
|
|
originalUserProfile = process.env.USERPROFILE;
|
|
originalCwd = process.cwd();
|
|
process.env.HOME = homeDir;
|
|
process.env.USERPROFILE = homeDir;
|
|
process.chdir(projectDir);
|
|
_resetConsentCacheForTest();
|
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
throw new ExitError(code ?? 0);
|
|
}) as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
if (originalHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = originalHome;
|
|
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = originalUserProfile;
|
|
vi.restoreAllMocks();
|
|
_resetConsentCacheForTest();
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function readJson(file: string): Record<string, unknown> | null {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
const globalConfig = () => readJson(path.join(homeDir, ".argent", "config.json"));
|
|
const projectConfig = () => readJson(path.join(projectDir, ".argent", "config.json"));
|
|
const output = () => logSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
|
|
const cleanEnv = { DO_NOT_TRACK: undefined, ARGENT_TELEMETRY: undefined };
|
|
|
|
describe("argent telemetry — scopes", () => {
|
|
it("disable defaults to the global scope", async () => {
|
|
await telemetry(["disable"]);
|
|
expect(globalConfig()).toEqual({ telemetry: { enabled: false } });
|
|
expect(projectConfig()).toBeNull();
|
|
expect(output()).toContain("global scope");
|
|
});
|
|
|
|
it("disable --scope project writes the committed project file only", async () => {
|
|
await telemetry(["disable", "--scope", "project"]);
|
|
expect(projectConfig()).toEqual({ telemetry: { enabled: false } });
|
|
expect(globalConfig()).toBeNull();
|
|
const state = getConsentState(cleanEnv);
|
|
expect(state.enabled).toBe(false);
|
|
expect(state.source.detail).toBe("config.json (project)");
|
|
});
|
|
|
|
it("enable --scope=project alone cannot override a global opt-out (restrictive)", async () => {
|
|
await telemetry(["disable"]);
|
|
await telemetry(["enable", "--scope=project"]);
|
|
expect(projectConfig()).toEqual({ telemetry: { enabled: true } });
|
|
expect(getConsentState(cleanEnv).enabled).toBe(false);
|
|
expect(output()).toContain("stays disabled");
|
|
});
|
|
|
|
it("a global enable cannot override a project opt-out", async () => {
|
|
await telemetry(["disable", "--scope", "project"]);
|
|
await telemetry(["enable"]);
|
|
expect(globalConfig()).toEqual({ telemetry: { enabled: true } });
|
|
expect(getConsentState(cleanEnv).enabled).toBe(false);
|
|
});
|
|
|
|
it("status names the document that opted out", async () => {
|
|
await telemetry(["disable", "--scope", "project"]);
|
|
logSpy.mockClear();
|
|
await telemetry(["status"]);
|
|
expect(output()).toContain("state: disabled");
|
|
expect(output()).toContain("config.json (project)");
|
|
});
|
|
|
|
it("rejects an unknown scope and an unknown argument", async () => {
|
|
await expect(telemetry(["disable", "--scope", "team"])).rejects.toThrow("process.exit(2)");
|
|
await expect(telemetry(["enable", "--force"])).rejects.toThrow("process.exit(2)");
|
|
expect(errSpy).toHaveBeenCalled();
|
|
expect(globalConfig()).toBeNull();
|
|
expect(projectConfig()).toBeNull();
|
|
});
|
|
});
|