@cantinasecurity/apex-cli 0.1.17 → 0.1.18

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.
@@ -0,0 +1,65 @@
1
+ const CLAIM_DEADLINE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
2
+ function isLeapYear(year) {
3
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
4
+ }
5
+ function daysInMonth(year, month) {
6
+ if (month === 2)
7
+ return isLeapYear(year) ? 29 : 28;
8
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
9
+ }
10
+ /** Parses only the RFC3339 shape emitted by the onboarding API. */
11
+ export function parseClaimDeadlineMs(value) {
12
+ if (typeof value !== "string")
13
+ return null;
14
+ const match = CLAIM_DEADLINE_PATTERN.exec(value);
15
+ if (!match)
16
+ return null;
17
+ const year = Number(match[1]);
18
+ const month = Number(match[2]);
19
+ const day = Number(match[3]);
20
+ const hour = Number(match[4]);
21
+ const minute = Number(match[5]);
22
+ const second = Number(match[6]);
23
+ const offset = match[7] ?? "";
24
+ if (year < 1 ||
25
+ month < 1 ||
26
+ month > 12 ||
27
+ day < 1 ||
28
+ day > daysInMonth(year, month) ||
29
+ hour > 23 ||
30
+ minute > 59 ||
31
+ second > 59) {
32
+ return null;
33
+ }
34
+ if (offset !== "Z") {
35
+ const offsetHour = Number(offset.slice(1, 3));
36
+ const offsetMinute = Number(offset.slice(4, 6));
37
+ if (offsetHour > 23 || offsetMinute > 59)
38
+ return null;
39
+ }
40
+ const parsed = Date.parse(value);
41
+ return Number.isFinite(parsed) ? parsed : null;
42
+ }
43
+ /**
44
+ * Classifies the locally stored claim proof without mutating it.
45
+ *
46
+ * A missing or malformed deadline is treated conservatively as available: an
47
+ * older CLI or interrupted write may have left a valid proof without usable
48
+ * deadline metadata, and only the server can safely declare that proof dead.
49
+ * A known deadline is exclusive, so the proof expires exactly at that instant.
50
+ */
51
+ export function getClaimProofState(credentials, nowMs = Date.now()) {
52
+ if (!credentials?.claimToken?.trim())
53
+ return "absent";
54
+ const rawDeadline = credentials.claimExpiresAt;
55
+ const deadline = typeof rawDeadline === "string" ? rawDeadline.trim() : "";
56
+ if (!deadline)
57
+ return "available";
58
+ const deadlineMs = parseClaimDeadlineMs(deadline);
59
+ if (deadlineMs === null || !Number.isFinite(nowMs))
60
+ return "available";
61
+ return deadlineMs > nowMs ? "available" : "expired";
62
+ }
63
+ export function isClaimProofAvailable(credentials, nowMs = Date.now()) {
64
+ return getClaimProofState(credentials, nowMs) === "available";
65
+ }
package/dist/commands.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { canPromptInteractively, getFlagList, getFlagString, isJsonMode, } from "./args.js";
2
- import { getMe, logout } from "./auth.js";
3
- import { ApiError } from "./api-client.js";
2
+ import { getMe, login, logout } from "./auth.js";
3
+ import { ApiError, formatApiError, getApiErrorCode, } from "./api-client.js";
4
4
  import { openInBrowser } from "./browser.js";
5
- import { loadConfig, saveConfig } from "./config.js";
5
+ import { getClaimProofState, isClaimProofAvailable, parseClaimDeadlineMs, } from "./claim-proof.js";
6
+ import { loadConfig, updateConfig } from "./config.js";
6
7
  import { createFindingComment, fetchScanExport, fetchScanFindings, isFindingUuid, normalizeFindingRef, resolveFindingSelection, resolveScanSelection, startFindingFixReviewScan, submitFindingFeedback, } from "./findings.js";
7
8
  import { prepareExplicitScanSources, supportsLegacyRemoteFlow, } from "./local-source-scan.js";
8
9
  import { logLine, printJson, withLoadingIndicator } from "./output.js";
10
+ import { claimOnboardingSession, isProofOnlyOnboardingCredentials, redeemOnboardingCode, revokeGuestOnboardingSession, } from "./onboarding.js";
9
11
  import { confirm } from "./prompt.js";
