@tokenoftrust/cli 1.4.0 → 1.5.0
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.
- package/README.md +5 -0
- package/bin/tot.mjs +148 -57
- package/package.json +6 -1
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +4 -4
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +3 -3
- package/src/commands/accept.mjs +498 -59
- package/src/commands/app/dev.mjs +8 -4
- package/src/commands/app/index.mjs +3 -3
- package/src/commands/app/scaffold.mjs +1 -1
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +264 -0
- package/src/commands/clone.mjs +307 -25
- package/src/commands/dev.mjs +440 -156
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/ideas.mjs +2 -2
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +5 -6
- package/src/commands/pr.mjs +62 -25
- package/src/commands/preview-build.mjs +6 -6
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview-retry-evidence.mjs +156 -0
- package/src/commands/preview.mjs +19 -3
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +51 -14
- package/src/commands/start.mjs +101 -59
- package/src/commands/submit.mjs +1183 -169
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +10 -4
- package/src/commands/whoami.mjs +1 -1
- package/src/dev-heartbeat.mjs +3 -2
- package/src/dev-logs.mjs +2 -2
- package/src/errors.mjs +11 -4
- package/src/git-credential.mjs +257 -0
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +83 -15
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +187 -15
- package/src/vendor/private-apps-devkit.mjs +3 -3
- package/src/viewer-session.mjs +118 -0
- package/template/private-app/README.md +12 -6
- package/src/commands/retire.mjs +0 -203
package/src/commands/doctor.mjs
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* auth identity is available, and Docker — plus reports the detected context
|
|
7
7
|
* so a developer knows which mode `tot` will use here.
|
|
8
8
|
*
|
|
9
|
-
* Docker is informational-only (not blocking):
|
|
9
|
+
* Docker is informational-only (not blocking): the native runtime is
|
|
10
10
|
* the default for `tot dev`/`tot start`, so a Docker that isn't running no
|
|
11
11
|
* longer stops the loop — it only matters if you pass --docker or the native
|
|
12
12
|
* artifact can't be fetched (see dev.mjs#NativeArtifactUnavailableError).
|
|
13
13
|
*
|
|
14
|
-
* `--fix`
|
|
14
|
+
* `--fix` auto-remediates what it safely can, each fix announced as it
|
|
15
15
|
* runs: starts Docker (macOS, opportunistically — harmless even though it's
|
|
16
16
|
* no longer required), creates ~/.tot if it's missing, and signs you in when
|
|
17
17
|
* there's no usable session at all. Anything it can't fix (or won't — a stale
|
|
@@ -135,9 +135,9 @@ export function collectChecks(_ctx, env = process.env) {
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* Auto-remediate the checks that are safely fixable without human judgment
|
|
138
|
-
*
|
|
138
|
+
* — announcing each one as it runs. A stale-but-refreshable sign-in isn't
|
|
139
139
|
* handled here because it isn't a failing check: the developer session refreshes
|
|
140
|
-
* it silently the next time it's actually used
|
|
140
|
+
* it silently the next time it's actually used.
|
|
141
141
|
*/
|
|
142
142
|
async function applyFixes(checks, env) {
|
|
143
143
|
const totDir = join(env.TOT_HOME || homedir(), ".tot");
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot git-credential` — git's own credential-helper protocol (see `git help
|
|
3
|
+
* gitcredentials`), implemented over the developer's cached `tot login`
|
|
4
|
+
* session. `tot
|
|
5
|
+
* clone` configures a fresh checkout's `credential.helper` to run this (see
|
|
6
|
+
* ../git-credential.mjs's CREDENTIAL_HELPER), so `git fetch`/`git push`/`git
|
|
7
|
+
* pull` — run DIRECTLY by the developer, not just through `tot preview` —
|
|
8
|
+
* transparently mint a fresh forge token instead of ever needing one
|
|
9
|
+
* persisted in `.git/config`. This is the fix for the "the panel-prescribed
|
|
10
|
+
* `git fetch origin` fails even after `tot login`" dead-end: `tot login`
|
|
11
|
+
* only ever refreshed the CLI's OWN MCP session, never the token baked into
|
|
12
|
+
* a checkout's remote URL at clone time.
|
|
13
|
+
*
|
|
14
|
+
* git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
|
|
15
|
+
* request on stdin (key=value lines, blank-line/EOF terminated). Only `get`
|
|
16
|
+
* does real work — this CLI never persists a forge credential of its own
|
|
17
|
+
* beyond the short-lived cache in ../git-credential.mjs, so `store`/`erase`
|
|
18
|
+
* are no-ops (git calls them after a successful/failed auth respectively; we
|
|
19
|
+
* just drain stdin and exit 0, the correct behavior for a stateless helper).
|
|
20
|
+
*
|
|
21
|
+
* FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
|
|
22
|
+
* problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
|
|
23
|
+
* checkout) — git then falls through to its next configured helper or its
|
|
24
|
+
* own prompt, exactly as if this helper weren't configured. A stack trace or
|
|
25
|
+
* a malformed credential line here would otherwise corrupt EVERY git
|
|
26
|
+
* operation in the checkout. It also NEVER triggers an interactive sign-in —
|
|
27
|
+
* this runs as a non-interactive subprocess of `git`, so a missing session
|
|
28
|
+
* fails through rather than trying to open a browser mid-`git fetch`.
|
|
29
|
+
*
|
|
30
|
+
* Dependency-free (global fetch + `git`, via the same MCP client + auth
|
|
31
|
+
* module every other command uses).
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
36
|
+
import { establishSession } from "../auth.mjs";
|
|
37
|
+
import { checkoutTenant } from "./clone.mjs";
|
|
38
|
+
import { detectContext } from "../context.mjs";
|
|
39
|
+
import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
|
|
40
|
+
import {
|
|
41
|
+
parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
|
|
42
|
+
credentialCachePath, readCachedCredential, writeCachedCredential,
|
|
43
|
+
} from "../git-credential.mjs";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
46
|
+
|
|
47
|
+
/** Does `remoteUrl`'s host equal `host` (case-insensitive)? An unparseable or
|
|
48
|
+
* missing remote URL never matches — fail closed. Exported for testing. */
|
|
49
|
+
export function hostMatches(remoteUrl, host) {
|
|
50
|
+
if (!remoteUrl || !host) return false;
|
|
51
|
+
try {
|
|
52
|
+
// scp-like remotes (git@host:owner/repo.git) have no scheme — synthesize one so URL can parse the host.
|
|
53
|
+
const normalized = /^[^/]+@[^/:]+:/.test(remoteUrl) ? `ssh://${remoteUrl.replace(":", "/")}` : remoteUrl;
|
|
54
|
+
return new URL(normalized).host.toLowerCase() === host.toLowerCase();
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the credential request git writes to stdin — blocking is correct
|
|
61
|
+
* here: git writes the request then closes its end, so this returns as
|
|
62
|
+
* soon as it's fully sent. Never blocks on an interactive terminal (git
|
|
63
|
+
* NEVER attaches a TTY to a credential helper's stdin — only a human
|
|
64
|
+
* poking at this command directly by hand would) and never throws (an
|
|
65
|
+
* unreadable/absent stdin is treated as an empty request). Exported so a
|
|
66
|
+
* test can inject a canned request instead of touching real fd 0. */
|
|
67
|
+
export function readStdin() {
|
|
68
|
+
if (process.stdin.isTTY) return "";
|
|
69
|
+
try {
|
|
70
|
+
return readFileSync(0, "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint (or reuse a cached) forge credential for the tenant the CURRENT
|
|
78
|
+
* directory checks out — the same `tenant_checkout` MCP call `tot clone`
|
|
79
|
+
* itself uses, so the credential is exactly as push-capable. Returns null on
|
|
80
|
+
* ANY failure (wrong dir, not signed in, MCP unreachable) rather than
|
|
81
|
+
* throwing — `get` treats null as "say nothing, exit non-zero".
|
|
82
|
+
*
|
|
83
|
+
* `createClient`/`establish`/`checkout` are injected (default to the real MCP
|
|
84
|
+
* client + auth + clone.mjs's checkoutTenant) purely so this is testable
|
|
85
|
+
* without a live MCP — same DI shape as chooseChangeId's injected `mint` /
|
|
86
|
+
* resolveDeveloperSession's injected `fetchImpl` elsewhere in this CLI.
|
|
87
|
+
* SCOPED TO THE CHECKOUT'S OWN REMOTE: `expectedHost` (git's requested host,
|
|
88
|
+
* from the credential-helper request) is checked against the host of this
|
|
89
|
+
* checkout's `origin` remote before a credential is ever minted or returned
|
|
90
|
+
* — a `get` for any OTHER host returns null (silent fail), never handing the
|
|
91
|
+
* tenant's forge token to a host this checkout doesn't itself push to. This
|
|
92
|
+
* matters because `credential.helper` is invoked per-URL by git, and a
|
|
93
|
+
* globally-scoped helper (or a checkout with a submodule / unrelated remote)
|
|
94
|
+
* must not become a way to exfiltrate the token to an arbitrary host.
|
|
95
|
+
* @param {{
|
|
96
|
+
* env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string|null,
|
|
97
|
+
* createClient?: typeof createMcpClient,
|
|
98
|
+
* establish?: typeof establishSession,
|
|
99
|
+
* checkout?: typeof checkoutTenant,
|
|
100
|
+
* }} [opts]
|
|
101
|
+
* @returns {Promise<{username:string,password:string}|null>}
|
|
102
|
+
*/
|
|
103
|
+
export async function mintOrCacheCredential({
|
|
104
|
+
env = process.env, cwd = process.cwd(), expectedHost = null,
|
|
105
|
+
createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const ctx = detectContext(cwd);
|
|
108
|
+
if (ctx.mode !== "checkout" || !ctx.tenant) return null;
|
|
109
|
+
|
|
110
|
+
const git = (cargs) =>
|
|
111
|
+
execFileSync("git", ["-C", ctx.workspacePath, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
112
|
+
let originUrl = null;
|
|
113
|
+
let repoName = null;
|
|
114
|
+
try {
|
|
115
|
+
originUrl = git(["remote", "get-url", "origin"]).trim();
|
|
116
|
+
repoName = repoNameFromRemote(originUrl);
|
|
117
|
+
} catch {
|
|
118
|
+
/* fall through — tagFromRepoName degrades to "main" on a null repo name */
|
|
119
|
+
}
|
|
120
|
+
if (expectedHost && !hostMatches(originUrl, expectedHost)) return null;
|
|
121
|
+
const tag = tagFromRepoName(repoName, ctx.tenant);
|
|
122
|
+
|
|
123
|
+
const cachePath = credentialCachePath(ctx.tenant, tag, env);
|
|
124
|
+
const cached = readCachedCredential(cachePath);
|
|
125
|
+
if (cached) return { username: cached.username, password: cached.password };
|
|
126
|
+
|
|
127
|
+
const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
128
|
+
const client = createClient(baseUrl);
|
|
129
|
+
try {
|
|
130
|
+
await establish(client, { env });
|
|
131
|
+
} catch {
|
|
132
|
+
return null; // not signed in (or session unrefreshable) — nothing this helper can do
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const res = await checkout(client, { tenant: ctx.tenant, tag, cloneDir: null });
|
|
136
|
+
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
137
|
+
if (!cred) return null;
|
|
138
|
+
const fresh = { username: cred.username, password: cred.token };
|
|
139
|
+
writeCachedCredential(cachePath, fresh);
|
|
140
|
+
return fresh;
|
|
141
|
+
} catch {
|
|
142
|
+
return null; // MCP unreachable / tenant_checkout refused — say nothing, exit non-zero
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
|
|
147
|
+
|
|
148
|
+
Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
|
|
149
|
+
in every checkout it creates — you should never need to run this by hand.
|
|
150
|
+
See \`git help gitcredentials\` for the protocol this implements.`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string[]} argv argv[0] is git's operation: get|store|erase
|
|
154
|
+
* @param {any} _ctx unused — this command derives its OWN context from cwd
|
|
155
|
+
* (mintOrCacheCredential), since git invokes it with the credentialed
|
|
156
|
+
* repo's directory as cwd, which may differ from wherever `tot` itself
|
|
157
|
+
* was dispatched from.
|
|
158
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, readStdin?: typeof readStdin }
|
|
159
|
+
* & Parameters<typeof mintOrCacheCredential>[0]} [opts]
|
|
160
|
+
*/
|
|
161
|
+
export async function run(argv, _ctx, opts = {}) {
|
|
162
|
+
const { env = process.env, readStdin: read = readStdin } = opts;
|
|
163
|
+
const op = argv[0];
|
|
164
|
+
if (!op || op === "--help" || op === "-h") {
|
|
165
|
+
console.log(USAGE);
|
|
166
|
+
return op ? 0 : 2;
|
|
167
|
+
}
|
|
168
|
+
// git always writes a request to stdin, even for store/erase — drain it either way
|
|
169
|
+
// so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
|
|
170
|
+
const request = parseCredentialInput(read());
|
|
171
|
+
|
|
172
|
+
if (op !== "get") return 0; // store/erase: stateless, nothing to persist or drop.
|
|
173
|
+
|
|
174
|
+
const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
|
|
175
|
+
if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
|
|
176
|
+
process.stdout.write(formatCredentialOutput({
|
|
177
|
+
protocol: request.protocol, host: request.host, username: cred.username, password: cred.password,
|
|
178
|
+
}));
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
package/src/commands/go-live.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `tot go-live` — cut the apex domain over to the storefront
|
|
2
|
+
* `tot go-live` — cut the apex domain over to the storefront. The CLI
|
|
3
3
|
* counterpart to the admin Publish tab's Domain "Connect" button: it drives the
|
|
4
4
|
* SAME go-live CI/dispatch the button does (the storefront `/api/domain/dispatch`
|
|
5
5
|
* → `www-domain` repository_dispatch → `cutover-dns.mjs`), gated by the SAME
|
|
6
6
|
* server-authoritative readiness gate the admin display reads
|
|
7
|
-
* (`GET /api/domain/readiness
|
|
7
|
+
* (`GET /api/domain/readiness`). This command CONSUMES those seams — it
|
|
8
8
|
* does NOT reimplement the DNS cutover, the readiness policy, or the revert.
|
|
9
9
|
*
|
|
10
10
|
* tot go-live show apex readiness, then (if ready) connect the apex
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* revert). Fail-closed: a CONNECT is refused unless the server reports
|
|
20
20
|
* `ready:true` — the CLI never recomputes readiness, it reads the same gate
|
|
21
21
|
* the cutover is governed by, so they cannot diverge.
|
|
22
|
-
* 2. Owner-only: the server resolves owner capability
|
|
22
|
+
* 2. Owner-only: the server resolves owner capability; a non-owner is
|
|
23
23
|
* DENIED with the itemized reason. The CLI does not assert ownership itself.
|
|
24
24
|
* 3. ALWAYS require ONE explicit [y/N] confirm (default NO). There is NO
|
|
25
25
|
* `--yes`/`--force`; in a NON-TTY (CI, piped) it REFUSES rather than
|
|
@@ -196,6 +196,9 @@ export function renderReadiness({ appDomain, readiness, action }) {
|
|
|
196
196
|
const lines = ["", ` Apex ${action === "rollback" ? "rollback" : "cutover"} readiness for ${appDomain ?? "this store"}:`];
|
|
197
197
|
for (const c of readiness.checks) {
|
|
198
198
|
lines.push(` ${c.ok ? "✓" : "✗"} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`);
|
|
199
|
+
// Blocked checks carry a "how to clear this" remedy from the server gate —
|
|
200
|
+
// the same guidance the admin panel shows, so the two surfaces never diverge.
|
|
201
|
+
if (!c.ok && c.remedy) lines.push(` → ${c.remedy}`);
|
|
199
202
|
}
|
|
200
203
|
if (action === "connect") {
|
|
201
204
|
lines.push(
|
|
@@ -217,6 +220,7 @@ export function renderReadiness({ appDomain, readiness, action }) {
|
|
|
217
220
|
* assuming it from the dispatch. Injectable delay/attempts. Returns the last
|
|
218
221
|
* normalised run status (may still be "dispatched" if CI is slow — reported honestly).
|
|
219
222
|
* @param {{ get:(path:string)=>Promise<any> }} http
|
|
223
|
+
* @param {{ attempts?: number, delayMs?: number, sleep?: Function }} [opts]
|
|
220
224
|
*/
|
|
221
225
|
export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, sleep } = {}) {
|
|
222
226
|
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -391,7 +395,7 @@ function cap(s) {
|
|
|
391
395
|
* ship floor. Throws on a non-2xx with the server's error message (so the caller
|
|
392
396
|
* surfaces the real refusal). `fetchImpl` is injectable for tests.
|
|
393
397
|
* @param {string} base
|
|
394
|
-
* @param {{ token
|
|
398
|
+
* @param {{ token?:string, owner?:string, capability?:string, fetchImpl?:typeof fetch }} [auth]
|
|
395
399
|
*/
|
|
396
400
|
export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
|
|
397
401
|
const root = base.replace(/\/+$/, "");
|
|
@@ -476,7 +480,7 @@ export async function run(argv, ctx) {
|
|
|
476
480
|
const http = createStorefrontHttp(base, { token, owner });
|
|
477
481
|
return await runGoLive(
|
|
478
482
|
http,
|
|
479
|
-
{ appDomain: owner, action: args.action, rehearsal: args.rehearsal, noOpen: args.noOpen },
|
|
483
|
+
{ appDomain: owner, action: /** @type {any} */ (args.action), rehearsal: args.rehearsal, noOpen: args.noOpen },
|
|
480
484
|
{ openUrl: (u) => openBrowser(u) },
|
|
481
485
|
);
|
|
482
486
|
}
|
package/src/commands/grants.mjs
CHANGED
|
@@ -61,9 +61,10 @@ function parseArgs(argv) {
|
|
|
61
61
|
* exp: number|null, revokedAt: string|null }> }}
|
|
62
62
|
*/
|
|
63
63
|
export function normalizeGrantRows(resp) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
64
|
+
const rr = /** @type {any} */ (resp);
|
|
65
|
+
if (resp && typeof resp === "object" && !Array.isArray(resp) && Array.isArray(rr.grants)) {
|
|
66
|
+
const activeTenant = typeof rr.activeTenant === "string" ? rr.activeTenant : null;
|
|
67
|
+
const rows = rr.grants
|
|
67
68
|
.map((g) => {
|
|
68
69
|
const tenant = g?.tenant ?? g?.id ?? null;
|
|
69
70
|
return {
|
|
@@ -83,7 +84,7 @@ export function normalizeGrantRows(resp) {
|
|
|
83
84
|
|
|
84
85
|
// Fallback: client_list — store ids + environment + the selected marker only. No
|
|
85
86
|
// grant detail is knowable here (grantActive/capability/tier/exp/revokedAt stay null).
|
|
86
|
-
const clients = Array.isArray(resp) ? resp :
|
|
87
|
+
const clients = Array.isArray(resp) ? resp : rr?.clients || rr?.tenants || [];
|
|
87
88
|
const rows = (Array.isArray(clients) ? clients : [])
|
|
88
89
|
.map((c) => ({
|
|
89
90
|
tenant: c?.tenant ?? c?.id ?? c?.clientId ?? c?.appDomain ?? null,
|
|
@@ -147,6 +148,7 @@ export function formatExpiry(exp, { now = Date.now() } = {}) {
|
|
|
147
148
|
export function describeGrantRow(row, { now = Date.now() } = {}) {
|
|
148
149
|
const status = grantStatus(row, { now });
|
|
149
150
|
if (status === "unknown") return "grant detail unavailable (server introspection not enabled)";
|
|
151
|
+
/** @type {string[]} */
|
|
150
152
|
const parts = [status];
|
|
151
153
|
if (row.capability) parts.push(`capability=${row.capability}`);
|
|
152
154
|
if (row.tier) parts.push(`tier=${row.tier}`);
|
|
@@ -238,7 +240,7 @@ export async function run(argv, _ctx) {
|
|
|
238
240
|
return 1;
|
|
239
241
|
}
|
|
240
242
|
if (rows.length === 0) {
|
|
241
|
-
// Status-aware, like whoami/checkout
|
|
243
|
+
// Status-aware, like whoami/checkout: an UNLINKED identity is told to
|
|
242
244
|
// link, not given the "may still be propagating" copy — that's only the genuine
|
|
243
245
|
// linked-but-zero-grants case.
|
|
244
246
|
const g = noStoresGuidance(resp);
|