@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.11
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 +12 -9
- package/bin/tot.mjs +51 -11
- package/package.json +2 -2
- package/src/candidate-state.mjs +137 -0
- package/src/commands/{checkout.mjs → clone.mjs} +129 -28
- package/src/commands/dev.mjs +110 -15
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/grants.mjs +8 -3
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +19 -12
- package/src/commands/pr.mjs +214 -0
- package/src/commands/preview.mjs +71 -0
- package/src/commands/ship.mjs +667 -0
- package/src/commands/start.mjs +34 -14
- package/src/commands/submit.mjs +458 -41
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/oauth.mjs +92 -42
- package/src/obstacle-beacon.cjs +1 -1
package/README.md
CHANGED
|
@@ -5,11 +5,12 @@ One command to go from a Token of Trust invite to a running store.
|
|
|
5
5
|
```sh
|
|
6
6
|
npm i -g @tokenoftrust/cli
|
|
7
7
|
|
|
8
|
-
tot
|
|
9
|
-
tot
|
|
8
|
+
tot clone # list the stores you can build on
|
|
9
|
+
tot clone <tenant> my-store # mirrors `git clone`; dir defaults to <tenant>
|
|
10
10
|
cd my-store
|
|
11
11
|
tot dev # run it locally with save→reload — no Docker needed
|
|
12
|
-
tot
|
|
12
|
+
tot preview # bundle your edits into a compliance-reviewed preview
|
|
13
|
+
tot ship # promote a reconciled preview live (diff + one confirm)
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
**Prerequisites: Node.js and an invite. Nothing else.** `tot dev` downloads the
|
|
@@ -22,10 +23,12 @@ be fetched.)
|
|
|
22
23
|
|
|
23
24
|
| Command | Status | What it does |
|
|
24
25
|
| --- | --- | --- |
|
|
25
|
-
| `tot
|
|
26
|
-
| `tot validate` | next | Lint your store before you
|
|
26
|
+
| `tot clone [<tenant>] [<dir>]` | **built** | Clone a store you're entitled to build on (mirrors `git clone`), with an authenticated remote configured. Dir defaults to `<tenant>`. No arg → list your stores. |
|
|
27
|
+
| `tot validate` | next | Lint your store before you preview. |
|
|
27
28
|
| `tot dev` | **built** | Run your store locally with save→reload — NATIVELY (no Docker; falls back to it with `--docker` or automatically if the native artifact isn't available). |
|
|
28
|
-
| `tot
|
|
29
|
+
| `tot preview` | **built** | Bundle your edits into a compliance-reviewed preview (validates, auto-commits the known content trees, pushes the preview ref, opens/updates a candidate PR, reports the reconcile/compliance verdict + preview URL). `tot submit` / `tot deploy` still work as teaching aliases for this same flow. |
|
|
30
|
+
| `tot ship` | **built** | Promote a reconciled preview live: always shows a diff-vs-live and asks for one `[y/N]` confirm (no `--yes`, refuses outside a terminal); records an approval request if you're not authorised to ship yourself. |
|
|
31
|
+
| `tot pr [list\|view\|close]` | **built** | See and manage the candidate PRs `tot preview` opens (`gh pr`-shaped). |
|
|
29
32
|
| `tot doctor` | built | Check this machine is ready and show which context `tot` detected. |
|
|
30
33
|
|
|
31
34
|
## Context-aware
|
|
@@ -34,7 +37,7 @@ The same `tot` does the right thing wherever you run it (walks up like `git`):
|
|
|
34
37
|
|
|
35
38
|
- **storefront monorepo** — a full platform checkout;
|
|
36
39
|
- **tenant checkout** — a standalone `content/ public/ theme.json .tot/config.json` clone (tenant read from `.tot/config.json`);
|
|
37
|
-
- **loose** — anywhere else; `tot
|
|
40
|
+
- **loose** — anywhere else; `tot clone <tenant>` gets you a checkout.
|
|
38
41
|
|
|
39
42
|
`tot doctor` prints the detected context.
|
|
40
43
|
|
|
@@ -42,8 +45,8 @@ The same `tot` does the right thing wherever you run it (walks up like `git`):
|
|
|
42
45
|
|
|
43
46
|
`tot` talks to the Token of Trust MCP (default `https://mcp.tokenoftrust.com`, override with `--mcp` or `MCP_BASE_URL`). It is **single-plane**: the only identity is **you**, signed in against the MCP over OAuth.
|
|
44
47
|
|
|
45
|
-
- Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached at `~/.tot/credentials.json` and refreshed silently. Every later command (`tot
|
|
46
|
-
- Not signed in? On a terminal, `tot start` / `tot
|
|
48
|
+
- Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached at `~/.tot/credentials.json` and refreshed silently. Every later command (`tot clone`, `tot start`, `tot preview`, `tot ship`, …) runs as you, with no re-auth. Entitlement is derived server-side from your ToT memberships.
|
|
49
|
+
- Not signed in? On a terminal, `tot start` / `tot clone` **offer to sign you in right there** and continue in-flow — no "run `tot login`, then re-run".
|
|
47
50
|
- The old operator env-triple (`TOT_API_KEY` / `TOT_SECRET_KEY` / `TOT_APP_DOMAIN`) **no longer signs the CLI in** — tot-mcp went OAuth-first on 2026-07-23. If those vars are set, `tot` prints a one-line advisory and uses your `tot login` session anyway; it never reads them for auth.
|
|
48
51
|
|
|
49
52
|
### Optional: multiple identities at once (`TOT_PROFILE`)
|
package/bin/tot.mjs
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* tot login sign in to Token of Trust (OAuth) ← built (MCP OAuth PKCE loopback; caches ~/.tot/credentials.json)
|
|
9
9
|
* tot logout sign out (clear the cached session) ← built (deletes ~/.tot/credentials.json; local-only, no server revoke)
|
|
10
10
|
* tot whoami who you're signed in as ← built
|
|
11
|
+
* tot link link your identity to the ToT broker (resolve store scope) ← built
|
|
11
12
|
* tot grants capability/tier/expiry per store you can act on ← built (introspection diagnostics)
|
|
12
|
-
* tot
|
|
13
|
+
* tot clone [<tenant>] clone a store you can build on ← built
|
|
13
14
|
* tot validate lint your store before you submit ← built
|
|
14
15
|
* tot dev run your store locally with save→reload ← built (monorepo: host astro; standalone: runs the published runner image)
|
|
15
|
-
* tot
|
|
16
|
+
* tot preview push your store to a reviewable preview ← built (validate + push preview ref; MCP preview_status read-back). `submit`/`deploy` are teaching aliases.
|
|
17
|
+
* tot ship promote a reconciled preview live ← built (diff-vs-live + y/N confirm → change_accept; refuses non-TTY / unreconciled)
|
|
18
|
+
* tot pr list / view / close your candidate PRs ← built (candidate_status/candidate_close; gh-pr-shaped)
|
|
16
19
|
* tot doctor check this machine is ready
|
|
17
20
|
* tot ideas copy-paste AI prompts that reliably wow
|
|
18
21
|
* tot feedback send a note to ToT + your recent CLI activity ← built (activity-log.mjs → feedback_submit MCP tool)
|
|
@@ -24,7 +27,7 @@
|
|
|
24
27
|
* from anywhere else. Every command receives the detected context.
|
|
25
28
|
*
|
|
26
29
|
* This package is `@tokenoftrust/cli` (bin `tot`). Its heaviest command, `tot dev`,
|
|
27
|
-
* fetches the published storefront runner; `tot
|
|
30
|
+
* fetches the published storefront runner; `tot clone/validate/submit` are
|
|
28
31
|
* pure Node. Dependency-free by design so `npm i -g @tokenoftrust/cli` stays light.
|
|
29
32
|
*/
|
|
30
33
|
import "../src/ensure-node.mjs"; // hard Node-version gate — must stay first (see the module doc)
|
|
@@ -34,7 +37,7 @@ import { printError } from "../src/errors.mjs";
|
|
|
34
37
|
import { recordActivity, redactArgs } from "../src/activity-log.mjs";
|
|
35
38
|
import { maybeNotifyUpdate } from "../src/update-check.mjs";
|
|
36
39
|
|
|
37
|
-
const BUILD_ORDER = ["
|
|
40
|
+
const BUILD_ORDER = ["clone", "validate", "dev", "preview"];
|
|
38
41
|
|
|
39
42
|
// CLI version (stamped into the activity log). Read from our own package.json; best-effort.
|
|
40
43
|
const VERSION = (() => {
|
|
@@ -53,11 +56,14 @@ tot — Token of Trust developer CLI
|
|
|
53
56
|
tot login sign in to Token of Trust
|
|
54
57
|
tot logout sign out (clear the cached session)
|
|
55
58
|
tot whoami show who you're signed in as
|
|
59
|
+
tot link link your identity so your stores resolve
|
|
56
60
|
tot grants capability/tier/expiry per store you can act on
|
|
57
|
-
tot
|
|
61
|
+
tot clone [<tenant>] clone a store you can build on
|
|
58
62
|
tot validate lint your store before you submit
|
|
59
63
|
tot dev run your store locally with save→reload
|
|
60
|
-
tot
|
|
64
|
+
tot preview push your store to a reviewable preview
|
|
65
|
+
tot ship promote a reconciled preview live (diff → confirm → ship)
|
|
66
|
+
tot pr list / view / close your candidate PRs
|
|
61
67
|
tot doctor check this machine is ready
|
|
62
68
|
tot ideas copy-paste AI prompts that reliably wow
|
|
63
69
|
tot feedback "<msg>" send feedback to Token of Trust (attaches recent activity)
|
|
@@ -109,6 +115,11 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
109
115
|
return run(rest, ctx);
|
|
110
116
|
}
|
|
111
117
|
|
|
118
|
+
if (cmd === "link") {
|
|
119
|
+
const { run } = await import("../src/commands/link.mjs");
|
|
120
|
+
return run(rest, ctx);
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
if (cmd === "doctor") {
|
|
113
124
|
const { run } = await import("../src/commands/doctor.mjs");
|
|
114
125
|
return run(rest, ctx);
|
|
@@ -124,8 +135,8 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
124
135
|
return run(rest, ctx);
|
|
125
136
|
}
|
|
126
137
|
|
|
127
|
-
if (cmd === "
|
|
128
|
-
const { run } = await import("../src/commands/
|
|
138
|
+
if (cmd === "clone") {
|
|
139
|
+
const { run } = await import("../src/commands/clone.mjs");
|
|
129
140
|
return run(rest, ctx);
|
|
130
141
|
}
|
|
131
142
|
|
|
@@ -139,8 +150,26 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
139
150
|
return run(rest, ctx);
|
|
140
151
|
}
|
|
141
152
|
|
|
142
|
-
|
|
143
|
-
|
|
153
|
+
// dev → preview → ship. `preview` is the first-class verb; `submit`/`deploy`
|
|
154
|
+
// reach the SAME preview flow as teaching aliases (preview.mjs prints a one-line
|
|
155
|
+
// hint nudging the developer onto `tot preview` / `tot ship`).
|
|
156
|
+
if (cmd === "preview") {
|
|
157
|
+
const { run } = await import("../src/commands/preview.mjs");
|
|
158
|
+
return run(rest, ctx);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (cmd === "submit" || cmd === "deploy") {
|
|
162
|
+
const { run } = await import("../src/commands/preview.mjs");
|
|
163
|
+
return run(rest, ctx, { alias: cmd });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (cmd === "ship") {
|
|
167
|
+
const { run } = await import("../src/commands/ship.mjs");
|
|
168
|
+
return run(rest, ctx);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (cmd === "pr") {
|
|
172
|
+
const { run } = await import("../src/commands/pr.mjs");
|
|
144
173
|
return run(rest, ctx);
|
|
145
174
|
}
|
|
146
175
|
|
|
@@ -149,10 +178,21 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
149
178
|
return run(rest, ctx);
|
|
150
179
|
}
|
|
151
180
|
|
|
181
|
+
// `checkout` was deliberately freed up: it's no longer a tot verb precisely so it
|
|
182
|
+
// can mean what it means in git. Point both ways rather than a bare "unknown".
|
|
183
|
+
if (cmd === "checkout") {
|
|
184
|
+
console.error(
|
|
185
|
+
"`tot checkout` isn't a tot command — `checkout` now means what it does in git.\n\n" +
|
|
186
|
+
" • switch branches / restore files in your store: git checkout <ref>\n" +
|
|
187
|
+
" • clone a store you can build on: tot clone <tenant>\n",
|
|
188
|
+
);
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
|
|
152
192
|
if (BUILD_ORDER.includes(cmd)) {
|
|
153
193
|
console.error(
|
|
154
194
|
`\`tot ${cmd}\` isn't built yet.\n\n` +
|
|
155
|
-
`Build order: ${BUILD_ORDER.map((c) => (c === "
|
|
195
|
+
`Build order: ${BUILD_ORDER.map((c) => (c === "clone" ? `${c}✓` : c)).join(" → ")}.\n` +
|
|
156
196
|
`Inside a storefront checkout you can use the in-repo scripts today ` +
|
|
157
197
|
`(e.g. \`pnpm tot ${cmd}\`).`,
|
|
158
198
|
);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.4.0-rc.
|
|
4
|
-
"description": "Token of Trust developer CLI —
|
|
3
|
+
"version": "1.4.0-rc.11",
|
|
4
|
+
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
|
7
7
|
"homepage": "https://www.tokenoftrust.com",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "active candidate" pointer — which open candidate PR a plain `tot submit`
|
|
3
|
+
* updates, per forge repo.
|
|
4
|
+
*
|
|
5
|
+
* `tot submit` is idempotent on a STABLE changeId (`deriveChangeId`) so a re-submit
|
|
6
|
+
* updates the same PR by default — the common case needs NO state and writes
|
|
7
|
+
* nothing here (backward-compatible with the stateless original). This file only
|
|
8
|
+
* records a DIVERGENCE from that stable default:
|
|
9
|
+
*
|
|
10
|
+
* - `tot submit --new` forks a fresh candidate and remembers it here, so the
|
|
11
|
+
* NEXT plain `tot submit` keeps updating the NEW PR (like pushing more commits
|
|
12
|
+
* to a `gh pr` branch), not the old one; and
|
|
13
|
+
* - a terminal-roll (the active candidate was merged/closed) records the fresh
|
|
14
|
+
* candidate it rolled to, so you're never wedged submitting to a dead PR.
|
|
15
|
+
*
|
|
16
|
+
* ONE file, `~/.tot/candidates.json`, a map keyed by `<mcpUrl>::<repo>` on the
|
|
17
|
+
* DEFAULT branch and `<mcpUrl>::<repo>::<branch>` on any other (u4 — branch-bound
|
|
18
|
+
* candidates): a different MCP, repo, OR non-default git branch is a different
|
|
19
|
+
* candidate namespace, so a feature branch gets its OWN candidate PR instead of
|
|
20
|
+
* fighting main's over the same handle. The default branch deliberately keeps the
|
|
21
|
+
* OLD branch-less key so existing devs' state is byte-identical (zero migration),
|
|
22
|
+
* and a branch-scoped read that misses FALLS BACK to that old key so state written
|
|
23
|
+
* before the rekey (or by the default branch) is never orphaned. Same atomic-write
|
|
24
|
+
* discipline as last-tenant.mjs (0600 in a 0700 dir, write-tmp-then-rename).
|
|
25
|
+
* Dependency-free (node:fs/os/path). `TOT_HOME` overrides home (tests).
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { join, dirname } from "node:path";
|
|
32
|
+
import { randomBytes } from "node:crypto";
|
|
33
|
+
|
|
34
|
+
/** Absolute path to the active-candidate map for this machine. */
|
|
35
|
+
export function defaultCandidateStatePath(env = process.env) {
|
|
36
|
+
const home = env.TOT_HOME || homedir();
|
|
37
|
+
return join(home, ".tot", "candidates.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Branch names that are treated as the repo's DEFAULT — their candidates keep the
|
|
42
|
+
* OLD branch-less key (zero migration). A null/empty/detached ("HEAD") branch is
|
|
43
|
+
* treated as default too, so an environment where the branch can't be resolved
|
|
44
|
+
* degrades to exactly today's behavior rather than minting a spurious namespace.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_BRANCHES = new Set(["main", "master"]);
|
|
47
|
+
|
|
48
|
+
/** Whether `branch` should use the OLD branch-less candidate key. Pure. */
|
|
49
|
+
export function isDefaultBranch(branch) {
|
|
50
|
+
return !branch || branch === "HEAD" || DEFAULT_BRANCHES.has(branch);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The legacy (branch-less) namespace key — today's exact `<mcpUrl>::<repo>`. */
|
|
54
|
+
function legacyStateKey(mcpUrl, repo) {
|
|
55
|
+
return `${mcpUrl}::${repo}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Namespace key for one (MCP, repo, branch) candidate pointer. The DEFAULT branch
|
|
60
|
+
* keeps the legacy `<mcpUrl>::<repo>` key byte-for-byte (zero migration); any other
|
|
61
|
+
* branch gets its own `<mcpUrl>::<repo>::<branch>` namespace. Pure.
|
|
62
|
+
*/
|
|
63
|
+
function stateKey(mcpUrl, repo, branch) {
|
|
64
|
+
return isDefaultBranch(branch) ? legacyStateKey(mcpUrl, repo) : `${mcpUrl}::${repo}::${branch}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Extract a usable changeId from a stored record, or null. Pure. */
|
|
68
|
+
function recordChangeId(rec) {
|
|
69
|
+
return rec && typeof rec.changeId === "string" && rec.changeId ? rec.changeId : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readMap(filePath) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
75
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
76
|
+
} catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeMap(filePath, map) {
|
|
82
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
83
|
+
const tmp = `${filePath}.tmp`;
|
|
84
|
+
writeFileSync(tmp, `${JSON.stringify(map, null, 2)}\n`, { mode: 0o600 });
|
|
85
|
+
renameSync(tmp, filePath);
|
|
86
|
+
chmodSync(filePath, 0o600);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The remembered active changeId for `(mcpUrl, repo, branch)`, or null when there
|
|
91
|
+
* isn't one (absent/unreadable/malformed) — a miss means "use the stable default".
|
|
92
|
+
* On a non-default branch whose branch-scoped key misses, FALLS BACK to the legacy
|
|
93
|
+
* branch-less key so state written before the rekey (or by the default branch)
|
|
94
|
+
* isn't orphaned. Never throws.
|
|
95
|
+
*/
|
|
96
|
+
export function readActiveChangeId(filePath, { mcpUrl, repo, branch }) {
|
|
97
|
+
const map = readMap(filePath);
|
|
98
|
+
const primary = recordChangeId(map[stateKey(mcpUrl, repo, branch)]);
|
|
99
|
+
if (primary) return primary;
|
|
100
|
+
// Legacy fallback: a branch-scoped miss reads the old branch-less key (a no-op
|
|
101
|
+
// when we're already on the default branch, which IS the legacy key).
|
|
102
|
+
if (!isDefaultBranch(branch)) return recordChangeId(map[legacyStateKey(mcpUrl, repo)]);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Remember `changeId` as the active candidate for `(mcpUrl, repo, branch)`, atomically. */
|
|
107
|
+
export function writeActiveChangeId(filePath, { mcpUrl, repo, branch, changeId }) {
|
|
108
|
+
const map = readMap(filePath);
|
|
109
|
+
map[stateKey(mcpUrl, repo, branch)] = { changeId, updatedAt: Date.now() };
|
|
110
|
+
writeMap(filePath, map);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Forget the active candidate for `(mcpUrl, repo, branch)` (e.g. after closing it). */
|
|
114
|
+
export function clearActiveChangeId(filePath, { mcpUrl, repo, branch }) {
|
|
115
|
+
const map = readMap(filePath);
|
|
116
|
+
const key = stateKey(mcpUrl, repo, branch);
|
|
117
|
+
if (key in map) {
|
|
118
|
+
delete map[key];
|
|
119
|
+
writeMap(filePath, map);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A fresh candidate handle forked from a stable base — `<baseId>-<suffix>`, still
|
|
125
|
+
* matching candidate_open's `[a-z0-9._-]` handle grammar. The suffix defaults to
|
|
126
|
+
* 6 random hex chars (so two `--new` runs never collide); tests inject a fixed
|
|
127
|
+
* suffix. Pure given `suffix`.
|
|
128
|
+
*/
|
|
129
|
+
export function mintFreshChangeId(baseId, suffix = randomBytes(3).toString("hex")) {
|
|
130
|
+
const safe = String(suffix).toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12) || "new";
|
|
131
|
+
return `${baseId}-${safe}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether a forge candidate state means "no open PR to update" (rolled past). */
|
|
135
|
+
export function isTerminalCandidateState(state) {
|
|
136
|
+
return state === "merged" || state === "closed";
|
|
137
|
+
}
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `tot
|
|
3
|
-
* authenticated remote already configured, ready for the local loop.
|
|
2
|
+
* `tot clone` — clone the tenant store you're entitled to build on, with an
|
|
3
|
+
* authenticated remote already configured, ready for the local loop. Mirrors
|
|
4
|
+
* `git clone <repo> [dir]`: the tenant is the repo, the dir defaults to the
|
|
5
|
+
* tenant name. (Named `clone`, not `checkout`, so it means what `git clone`
|
|
6
|
+
* means — materialize a local working copy — and never collides with the
|
|
7
|
+
* unrelated `git checkout` = switch-refs verb.)
|
|
4
8
|
*
|
|
5
|
-
* tot
|
|
6
|
-
* tot
|
|
7
|
-
* tot
|
|
9
|
+
* tot clone list the stores you can build on
|
|
10
|
+
* tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
|
|
11
|
+
* tot clone <tenant> <dir> clone into <dir>
|
|
12
|
+
* tot clone <tenant> --remote-only just mint + print the remote, don't materialize
|
|
8
13
|
*
|
|
9
14
|
* This is the SAME MCP code path a developer gets when they switch to a tenant:
|
|
10
15
|
* sign-in → client_switch(tenant) → tenant_checkout, where the MCP derives your
|
|
11
16
|
* per-tenant Git user and mints a FRESH, single-active, repo-scoped push
|
|
12
|
-
* credential (a later
|
|
13
|
-
* privileged forge work itself — the MCP owns that.
|
|
17
|
+
* credential (a later clone for the same tenant rotates it). `tot` performs NO
|
|
18
|
+
* privileged forge work itself — the MCP owns that. ("checkout" survives only as
|
|
19
|
+
* the domain/lease term: the `tenant_checkout` MCP tool + the on-disk noun for a
|
|
20
|
+
* materialized store dir — never as a developer-facing verb.)
|
|
14
21
|
*
|
|
15
22
|
* Auth is the developer's own ToT identity (the cached `tot login` session,
|
|
16
23
|
* resolved via src/auth.mjs). When there's no session yet and we're on a TTY, we
|
|
@@ -34,8 +41,9 @@ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
|
34
41
|
function parseArgs(argv) {
|
|
35
42
|
const a = {
|
|
36
43
|
tenant: null,
|
|
44
|
+
dir: null,
|
|
37
45
|
tag: "main",
|
|
38
|
-
|
|
46
|
+
remoteOnly: false,
|
|
39
47
|
mcp: null,
|
|
40
48
|
printRemote: false,
|
|
41
49
|
help: false,
|
|
@@ -43,27 +51,32 @@ function parseArgs(argv) {
|
|
|
43
51
|
for (let i = 0; i < argv.length; i++) {
|
|
44
52
|
const t = argv[i];
|
|
45
53
|
if (t === "--tag") a.tag = argv[++i];
|
|
46
|
-
else if (t === "--
|
|
54
|
+
else if (t === "--remote-only") a.remoteOnly = true;
|
|
47
55
|
else if (t === "--mcp") a.mcp = argv[++i];
|
|
48
56
|
else if (t === "--print-remote") a.printRemote = true;
|
|
49
57
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
50
|
-
|
|
58
|
+
// Positional, git-clone style: `tot clone <tenant> [<dir>]`.
|
|
59
|
+
else if (!t.startsWith("--")) {
|
|
60
|
+
if (!a.tenant) a.tenant = t;
|
|
61
|
+
else if (!a.dir) a.dir = t;
|
|
62
|
+
}
|
|
51
63
|
}
|
|
52
64
|
return a;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
const USAGE = `tot
|
|
67
|
+
const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`git clone\`)
|
|
56
68
|
|
|
57
|
-
tot
|
|
58
|
-
tot
|
|
59
|
-
tot
|
|
69
|
+
tot clone list the stores you can build on
|
|
70
|
+
tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
|
|
71
|
+
tot clone <tenant> <dir> clone into <dir>
|
|
60
72
|
|
|
61
73
|
Options:
|
|
62
74
|
--tag <tag> which repo (repo = "<tenant>-<tag>"). Default: main.
|
|
63
|
-
--
|
|
75
|
+
--remote-only don't clone; just mint + print the clone URL for <tenant>.
|
|
64
76
|
--mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
|
|
65
77
|
${DEFAULT_MCP_URL}.
|
|
66
|
-
--print-remote also print the authenticated remote
|
|
78
|
+
--print-remote with --remote-only, also print the authenticated remote
|
|
79
|
+
(contains a live token!).`;
|
|
67
80
|
|
|
68
81
|
/** Redact known secrets from any string before it hits the terminal. */
|
|
69
82
|
function makeRedactor(env) {
|
|
@@ -92,6 +105,11 @@ export async function run(argv, ctx) {
|
|
|
92
105
|
args.tenant = ctx.tenant;
|
|
93
106
|
}
|
|
94
107
|
|
|
108
|
+
// git-clone semantics: unless you explicitly asked for --remote-only, cloning
|
|
109
|
+
// is the default, and the target dir defaults to the tenant name (like
|
|
110
|
+
// `git clone <url>` deriving the dir from the repo basename).
|
|
111
|
+
const cloneDir = args.remoteOnly ? null : args.dir || args.tenant;
|
|
112
|
+
|
|
95
113
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
96
114
|
const redact = makeRedactor(env);
|
|
97
115
|
const client = createMcpClient(baseUrl);
|
|
@@ -128,21 +146,21 @@ export async function run(argv, ctx) {
|
|
|
128
146
|
const res = await checkoutTenant(client, {
|
|
129
147
|
tenant: args.tenant,
|
|
130
148
|
tag: args.tag,
|
|
131
|
-
cloneDir
|
|
149
|
+
cloneDir,
|
|
132
150
|
redact,
|
|
133
151
|
});
|
|
134
|
-
console.log(`\n+
|
|
152
|
+
console.log(`\n+ ready. repo: ${res.cloneUrl || res.publicUrl}`);
|
|
135
153
|
|
|
136
154
|
if (res.cloned) {
|
|
137
155
|
console.log(`+ cloned. HEAD: ${res.head}`);
|
|
138
156
|
console.log(`\nYour local working clone is at ${res.dir} with an authenticated remote.`);
|
|
139
157
|
console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
|
|
140
|
-
console.log(` (the minted token lives in .git/config; a later
|
|
158
|
+
console.log(` (the minted token lives in .git/config; a later clone rotates it)`);
|
|
141
159
|
return 0;
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
|
|
145
|
-
console.log(`
|
|
163
|
+
console.log(`Drop --remote-only to clone with the authenticated remote configured.`);
|
|
146
164
|
if (args.printRemote) {
|
|
147
165
|
console.log(
|
|
148
166
|
`\nAUTHENTICATED remote (contains a live token — handle carefully):\n${res.gitRemote}`,
|
|
@@ -164,7 +182,7 @@ export async function run(argv, ctx) {
|
|
|
164
182
|
}
|
|
165
183
|
|
|
166
184
|
/**
|
|
167
|
-
* The privileged checkout core, composable in-process (used by `tot
|
|
185
|
+
* The privileged checkout core, composable in-process (used by `tot clone`
|
|
168
186
|
* and by `tot start`): client_switch → tenant_checkout → optionally clone. The
|
|
169
187
|
* MCP mints a fresh, repo-scoped push credential each time. Assumes `client` is
|
|
170
188
|
* already initialized + has a validated session.
|
|
@@ -211,7 +229,7 @@ async function cloneRepo(gitRemote, dir, redact) {
|
|
|
211
229
|
await git(["clone", gitRemote, dir]);
|
|
212
230
|
} catch (e) {
|
|
213
231
|
// Beacon the cockpit before we surface the error — covers this path for both
|
|
214
|
-
// `tot
|
|
232
|
+
// `tot clone` and `tot start` (which clones through here). Awaited so the
|
|
215
233
|
// packet lands before the process prints + exits; swallowed either way.
|
|
216
234
|
await emitObstacle("clone-failed");
|
|
217
235
|
throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
|
|
@@ -255,13 +273,13 @@ export function checkoutError(checkout) {
|
|
|
255
273
|
}
|
|
256
274
|
return {
|
|
257
275
|
message: msg || "the store checkout couldn't be completed",
|
|
258
|
-
next: "confirm you're entitled to this store — `tot
|
|
276
|
+
next: "confirm you're entitled to this store — `tot clone` (lists your stores)",
|
|
259
277
|
};
|
|
260
278
|
}
|
|
261
279
|
|
|
262
280
|
/**
|
|
263
281
|
* Normalize the (shape-varying) `client_list` response into a plain, sorted
|
|
264
|
-
* list of the stores this identity can build on. Shared by `tot
|
|
282
|
+
* list of the stores this identity can build on. Shared by `tot clone`'s
|
|
265
283
|
* listing and `tot start`'s auto-pick so both read the same fields.
|
|
266
284
|
* @param {unknown} list
|
|
267
285
|
* @returns {Array<{ id: string, name: string, raw: any }>}
|
|
@@ -308,6 +326,86 @@ export function storeListError(list) {
|
|
|
308
326
|
return null;
|
|
309
327
|
}
|
|
310
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Extract card c1's broker-identity remediation signal from a `client_list`
|
|
331
|
+
* result. When the identity resolved ZERO stores, the server MAY carry:
|
|
332
|
+
* - `brokerStatus`: 'unlinked' | 'unconfigured' | 'broker_error' — present only
|
|
333
|
+
* when the empty scope is a BROKER-IDENTITY problem (not an entitlement one);
|
|
334
|
+
* ABSENT for the genuine linked-but-zero-grants case.
|
|
335
|
+
* - `nextAction`: a status-specific human remediation string the server owns
|
|
336
|
+
* (single source of truth; e.g. an unlinked identity is told to finish
|
|
337
|
+
* linking, an operator sees a diagnostic, zero-grants sees NO_TENANTS_MESSAGE).
|
|
338
|
+
* Returns { brokerStatus, nextAction } with nulls when absent. Pure + exported so
|
|
339
|
+
* it's unit-tested without any I/O.
|
|
340
|
+
* @param {unknown} list
|
|
341
|
+
* @returns {{ brokerStatus: string|null, nextAction: string|null }}
|
|
342
|
+
*/
|
|
343
|
+
export function brokerRemediation(list) {
|
|
344
|
+
const c = list && typeof list === "object" && !Array.isArray(list) ? list : null;
|
|
345
|
+
const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
|
|
346
|
+
const nextAction =
|
|
347
|
+
c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
|
|
348
|
+
return { brokerStatus, nextAction };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Status-aware human guidance for an authenticated identity that resolved ZERO
|
|
353
|
+
* stores (card c2 — the fix for the misleading "may still be propagating / ask for
|
|
354
|
+
* a store invite" copy that dead-ended an UNLINKED identity). Driven by card c1's
|
|
355
|
+
* `brokerStatus`/`nextAction`:
|
|
356
|
+
* - `unlinked` → the identity isn't linked to the ToT broker yet, so no scope can
|
|
357
|
+
* resolve. This is NOT an entitlement problem — point at `tot link` (the
|
|
358
|
+
* terminal action), NOT "ask for a store invite".
|
|
359
|
+
* - `unconfigured` | `broker_error` → an operator/diagnostic condition; surface
|
|
360
|
+
* the server's own `nextAction`, never "ask for an invite".
|
|
361
|
+
* - no brokerStatus → the genuine linked-but-zero-grants case; keep the existing
|
|
362
|
+
* "invite may still be propagating / ask for one" wording (NO_TENANTS_MESSAGE),
|
|
363
|
+
* preferring the server's `nextAction` when present.
|
|
364
|
+
*
|
|
365
|
+
* Prefers the server's `nextAction` as the concrete `next` step (single source of
|
|
366
|
+
* truth) and falls back to sensible local copy when it's absent (older server).
|
|
367
|
+
* `linkHint` names the terminal action an unlinked identity is pointed at — the
|
|
368
|
+
* built-in `tot link`; a caller that didn't build `tot link` passes an MCP-client
|
|
369
|
+
* phrasing instead. Pure + exported so it's unit-tested without any I/O.
|
|
370
|
+
* @param {unknown} list
|
|
371
|
+
* @param {{ linkHint?: string }} [opts]
|
|
372
|
+
* @returns {{ brokerStatus: string|null, headline: string, next: string }}
|
|
373
|
+
*/
|
|
374
|
+
export function noStoresGuidance(list, { linkHint = "tot link" } = {}) {
|
|
375
|
+
const { brokerStatus, nextAction } = brokerRemediation(list);
|
|
376
|
+
|
|
377
|
+
if (brokerStatus === "unlinked") {
|
|
378
|
+
// The actionable terminal step beats the server's identity_link_begin/poll
|
|
379
|
+
// MCP-tool wording for a CLI user, so lead with `tot link` (c2 optional-arm
|
|
380
|
+
// directive). Fall back to the server string only if there's no link action.
|
|
381
|
+
return {
|
|
382
|
+
brokerStatus,
|
|
383
|
+
headline: "your Token of Trust identity isn't linked yet, so no stores could be resolved",
|
|
384
|
+
next: linkHint
|
|
385
|
+
? `run \`${linkHint}\` to finish linking your identity, then re-run`
|
|
386
|
+
: nextAction || "finish linking your identity (identity_link_begin), then re-run",
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (brokerStatus === "unconfigured" || brokerStatus === "broker_error") {
|
|
390
|
+
// Operator/diagnostic conditions — surface the server's own words; do NOT tell
|
|
391
|
+
// the developer to "ask for an invite" (it isn't an entitlement problem).
|
|
392
|
+
return {
|
|
393
|
+
brokerStatus,
|
|
394
|
+
headline: "no stores could be resolved — the Token of Trust identity broker had a problem",
|
|
395
|
+
next: nextAction || "run `tot whoami` for details, or contact your Token of Trust operator",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// No brokerStatus → the genuine linked-but-zero-grants case.
|
|
399
|
+
return {
|
|
400
|
+
brokerStatus: null,
|
|
401
|
+
headline: "you have no stores to build on yet",
|
|
402
|
+
next:
|
|
403
|
+
nextAction ||
|
|
404
|
+
"if you were just invited, it may still be propagating — try again in a minute; " +
|
|
405
|
+
"otherwise ask your Token of Trust contact for a store invite (see `tot whoami`)",
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
311
409
|
function printClientList(list) {
|
|
312
410
|
const err = storeListError(list);
|
|
313
411
|
const stores = normalizeStores(list);
|
|
@@ -317,14 +415,17 @@ function printClientList(list) {
|
|
|
317
415
|
return;
|
|
318
416
|
}
|
|
319
417
|
if (stores.length === 0) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
418
|
+
// Status-aware, human-readable guidance — NEVER a raw JSON dump (the old
|
|
419
|
+
// behaviour, James's 2026-07-19 failure mode).
|
|
420
|
+
const g = noStoresGuidance(list);
|
|
421
|
+
const line = g.headline.charAt(0).toUpperCase() + g.headline.slice(1);
|
|
422
|
+
console.log(`\n${line}.`);
|
|
423
|
+
console.log(`Next: ${g.next}`);
|
|
323
424
|
return;
|
|
324
425
|
}
|
|
325
426
|
console.log("\nStores you can build on:\n");
|
|
326
427
|
for (const s of stores) {
|
|
327
428
|
console.log(` ${s.id}${s.name ? ` — ${s.name}` : ""}`);
|
|
328
429
|
}
|
|
329
|
-
console.log(`\nNext: tot
|
|
430
|
+
console.log(`\nNext: tot clone <tenant>`);
|
|
330
431
|
}
|