10
12
  import { cancelScan, fetchScanProgress, fetchWorkspaceScans, findMostRelevantActiveScan, getScanDisplayLabel, getScanDisplayId, getTrackedScanId, isActiveScanStatus, matchesScanId, selectDefaultScanToCancel, } from "./scan.js";
11
13
  import { createServiceApiKey, isServiceApiScope, listServiceApiKeys, revokeServiceApiKey, SERVICE_API_SCOPES, } from "./service-keys.js";
@@ -13,7 +15,7 @@ import { chooseCompany, createWorkspaceBinding, ensureAuthenticated, remediateMi
13
15
  import { createWorkspaceBindingFromSummary, fetchCompanyCredits, fetchCompanyWorkspaces, findWorkspaceByRef, } from "./workspaces.js";
14
16
  import { loadWorkspaceBinding, saveWorkspaceBinding } from "./workspace-binding.js";
15
17
  import { commandSetup as runCliSetup } from "./setup.js";
16
- import { getTelemetryStatus, setTelemetryEnabled, } from "./telemetry.js";
18
+ import { getTelemetryStatus, setTelemetryAuthKind, setTelemetryEnabled, setTelemetryOnboardingOutcome, } from "./telemetry.js";
17
19
  import { commandUpdate as runCliUpdate } from "./update.js";
18
20
  import { mkdir, writeFile } from "node:fs/promises";
19
21
  import path from "node:path";
@@ -54,13 +56,94 @@ const SERVICE_KEY_NAME_SCHEMA = z
54
56
  .min(1)
55
57
  .max(100)
56
58
  .refine((value) => !SERVICE_KEY_NAME_CONTROL_CHARACTER_PATTERN.test(value));
59
+ function getRedeemTelemetryOutcome(error) {
60
+ switch (getApiErrorCode(error)) {
61
+ case "INVALID_CODE_FORMAT":
62
+ case "CODE_NOT_FOUND":
63
+ return "invalid";
64
+ case "CODE_EXPIRED":
65
+ case "CODE_REVOKED":
66
+ case "CODE_PAUSED":
67
+ return "expired";
68
+ case "CODE_EXHAUSTED":
69
+ return "exhausted";
70
+ case "RATE_LIMITED":
71
+ return "rate_limited";
72
+ case "TRIAL_CAPACITY":
73
+ return "capacity";
74
+ case "ACTIVE_GUEST_SESSION":
75
+ return "active_guest_session";
76
+ default:
77
+ return error instanceof Error && /trial code (?:format is invalid|is required)/i.test(error.message)
78
+ ? "invalid"
79
+ : "error";
80
+ }
81
+ }
82
+ function getClaimTelemetryOutcome(error) {
83
+ switch (getApiErrorCode(error)) {
84
+ case "CLAIM_INVALID":
85
+ return "invalid";
86
+ case "CLAIM_EXPIRED":
87
+ return "claim_expired";
88
+ case "ALREADY_CLAIMED":
89
+ return "already_claimed";
90
+ case "SCAN_IN_PROGRESS":
91
+ return "scan_in_progress";
92
+ case "EMAIL_UNVERIFIED":
93
+ return "email_unverified";
94
+ case "FINGERPRINT_MISMATCH":
95
+ return "fingerprint_mismatch";
96
+ default:
97
+ return "error";
98
+ }
99
+ }
100
+ async function getCommandAuthState(client, me) {
101
+ let credentials = null;
102
+ try {
103
+ credentials = await client.getCredentials();
104
+ }
105
+ catch {
106
+ // Local metadata is only for UX and telemetry; it must not break scan commands.
107
+ }
108
+ const guest = credentials?.guest === true || me.user.role?.trim().toLowerCase() === "guest";
109
+ return {
110
+ authKind: guest ? "guest" : "user",
111
+ // Ordinary device login intentionally keeps an unclaimed trial proof. Keep
112
+ // reminding the user after that auth-kind transition until claim consumes it.
113
+ guestCredentials: credentials &&
114
+ (credentials.guest === true || isClaimProofAvailable(credentials))
115
+ ? credentials
116
+ : null,
117
+ };
118
+ }
119
+ function logGuestScanFooter(authState, flags) {
120
+ if (isJsonMode(flags) || !authState.guestCredentials)
121
+ return;
122
+ const claimState = getClaimProofState(authState.guestCredentials);
123
+ if (claimState === "expired") {
124
+ logLine("Guest trial claim window expired — run 'apex login' to continue with a Cantina account.", flags);
125
+ return;
126
+ }
127
+ const deadlineTime = parseClaimDeadlineMs(authState.guestCredentials.claimExpiresAt);
128
+ const deadline = deadlineTime !== null
129
+ ? new Date(deadlineTime).toISOString()
130
+ : "the claim deadline";
131
+ if (claimState === "absent") {
132
+ logLine(`Guest trial claim proof is missing — run 'apex redeem <code>' again on this machine before ${deadline}, then run 'apex claim'.`, flags);
133
+ return;
134
+ }
135
+ const message = authState.authKind === "guest"
136
+ ? `Guest scan — create a Cantina account and run 'apex claim' by ${deadline} to keep it.`
137
+ : `Trial results are still unclaimed — run 'apex claim' by ${deadline} to keep them.`;
138
+ logLine(message, flags);
139
+ }
140
+ export function getRedeemExitCode(result) {
141
+ return result.redeemed ? 0 : 2;
142
+ }
57
143
  async function saveCompanyDefault(companyId) {
58
144
  const config = await loadConfig();
59
145
  if (config.defaultCompanyId !== companyId) {
60
- await saveConfig({
61
- ...config,
62
- defaultCompanyId: companyId,
63
- });
146
+ await updateConfig({ defaultCompanyId: companyId });
64
147
  }
65
148
  }
66
149
  function formatCommandArgument(value) {
@@ -564,7 +647,13 @@ export async function initializeInteractiveSession(client, cwd, flags) {
564
647
  });
565
648
  }
