Files
software-mansion__argent/packages/argent-cli/src/telemetry.ts
Ignacy Łątka 2e19c97de0 fix(cli): accept --help after a telemetry subcommand (#959)
Found while differentially regression-testing the 0.22.1 release against
a bundle built from `v0.22.0`.

## The problem

`argent telemetry status --help` exits 2 with `Error: Unknown flag:
--help`. So do `-h`, and the same after `enable` / `disable`. On 0.22.0
all of them exited 0.

```
$ argent telemetry status --help
0.22.0   telemetry: / state: enabled / device id: not created     (rc=0)
0.22.1   Error: Unknown flag: --help                              (rc=2)
```

`telemetry` is the only subcommand that behaves this way. Checked
against every sibling on both versions — `server status|logs|start`,
`config list|get|set`, `flow list|run`, `secrets`, `flags`, `enable`,
`link`, `unlink` all return 0 on both. And the top-level help tells the
user to run exactly this: "Run `argent <command> --help` for
command-specific help."

## Cause

`TELEMETRY_OPTIONS` declares only `scope`, and
`parseCommandArgs(args.slice(1), …)` reads everything after the
subcommand — so `--help` arrives as an unknown flag. The `case "--help"`
arm in the switch only fires when help is `args[0]`, which is why bare
`argent telemetry --help` still works.

Introduced when `telemetry` was converted to the declarative parser
(#952, extended by #953).

## The fix

Hoist the help check above the parser, matching what `config.ts:220` and
`flags.ts:92` already do; `server`, `link` and `lens` instead declare
`help` in their option spec. Telemetry did neither. Ordering of
`telemetryInit` / `telemetryShutdown` around the help path is unchanged,
so a help invocation behaves exactly as `argent telemetry --help` did
before.

## Tests

Six cases added to `telemetry-command.test.ts` covering `--help` / `-h`,
bare and after each subcommand, asserting usage is printed and no config
file is written.

Mutation-checked: with the source change reverted, 4 of the 6 fail (the
2 bare-help cases pass either way, since those already worked) — so the
test pins the actual defect rather than the behaviour around it.

`@argent/cli`: 498 passed, 3 skipped. `tsc --build` clean, prettier
clean.

## Docs

No docs update needed — this restores previously documented behaviour
rather than changing a documented surface.

Co-authored-by: Ignacy Łątka <f.kaminski2000@gmail.com>
2026-08-25 17:05:57 +02:00

147 lines
4.6 KiB
TypeScript

import pc from "picocolors";
import type { FlagScope } from "@argent/configuration-core";
import { parseCommandArgs, UsageError, type OptionSpecs } from "./command-args.js";
import {
init as telemetryInit,
isEnabled as telemetryIsEnabled,
markDisabled,
markEnabled,
shutdown as telemetryShutdown,
status as telemetryStatus,
} from "@argent/telemetry";
const SCOPES: readonly FlagScope[] = ["global", "project"];
const TELEMETRY_OPTIONS = {
scope: { kind: "value", choices: SCOPES },
} as const satisfies OptionSpecs;
// Telemetry is opt-out: on by default.
export async function telemetry(args: string[]): Promise<void> {
const sub = args[0];
let scope: FlagScope = "global";
// Help is accepted in any position, including after a subcommand; the option
// parser below knows only `--scope` and would reject it as an unknown flag.
const wantsHelp = args.includes("--help") || args.includes("-h");
if (!wantsHelp) {
try {
const { positionals, options } = parseCommandArgs(args.slice(1), TELEMETRY_OPTIONS);
if (positionals.length > 0) {
throw new UsageError(`Unexpected argument "${positionals[0]}".`);
}
if (options.scope !== undefined) scope = options.scope as FlagScope;
} catch (err) {
if (!(err instanceof UsageError)) throw err;
console.error(`Error: ${err.message}`);
printUsage();
process.exit(2);
}
}
telemetryInit("cli");
if (wantsHelp) {
printUsage();
await telemetryShutdown();
return;
}
switch (sub) {
case undefined:
printUsage();
await telemetryShutdown();
return;
case "status":
printStatus();
await telemetryShutdown();
return;
case "enable":
await cmdEnable(scope);
return;
case "disable":
await cmdDisable(scope);
return;
default:
console.error(`Unknown subcommand: telemetry ${sub}`);
await telemetryShutdown();
process.exit(1);
}
}
function printUsage(): void {
console.log(`Usage:
argent telemetry status Show telemetry state and device id
argent telemetry enable [--scope global|project] Enable telemetry
argent telemetry disable [--scope global|project] Disable telemetry
The default scope is global (~/.argent/config.json). \`--scope project\` writes
<project-root>/.argent/config.json instead — commit it and telemetry stays off
for everyone who clones the repository. \`false\` in either scope wins.
`);
}
function scopeLabel(scope: FlagScope): string {
return scope === "project" ? "project scope" : "global scope";
}
function printStatus(): void {
const s = telemetryStatus();
const idLabel = s.anonIdPrefix
? `${s.anonIdPrefix}...`
: s.hasAnonIdOnDisk
? "present"
: "not created";
console.log("telemetry:");
console.log(` state: ${s.enabled ? "enabled" : "disabled"}`);
console.log(` source: ${describeSource(s.source)}`);
console.log(` device id: ${idLabel}`);
}
function describeSource(source: ReturnType<typeof telemetryStatus>["source"]): string {
switch (source.source) {
case "env_do_not_track":
case "env_argent_telemetry":
return `environment (${source.detail ?? source.source})`;
case "config_file":
return source.detail ?? "config.json";
case "session_override":
return "this session";
case "default":
return "default (no opt-out set)";
}
}
async function cmdEnable(scope: FlagScope): Promise<void> {
const wasEnabled = telemetryIsEnabled();
markEnabled(scope);
const nowEnabled = telemetryIsEnabled();
if (!nowEnabled) {
// The other scope (or an env override) still says no — restrictive merge.
console.log(
pc.yellow(
`Telemetry set to enabled at ${scopeLabel(scope)}, but it stays disabled: ` +
`${describeSource(telemetryStatus().source)} still opts out.`
)
);
} else if (wasEnabled) {
console.log(pc.dim(`Telemetry was already enabled (written at ${scopeLabel(scope)}).`));
} else {
console.log(pc.green(`Telemetry enabled (${scopeLabel(scope)}).`));
}
await telemetryShutdown();
}
async function cmdDisable(scope: FlagScope): Promise<void> {
const wasEnabled = telemetryIsEnabled();
// Still write when already off: a global opt-out and a committed project
// opt-out are different guarantees, and the user asked for this one.
await markDisabled(scope);
if (!wasEnabled) {
console.log(pc.dim(`Telemetry was already disabled (written at ${scopeLabel(scope)}).`));
} else {
console.log(pc.red(`Telemetry disabled (${scopeLabel(scope)}).`));
}
await telemetryShutdown();
}