@cantinasecurity/apex-cli 0.1.16 → 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) {
@@ -184,6 +267,28 @@ function formatFixReviewScanBalance(scanBalance) {
184
267
  }
185
268
  return `Fix review scans: ${detailParts.join(", ")}`;
186
269
  }
270
+ function formatLiteScanBalance(scanBalance) {
271
+ const purchased = getOptionalCount(scanBalance.litePurchased);
272
+ const used = getOptionalCount(scanBalance.liteUsed);
273
+ const remaining = getOptionalCount(scanBalance.liteRemaining);
274
+ const available = remaining ??
275
+ (purchased !== null && used !== null ? Math.max(0, purchased - used) : null);
276
+ if (purchased === null && used === null && available === null) {
277
+ return null;
278
+ }
279
+ const detailParts = [];
280
+ if (purchased !== null) {
281
+ detailParts.push(`${purchased} purchased`);
282
+ }
283
+ if (used !== null) {
284
+ detailParts.push(`${used} used`);
285
+ }
286
+ if (available !== null) {
287
+ const detailSuffix = detailParts.length ? ` (${detailParts.join(", ")})` : "";
288
+ return `Lite scan credits: ${available} available${detailSuffix}`;
289
+ }
290
+ return `Lite scan credits: ${detailParts.join(", ")}`;
291
+ }
187
292
  function normalizeRequestedScanMode(value) {
188
293
  if (value === "ultra" || value === "audit") {
189
294
  return "audit";
@@ -191,6 +296,9 @@ function normalizeRequestedScanMode(value) {
191
296
  if (value === "pr") {
192
297
  return "pr";
193
298
  }
299
+ if (value === "lite") {
300
+ return "lite";
301
+ }
194
302
  return "standard";
195
303
  }
196
304
  function getFlagValues(flags, key) {
@@ -539,7 +647,13 @@ export async function initializeInteractiveSession(client, cwd, flags) {
539
647
  });
540
648
  }
541
649
  export async function commandLogin(client, flags) {
542
- 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
+ });
543
657
  if (isJsonMode(flags)) {
544
658
  printJson(me);
545
659
  return me;
@@ -547,6 +661,75 @@ export async function commandLogin(client, flags) {
547
661
  logLine(`Authenticated as ${me.user.username ?? me.user.id}`, flags);
548
662
  return me;
549
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
+ }
550
733
  export async function commandCredits(client, cwd, flags) {
551
734
  const me = await ensureAuthenticated(client, flags);
552
735
  const binding = await loadWorkspaceBinding(cwd);
@@ -574,14 +757,57 @@ export async function commandCredits(client, cwd, flags) {
574
757
  if (redeemableFixReviewScans && redeemableFixReviewScans > 0) {
575
758
  logLine(`Redeemable fix review scans from standard credits: ${redeemableFixReviewScans}`, flags);
576
759
  }
760
+ const liteBalance = formatLiteScanBalance(payload.scanBalance);
761
+ if (liteBalance) {
762
+ logLine(liteBalance, flags);
763
+ }
577
764
  logLine(`Scans enabled: ${payload.scansEnabled ? "yes" : "no"}`, flags);
578
765
  return payload;
579
766
  }
580
767
  export async function commandLogout(client, flags) {
581
- 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;
582
795
  if (!isJsonMode(flags)) {
583
- 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);
584
803
  }
804
+ return {
805
+ loggedOut: true,
806
+ guestTrialRevoked,
807
+ ...(serverRevocationUnavailable
808
+ ? { serverRevocationUnavailable: true }
809
+ : {}),
810
+ };
585
811
  }
586
812
  export async function commandServiceKeyCreate(client, flags) {
587
813
  const name = normalizeServiceKeyName(getFlagString(flags, "name"));
@@ -745,7 +971,7 @@ export async function commandDoctor(client, cwd, flags) {
745
971
  if (isJsonMode(flags)) {
746
972
  const payload = {
747
973
  ok: false,
748
- error: error instanceof Error ? error.message : String(error),
974
+ error: formatApiError(error),
749
975
  };
750
976
  printJson(payload);
751
977
  if (error instanceof Error) {
@@ -757,7 +983,9 @@ export async function commandDoctor(client, cwd, flags) {
757
983
  }
758
984
  }
759
985
  export async function commandStatus(client, cwd, flags) {
760
- await ensureAuthenticated(client, flags);
986
+ const me = await ensureAuthenticated(client, flags);
987
+ const authState = await getCommandAuthState(client, me);
988
+ setTelemetryAuthKind(authState.authKind);
761
989
  const binding = await requireWorkspaceBinding(cwd);
762
990
  const requestedScanId = getFlagString(flags, "scan");
763
991
  const scanId = requestedScanId ?? binding.lastScanId;
@@ -778,6 +1006,7 @@ export async function commandStatus(client, cwd, flags) {
778
1006
  if (binding.lastScanUrl && (!requestedScanId || requestedScanId === binding.lastScanId)) {
779
1007
  logLine(`View: ${binding.lastScanUrl}`, flags);
780
1008
  }
1009
+ logGuestScanFooter(authState, flags);
781
1010
  return { binding, scanId, progress };
782
1011
  }
783
1012
  export async function commandConnect(client, cwd, flags, provider) {
@@ -809,6 +1038,8 @@ export async function commandScan(client, cwd, flags) {
809
1038
  throw new Error("PR scans require at least one --pr <number> selection.");
810
1039
  }
811
1040
  const me = await ensureAuthenticated(client, flags);
1041
+ const authState = await getCommandAuthState(client, me);
1042
+ setTelemetryAuthKind(authState.authKind);
812
1043
  const selection = await selectWorkspaceTarget(cwd, me, flags);
813
1044
  const result = await withLoadingIndicator("Resolving workspace and scan plan...", flags, () => resolveWorkspaceSelection(client, selection));
814
1045
  if (!result.resolve.workspaceId) {
@@ -855,16 +1086,17 @@ export async function commandScan(client, cwd, flags) {
855
1086
  },
856
1087
  }));
857
1088
  }
858
- else if (requestedMode === "audit") {
1089
+ else if (requestedMode === "audit" || requestedMode === "lite") {
1090
+ const modeLabel = requestedMode === "audit" ? "Audit" : "Lite";
859
1091
  if (!supportsLegacyRemoteFlow(result.resolve.plannedSources)) {
860
- throw new Error("Audit scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.");
1092
+ throw new Error(`${modeLabel} scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.`);
861
1093
  }
862
- const legacyResolve = await withLoadingIndicator("Preparing audit scan...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
1094
+ const legacyResolve = await withLoadingIndicator(`Preparing ${modeLabel.toLowerCase()} scan...`, flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
863
1095
  if (!legacyResolve.workspaceId) {
864
1096
  throw new Error("Workspace resolution did not return a workspaceId.");
865
1097
  }
866
1098
  workspaceId = legacyResolve.workspaceId;
867
- scan = await withLoadingIndicator("Starting audit scan...", flags, () => client.request(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
1099
+ scan = await withLoadingIndicator(`Starting ${modeLabel.toLowerCase()} scan...`, flags, () => client.request(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
868
1100
  method: "POST",
869
1101
  json: {
870
1102
  mode: requestedMode,
@@ -890,14 +1122,19 @@ export async function commandScan(client, cwd, flags) {
890
1122
  });
891
1123
  });
892
1124
  }
1125
+ const trackedScanId = getTrackedScanId(scan);
893
1126
  const binding = {
894
1127
  ...createWorkspaceBinding(result, result.binding),
895
1128
  workspaceId,
896
- lastScanId: getTrackedScanId(scan),
1129
+ lastScanId: trackedScanId,
897
1130
  lastScanUrl: scan.scanUrl ?? null,
898
1131
  };
899
- await saveWorkspaceBinding(cwd, binding);
900
- 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";
901
1138
  const summarySources = prScanSource
902
1139
  ? getPrScanSelectedSources(result.sources, prScanSource)
903
1140
  : result.sources;
@@ -916,6 +1153,14 @@ export async function commandScan(client, cwd, flags) {
916
1153
  apexScanId: scan.apexScanId ?? scan.bedrockScanId ?? null,
917
1154
  },
918
1155
  binding,
1156
+ ...(!workspaceBindingSaved || !companyDefaultSaved
1157
+ ? {
1158
+ localPersistence: {
1159
+ workspaceBindingSaved,
1160
+ companyDefaultSaved,
1161
+ },
1162
+ }
1163
+ : {}),
919
1164
  };
920
1165
  if (isJsonMode(flags)) {
921
1166
  printJson(payload);
@@ -924,16 +1169,31 @@ export async function commandScan(client, cwd, flags) {
924
1169
  logLine(`Authenticated as ${me.user.username ?? me.user.id}`, flags);
925
1170
  logLine(`Company: ${result.company.handle ?? result.company.id}`, flags);
926
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
+ }
927
1178
  logLine(`Sources selected: ${summarySources.length}`, flags);
928
1179
  logLine(`Plan: ${summaryPlannedSources.length - localSnapshotCount} remote, ${localSnapshotCount} local snapshot`, flags);
929
- logLine(`Scan: ${scan.status}${getTrackedScanId(scan) ? ` (${getTrackedScanId(scan)})` : ""}`, flags);
1180
+ logLine(`Scan: ${scan.status}${trackedScanId ? ` (${trackedScanId})` : ""}`, flags);
930
1181
  if (scan.scanUrl) {
931
1182
  logLine(`View: ${scan.scanUrl}`, flags);
932
1183
  }
933
1184
  if (scan.fullScanRecommendation) {
934
1185
  logLine(`Recommendation: ${scan.fullScanRecommendation}`, flags);
935
1186
  }
936
- 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);
937
1197
  return payload;
938
1198
  }
939
1199
  export async function commandWorkspaces(client, cwd, flags) {
@@ -1131,7 +1391,9 @@ export async function commandCancelScan(client, cwd, flags, requestedScanId) {
1131
1391
  return payload;
1132
1392
  }
1133
1393
  export async function commandFindings(client, cwd, flags) {
1134
- await ensureAuthenticated(client, flags);
1394
+ const me = await ensureAuthenticated(client, flags);
1395
+ const authState = await getCommandAuthState(client, me);
1396
+ setTelemetryAuthKind(authState.authKind);
1135
1397
  const binding = await requireWorkspaceBinding(cwd);
1136
1398
  const { scan, response } = await withLoadingIndicator("Loading findings...", flags, async () => {
1137
1399
  const scan = await resolveScanSelection({
@@ -1160,6 +1422,7 @@ export async function commandFindings(client, cwd, flags) {
1160
1422
  logLine(`Scan: ${response.scan.displayName} (${response.scan.status})`, flags);
1161
1423
  if (response.findings.length === 0) {
1162
1424
  logLine("Findings: none for this scan.", flags);
1425
+ logGuestScanFooter(authState, flags);
1163
1426
  return payload;
1164
1427
  }
1165
1428
  for (const finding of response.findings) {
@@ -1169,6 +1432,7 @@ export async function commandFindings(client, cwd, flags) {
1169
1432
  logLine(`View: ${finding.findingUrl}`, flags);
1170
1433
  }
1171
1434
  }
1435
+ logGuestScanFooter(authState, flags);
1172
1436
  return payload;
1173
1437
  }
1174
1438
  export async function commandFindingComment(client, cwd, flags, requestedFindingRef, content, parentCommentId) {