566
649
  export async function commandLogin(client, flags) {
567
- const me = await ensureAuthenticated(client, flags);
650
+ // Explicit login is the escape hatch from an unavailable guest session. It
651
+ // intentionally bypasses ordinary-command claim steering while preserving
652
+ // the proof for an authoritative `apex claim` attempt.
653
+ const me = await login(client, {
654
+ noOpen: flags["no-open"] === true,
655
+ quiet: isJsonMode(flags),
656
+ });
568
657
  if (isJsonMode(flags)) {
569
658
  printJson(me);
570
659
  return me;
@@ -572,6 +661,75 @@ export async function commandLogin(client, flags) {
572
661
  logLine(`Authenticated as ${me.user.username ?? me.user.id}`, flags);
573
662
  return me;
574
663
  }
664
+ export async function commandRedeem(client, flags, code) {
665
+ try {
666
+ if (flags.replace === true) {
667
+ const warning = "Warning: --replace revokes the current guest trial and queues its unclaimed source, scans, and findings for deletion before redeeming the new code.";
668
+ if (isJsonMode(flags)) {
669
+ process.stderr.write(`${warning}\n`);
670
+ }
671
+ else {
672
+ logLine(warning, flags);
673
+ }
674
+ }
675
+ const result = await redeemOnboardingCode(client, {
676
+ code,
677
+ replace: flags.replace === true,
678
+ });
679
+ setTelemetryOnboardingOutcome(result.outcome);
680
+ if (isJsonMode(flags)) {
681
+ printJson(result);
682
+ return result;
683
+ }
684
+ if (!result.redeemed) {
685
+ logLine(`Trial redemption skipped: ${result.warning}.`, flags);
686
+ return result;
687
+ }
688
+ logLine(`Trial ${result.outcome}: ${result.grant.scans} standard scan${result.grant.scans === 1 ? "" : "s"}.`, flags);
689
+ logLine(`Guest session expires: ${result.guestSessionExpiresAt}`, flags);
690
+ logLine(`Claim deadline: ${result.claimDeadline}`, flags);
691
+ logLine(result.notice, flags);
692
+ logLine(`Terms and retention: ${result.termsUrl}`, flags);
693
+ return result;
694
+ }
695
+ catch (error) {
696
+ setTelemetryOnboardingOutcome(getRedeemTelemetryOutcome(error));
697
+ throw error;
698
+ }
699
+ }
700
+ export async function commandClaim(client, cwd, flags) {
701
+ const json = isJsonMode(flags);
702
+ try {
703
+ const result = await claimOnboardingSession(client, cwd, {
704
+ noOpen: json || flags["no-open"] === true,
705
+ quiet: json,
706
+ ...(json
707
+ ? {
708
+ onVerification: async (login) => {
709
+ process.stderr.write(`Complete Cantina account setup with code ${escapeTerminalControlCharacters(login.userCode)} at ${escapeTerminalControlCharacters(login.verificationUrl)}\n`);
710
+ },
711
+ }
712
+ : {}),
713
+ });
714
+ setTelemetryOnboardingOutcome("claimed");
715
+ if (json) {
716
+ printJson(result);
717
+ return result;
718
+ }
719
+ logLine(`Claimed into: ${result.company.name ?? result.company.handle ?? result.company.id}`, flags);
720
+ if (result.scanUrl) {
721
+ logLine(`View: ${result.scanUrl}`, flags);
722
+ }
723
+ if (result.discountCode) {
724
+ logLine(`First credit-pack discount: ${result.discountCode}`, flags);
725
+ }
726
+ return result;
727
+ }
728
+ catch (error) {
729
+ setTelemetryOnboardingOutcome(getClaimTelemetryOutcome(error));
730
+ throw error;
731
+ }
732
+ }
575
733
  export async function commandCredits(client, cwd, flags) {
576
734
  const me = await ensureAuthenticated(client, flags);
577
735
  const binding = await loadWorkspaceBinding(cwd);
@@ -607,10 +765,49 @@ export async function commandCredits(client, cwd, flags) {
607
765
  return payload;
608
766
  }
609
767
  export async function commandLogout(client, flags) {
610
- await logout(client);
768
+ const credentials = await client.getCredentials().catch(() => null);
769
+ const proofOnly = isProofOnlyOnboardingCredentials(credentials);
770
+ const hasGuestLifecycle = credentials?.guest === true || proofOnly;
771
+ if (hasGuestLifecycle && flags.force !== true) {
772
+ if (!canPromptForConfirmation(flags)) {
773
+ throw new Error("Logging out revokes this guest trial and queues its unclaimed data for deletion. Re-run with --force to confirm.");
774
+ }
775
+ const approved = await confirm("Logging out revokes this guest trial and queues all unclaimed source, scans, and findings for deletion. Continue?", false);
776
+ if (!approved) {
777
+ logLine("Guest logout cancelled; the trial and claim proof were kept.", flags);
778
+ return {
779
+ loggedOut: false,
780
+ guestTrialRevoked: false,
781
+ confirmationRequired: true,
782
+ };
783
+ }
784
+ }
785
+ const guestTrialRevoked = hasGuestLifecycle
786
+ ? await revokeGuestOnboardingSession(client, {
787
+ clearLocalOnTerminal: true,
788
+ expectedCredentials: credentials ?? undefined,
789
+ })
790
+ : false;
791
+ if (!hasGuestLifecycle) {
792
+ await logout(client, credentials);
793
+ }
794
+ const serverRevocationUnavailable = hasGuestLifecycle && !guestTrialRevoked;
611
795
  if (!isJsonMode(flags)) {
612
- logLine("Logged out", flags);
796
+ logLine(guestTrialRevoked
797
+ ? "Guest trial revoked; unclaimed trial data is queued for deletion."
798
+ : serverRevocationUnavailable
799
+ ? proofOnly
800
+ ? "Guest trial proof cleared locally, but server revocation could not be confirmed."
801
+ : "Guest access cleared locally, but the terminal server session could not be revoked."
802
+ : "Logged out", flags);
613
803
  }
804
+ return {
805
+ loggedOut: true,
806
+ guestTrialRevoked,
807
+ ...(serverRevocationUnavailable
808
+ ? { serverRevocationUnavailable: true }
809
+ : {}),
810
+ };
614
811
  }
615
812
  export async function commandServiceKeyCreate(client, flags) {
616
813
  const name = normalizeServiceKeyName(getFlagString(flags, "name"));
@@ -774,7 +971,7 @@ export async function commandDoctor(client, cwd, flags) {
774
971
  if (isJsonMode(flags)) {
775
972
  const payload = {
776
973
  ok: false,
777
- error: error instanceof Error ? error.message : String(error),
974
+ error: formatApiError(error),
778
975
  };
779
976
  printJson(payload);
780
977
  if (error instanceof Error) {
@@ -786,7 +983,9 @@ export async function commandDoctor(client, cwd, flags) {
786
983
  }
787
984
  }
788
985
  export async function commandStatus(client, cwd, flags) {
789
- await ensureAuthenticated(client, flags);
986
+ const me = await ensureAuthenticated(client, flags);
987
+ const authState = await getCommandAuthState(client, me);
988
+ setTelemetryAuthKind(authState.authKind);
790
989
  const binding = await requireWorkspaceBinding(cwd);
791
990
  const requestedScanId = getFlagString(flags, "scan");
792
991
  const scanId = requestedScanId ?? binding.lastScanId;
@@ -807,6 +1006,7 @@ export async function commandStatus(client, cwd, flags) {
807
1006
  if (binding.lastScanUrl && (!requestedScanId || requestedScanId === binding.lastScanId)) {
808
1007
  logLine(`View: ${binding.lastScanUrl}`, flags);
809
1008
  }
1009
+ logGuestScanFooter(authState, flags);
810
1010
  return { binding, scanId, progress };
811
1011
  }
812
1012
  export async function commandConnect(client, cwd, flags, provider) {
@@ -838,6 +1038,8 @@ export async function commandScan(client, cwd, flags) {
838
1038
  throw new Error("PR scans require at least one --pr <number> selection.");
839
1039
  }
840
1040
  const me = await ensureAuthenticated(client, flags);
1041
+ const authState = await getCommandAuthState(client, me);
1042
+ setTelemetryAuthKind(authState.authKind);
841
1043
  const selection = await selectWorkspaceTarget(cwd, me, flags);
842
1044
  const result = await withLoadingIndicator("Resolving workspace and scan plan...", flags, () => resolveWorkspaceSelection(client, selection));
843
1045
  if (!result.resolve.workspaceId) {
@@ -920,14 +1122,19 @@ export async function commandScan(client, cwd, flags) {
920
1122
  });
921
1123
  });
922
1124
  }
1125
+ const trackedScanId = getTrackedScanId(scan);
923
1126
  const binding = {
924
1127
  ...createWorkspaceBinding(result, result.binding),
925
1128
  workspaceId,
926
- lastScanId: getTrackedScanId(scan),
1129
+ lastScanId: trackedScanId,
927
1130
  lastScanUrl: scan.scanUrl ?? null,
928
1131
  };
929
- await saveWorkspaceBinding(cwd, binding);
930
- await saveCompanyDefault(result.company.id);
1132
+ const [workspaceBindingWrite, companyDefaultWrite] = await Promise.allSettled([
1133
+ Promise.resolve().then(() => saveWorkspaceBinding(cwd, binding)),
1134
+ Promise.resolve().then(() => saveCompanyDefault(result.company.id)),
1135
+ ]);
1136
+ const workspaceBindingSaved = workspaceBindingWrite.status === "fulfilled";
1137
+ const companyDefaultSaved = companyDefaultWrite.status === "fulfilled";
931
1138
  const summarySources = prScanSource
932
1139
  ? getPrScanSelectedSources(result.sources, prScanSource)
933
1140
  : result.sources;
@@ -946,6 +1153,14 @@ export async function commandScan(client, cwd, flags) {
946
1153
  apexScanId: scan.apexScanId ?? scan.bedrockScanId ?? null,
947
1154
  },
948
1155
  binding,
1156
+ ...(!workspaceBindingSaved || !companyDefaultSaved
1157
+ ? {
1158
+ localPersistence: {
1159
+ workspaceBindingSaved,
1160
+ companyDefaultSaved,
1161
+ },
1162
+ }
1163
+ : {}),
949
1164
  };
950
1165
  if (isJsonMode(flags)) {
951
1166
  printJson(payload);
@@ -954,16 +1169,31 @@ export async function commandScan(client, cwd, flags) {
954
1169
  logLine(`Authenticated as ${me.user.username ?? me.user.id}`, flags);
955
1170
  logLine(`Company: ${result.company.handle ?? result.company.id}`, flags);
956
1171
  logLine(`Workspace: ${result.workspaceName}`, flags);
1172
+ if (!workspaceBindingSaved) {
1173
+ logLine(`Warning: the scan started, but Apex could not save this directory's workspace binding. Keep scan ID ${trackedScanId ?? "from the result"}; repair local repository permissions and bind this directory again before using status or findings commands here.`, flags);
1174
+ }
1175
+ if (!companyDefaultSaved) {
1176
+ logLine("Warning: the scan started, but Apex could not save the default company. Pass --company explicitly until local Apex config permissions are repaired.", flags);
1177
+ }
957
1178
  logLine(`Sources selected: ${summarySources.length}`, flags);
958
1179
  logLine(`Plan: ${summaryPlannedSources.length - localSnapshotCount} remote, ${localSnapshotCount} local snapshot`, flags);
959
- logLine(`Scan: ${scan.status}${getTrackedScanId(scan) ? ` (${getTrackedScanId(scan)})` : ""}`, flags);
1180
+ logLine(`Scan: ${scan.status}${trackedScanId ? ` (${trackedScanId})` : ""}`, flags);
960
1181
  if (scan.scanUrl) {
961
1182
  logLine(`View: ${scan.scanUrl}`, flags);
962
1183
  }
963
1184
  if (scan.fullScanRecommendation) {
964
1185
  logLine(`Recommendation: ${scan.fullScanRecommendation}`, flags);
965
1186
  }
966
- logLine("Next: run `apex status` to watch progress, then `apex findings` when the scan finishes.", flags);
1187
+ if (workspaceBindingSaved) {
1188
+ logLine("Next: run `apex status` to watch progress, then `apex findings` when the scan finishes.", flags);
1189
+ }
1190
+ else if (trackedScanId) {
1191
+ logLine(`Next: repair local repository permissions, run \`apex workspace use ${workspaceId}\`, then use \`apex status --scan ${trackedScanId}\` and \`apex findings --scan ${trackedScanId}\`.`, flags);
1192
+ }
1193
+ else {
1194
+ logLine(`Next: repair local repository permissions, then run \`apex workspace use ${workspaceId}\` before using status or findings.`, flags);
1195
+ }
1196
+ logGuestScanFooter(authState, flags);
967
1197
  return payload;
968
1198
  }
969
1199
  export async function commandWorkspaces(client, cwd, flags) {
@@ -1161,7 +1391,9 @@ export async function commandCancelScan(client, cwd, flags, requestedScanId) {
1161
1391
  return payload;
1162
1392
  }
1163
1393
  export async function commandFindings(client, cwd, flags) {
1164
- await ensureAuthenticated(client, flags);
1394
+ const me = await ensureAuthenticated(client, flags);
1395
+ const authState = await getCommandAuthState(client, me);
1396
+ setTelemetryAuthKind(authState.authKind);
1165
1397
  const binding = await requireWorkspaceBinding(cwd);
1166
1398
  const { scan, response } = await withLoadingIndicator("Loading findings...", flags, async () => {
1167
1399
  const scan = await resolveScanSelection({
@@ -1190,6 +1422,7 @@ export async function commandFindings(client, cwd, flags) {
1190
1422
  logLine(`Scan: ${response.scan.displayName} (${response.scan.status})`, flags);
1191
1423
  if (response.findings.length === 0) {
1192
1424
  logLine("Findings: none for this scan.", flags);
1425
+ logGuestScanFooter(authState, flags);
1193
1426
  return payload;
1194
1427
  }
1195
1428
  for (const finding of response.findings) {
@@ -1199,6 +1432,7 @@ export async function commandFindings(client, cwd, flags) {
1199
1432
  logLine(`View: ${finding.findingUrl}`, flags);
1200
1433
  }
1201
1434
  }
1435
+ logGuestScanFooter(authState, flags);
1202
1436
  return payload;
1203
1437
  }
1204
1438
  export async function commandFindingComment(client, cwd, flags, requestedFindingRef, content, parentCommentId) {