Skip to content

Commit c3ed9e1

Browse files
committed
feat(trial): add sentry trial list/start commands
- Add standalone trial commands: sentry trial list, sentry trial start - Rename getSeerTrialStatus → getProductTrials (returns ProductTrial[]) - Rename startSeerTrial → startProductTrial (generic category param) - Add src/lib/trials.ts with trial name mapping and status helpers - Add swap detection for trial start args (org/name order) - Update SeerError messages to hint at trial commands - Refactor seer-trial.ts to use shared trial infrastructure - Fix getTrialDisplayName bug: add getDisplayNameForTrialName for CLI names - Add comprehensive tests (80 tests across 5 files)
1 parent ebea42e commit c3ed9e1

File tree

15 files changed

+1477
-203
lines changed

15 files changed

+1477
-203
lines changed

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,9 @@ mock.module("./some-module", () => ({
643643
<!-- lore:019c972c-9f0f-75cd-9e24-9bdbb1ac03d6 -->
644644
* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only.
645645
646+
<!-- lore:019ce0bb-f35d-7380-b661-8dc56f9938cf -->
647+
* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: The CLI's error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\` errors) are caught by the inner wrapper; auth errors bubble up to the outer wrapper. After successful auth login retry, the retry also goes through \`executeWithSeerTrialPrompt\` (not \`runCommand\` directly) so the full middleware chain applies. Trial check API: \`GET /api/0/customers/{org}/\`\`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start trial: \`PUT /api/0/customers/{org}/product-trial/\`. The \`/customers/\` endpoint is getsentry SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` errors are excluded (admin's explicit choice). \`startSeerTrial\` accepts \`category\` from the trial object — don't hardcode it.
648+
646649
### Decision
647650
648651
<!-- lore:019c99d5-69f2-74eb-8c86-411f8512801d -->

src/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { teamRoute } from "./commands/team/index.js";
2828
import { listCommand as teamListCommand } from "./commands/team/list.js";
2929
import { traceRoute } from "./commands/trace/index.js";
3030
import { listCommand as traceListCommand } from "./commands/trace/list.js";
31+
import { trialRoute } from "./commands/trial/index.js";
32+
import { listCommand as trialListCommand } from "./commands/trial/list.js";
3133
import { CLI_VERSION } from "./lib/constants.js";
3234
import {
3335
AuthError,
@@ -49,6 +51,7 @@ const PLURAL_TO_SINGULAR: Record<string, string> = {
4951
teams: "team",
5052
logs: "log",
5153
traces: "trace",
54+
trials: "trial",
5255
};
5356

5457
/** Top-level route map containing all CLI commands */
@@ -65,6 +68,7 @@ export const routes = buildRouteMap({
6568
event: eventRoute,
6669
log: logRoute,
6770
trace: traceRoute,
71+
trial: trialRoute,
6872
init: initCommand,
6973
api: apiCommand,
7074
issues: issueListCommand,
@@ -74,6 +78,7 @@ export const routes = buildRouteMap({
7478
teams: teamListCommand,
7579
logs: logListCommand,
7680
traces: traceListCommand,
81+
trials: trialListCommand,
7782
whoami: whoamiCommand,
7883
},
7984
defaultCommand: "help",

src/commands/trial/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { buildRouteMap } from "@stricli/core";
2+
import { listCommand } from "./list.js";
3+
import { startCommand } from "./start.js";
4+
5+
export const trialRoute = buildRouteMap({
6+
routes: {
7+
list: listCommand,
8+
start: startCommand,
9+
},
10+
docs: {
11+
brief: "Manage product trials",
12+
fullDescription: "List and start product trials for your organization.",
13+
hideRoute: {},
14+
},
15+
});

src/commands/trial/list.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* sentry trial list
3+
*
4+
* List product trials available to an organization, including
5+
* active trials with time remaining and expired trials.
6+
*/
7+
8+
import type { SentryContext } from "../../context.js";
9+
import { getProductTrials } from "../../lib/api-client.js";
10+
import { buildCommand } from "../../lib/command.js";
11+
import { ContextError } from "../../lib/errors.js";
12+
import { colorTag } from "../../lib/formatters/markdown.js";
13+
import { type Column, writeTable } from "../../lib/formatters/table.js";
14+
import { resolveOrg } from "../../lib/resolve-target.js";
15+
import {
16+
getDaysRemaining,
17+
getTrialDisplayName,
18+
getTrialFriendlyName,
19+
getTrialStatus,
20+
type TrialStatus,
21+
} from "../../lib/trials.js";
22+
import type { Writer } from "../../types/index.js";
23+
24+
type ListFlags = {
25+
readonly json: boolean;
26+
readonly fields?: string[];
27+
};
28+
29+
/**
30+
* Enriched trial entry for display.
31+
* Adds derived fields (name, displayName, status, daysRemaining) to the raw API data.
32+
*/
33+
type TrialListEntry = {
34+
/** CLI-friendly name (e.g., "seer") */
35+
name: string;
36+
/** Human-readable product name (e.g., "Seer") */
37+
displayName: string;
38+
/** API category name (e.g., "seerUsers") */
39+
category: string;
40+
/** Derived status */
41+
status: TrialStatus;
42+
/** Days remaining for active trials, null otherwise */
43+
daysRemaining: number | null;
44+
/** Raw isStarted flag from API */
45+
isStarted: boolean;
46+
/** Trial length in days, null if not set */
47+
lengthDays: number | null;
48+
/** ISO date string when trial started, null if not started */
49+
startDate: string | null;
50+
/** ISO date string when trial ends, null if not started */
51+
endDate: string | null;
52+
};
53+
54+
/** Status display labels with color indicators */
55+
const STATUS_LABELS: Record<TrialStatus, string> = {
56+
available: `${colorTag("cyan", "○")} Available`,
57+
active: `${colorTag("green", "●")} Active`,
58+
expired: `${colorTag("muted", "−")} Expired`,
59+
};
60+
61+
/**
62+
* Format trial list as a human-readable table.
63+
*/
64+
function formatTrialListHuman(entries: TrialListEntry[]): string {
65+
if (entries.length === 0) {
66+
return "No product trials found for this organization.";
67+
}
68+
69+
const columns: Column<TrialListEntry>[] = [
70+
{ header: "NAME", value: (t) => t.name },
71+
{ header: "PRODUCT", value: (t) => t.displayName },
72+
{ header: "STATUS", value: (t) => STATUS_LABELS[t.status] ?? t.status },
73+
{
74+
header: "DAYS LEFT",
75+
value: (t) => {
76+
if (t.status === "active" && t.daysRemaining !== null) {
77+
return t.daysRemaining === 0
78+
? colorTag("yellow", "<1")
79+
: String(t.daysRemaining);
80+
}
81+
return colorTag("muted", "—");
82+
},
83+
align: "right",
84+
},
85+
{
86+
header: "CATEGORY",
87+
value: (t) => colorTag("muted", t.category),
88+
},
89+
];
90+
91+
const parts: string[] = [];
92+
const buffer: Writer = { write: (s) => parts.push(s) };
93+
writeTable(buffer, entries, columns);
94+
return parts.join("").trimEnd();
95+
}
96+
97+
export const listCommand = buildCommand({
98+
docs: {
99+
brief: "List product trials",
100+
fullDescription:
101+
"List product trials for an organization, including available,\n" +
102+
"active, and expired trials.\n\n" +
103+
"Examples:\n" +
104+
" sentry trial list\n" +
105+
" sentry trial list my-org\n" +
106+
" sentry trial list --json",
107+
},
108+
output: {
109+
json: true,
110+
human: formatTrialListHuman,
111+
jsonExclude: ["displayName"],
112+
},
113+
parameters: {
114+
positional: {
115+
kind: "tuple" as const,
116+
parameters: [
117+
{
118+
placeholder: "org",
119+
brief: "Organization slug (auto-detected if omitted)",
120+
parse: String,
121+
optional: true as const,
122+
},
123+
],
124+
},
125+
},
126+
async func(this: SentryContext, _flags: ListFlags, org?: string) {
127+
const resolved = await resolveOrg({
128+
org,
129+
cwd: this.cwd,
130+
});
131+
132+
if (!resolved) {
133+
throw new ContextError("Organization", "sentry trial list", [
134+
"sentry trial list <org>",
135+
]);
136+
}
137+
138+
const trials = await getProductTrials(resolved.org);
139+
140+
const entries: TrialListEntry[] = trials.map((t) => ({
141+
name: getTrialFriendlyName(t.category),
142+
displayName: getTrialDisplayName(t.category),
143+
category: t.category,
144+
status: getTrialStatus(t),
145+
daysRemaining: getDaysRemaining(t),
146+
isStarted: t.isStarted,
147+
lengthDays: t.lengthDays,
148+
startDate: t.startDate,
149+
endDate: t.endDate,
150+
}));
151+
152+
const hints: string[] = [];
153+
const hasAvailable = entries.some((e) => e.status === "available");
154+
if (hasAvailable) {
155+
hints.push("Tip: Use 'sentry trial start <name>' to start a trial");
156+
}
157+
158+
return { data: entries, hint: hints.join("\n") || undefined };
159+
},
160+
});

src/commands/trial/start.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* sentry trial start
3+
*
4+
* Start a product trial for an organization.
5+
* Supports swap detection: `sentry trial start my-org seer` works
6+
* the same as `sentry trial start seer my-org`.
7+
*/
8+
9+
import type { SentryContext } from "../../context.js";
10+
import { getProductTrials, startProductTrial } from "../../lib/api-client.js";
11+
import { detectSwappedTrialArgs } from "../../lib/arg-parsing.js";
12+
import { buildCommand } from "../../lib/command.js";
13+
import { ContextError, ValidationError } from "../../lib/errors.js";
14+
import { success } from "../../lib/formatters/colors.js";
15+
import { logger } from "../../lib/logger.js";
16+
import { resolveOrg } from "../../lib/resolve-target.js";
17+
import {
18+
findAvailableTrial,
19+
getDisplayNameForTrialName,
20+
getTrialDisplayName,
21+
getValidTrialNames,
22+
isTrialName,
23+
} from "../../lib/trials.js";
24+
25+
const VALID_NAMES = getValidTrialNames();
26+
const NAMES_LIST = VALID_NAMES.join(", ");
27+
28+
/**
29+
* Parse the positional args for `trial start`, handling swapped order.
30+
*
31+
* Expected: `<name> [org]`
32+
* Also accepted: `<org> <name>` (detected and auto-corrected)
33+
*
34+
* @returns Parsed name and optional org, plus any warning message
35+
*/
36+
function parseTrialStartArgs(
37+
first: string,
38+
second?: string
39+
): { name: string; org?: string; warning?: string } {
40+
if (!second) {
41+
// Single arg — must be a trial name
42+
return { name: first };
43+
}
44+
45+
// Two args — check for swapped order
46+
const swapped = detectSwappedTrialArgs(first, second, isTrialName);
47+
if (swapped) {
48+
return { name: swapped.name, org: swapped.org, warning: swapped.warning };
49+
}
50+
51+
// Normal order: first=name, second=org
52+
return { name: first, org: second };
53+
}
54+
55+
export const startCommand = buildCommand({
56+
docs: {
57+
brief: "Start a product trial",
58+
fullDescription:
59+
"Start a product trial for an organization.\n\n" +
60+
`Valid trial names: ${NAMES_LIST}\n\n` +
61+
"Examples:\n" +
62+
" sentry trial start seer\n" +
63+
" sentry trial start seer my-org\n" +
64+
" sentry trial start replays\n" +
65+
" sentry trial start --json seer",
66+
},
67+
output: { json: true, human: formatStartResult },
68+
parameters: {
69+
positional: {
70+
kind: "tuple" as const,
71+
parameters: [
72+
{
73+
placeholder: "name",
74+
brief: `Trial name (${NAMES_LIST})`,
75+
parse: String,
76+
},
77+
{
78+
placeholder: "org",
79+
brief: "Organization slug (auto-detected if omitted)",
80+
parse: String,
81+
optional: true as const,
82+
},
83+
],
84+
},
85+
},
86+
async func(
87+
this: SentryContext,
88+
_flags: unknown,
89+
first: string,
90+
second?: string
91+
) {
92+
const log = logger.withTag("trial");
93+
const parsed = parseTrialStartArgs(first, second);
94+
95+
if (parsed.warning) {
96+
log.warn(parsed.warning);
97+
}
98+
99+
// Validate trial name
100+
if (!isTrialName(parsed.name)) {
101+
throw new ValidationError(
102+
`Unknown trial name: '${parsed.name}'. Valid names: ${NAMES_LIST}`,
103+
"name"
104+
);
105+
}
106+
107+
// Resolve organization
108+
const resolved = await resolveOrg({
109+
org: parsed.org,
110+
cwd: this.cwd,
111+
});
112+
113+
if (!resolved) {
114+
throw new ContextError("Organization", "sentry trial start", [
115+
"sentry trial start <name> <org>",
116+
]);
117+
}
118+
119+
const orgSlug = resolved.org;
120+
121+
// Fetch trials and find an available one
122+
const trials = await getProductTrials(orgSlug);
123+
const trial = findAvailableTrial(trials, parsed.name);
124+
125+
if (!trial) {
126+
const displayName = getDisplayNameForTrialName(parsed.name);
127+
throw new ValidationError(
128+
`No ${displayName} trial available for organization '${orgSlug}'.`,
129+
"name"
130+
);
131+
}
132+
133+
// Start the trial
134+
await startProductTrial(orgSlug, trial.category);
135+
136+
return {
137+
data: {
138+
name: parsed.name,
139+
category: trial.category,
140+
organization: orgSlug,
141+
lengthDays: trial.lengthDays,
142+
started: true,
143+
},
144+
hint: undefined,
145+
};
146+
},
147+
});
148+
149+
/** Format start result as human-readable output */
150+
function formatStartResult(data: {
151+
name: string;
152+
category: string;
153+
organization: string;
154+
lengthDays: number | null;
155+
started: boolean;
156+
}): string {
157+
const displayName = getTrialDisplayName(data.category);
158+
const daysText = data.lengthDays ? ` (${data.lengthDays} days)` : "";
159+
return `${success("✓")} ${displayName} trial started for ${data.organization}!${daysText}`;
160+
}

0 commit comments

Comments
 (0)