@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.21
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 +219 -44
- package/package.json +7 -2
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +736 -0
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +269 -0
- package/src/commands/clone.mjs +713 -0
- package/src/commands/dev.mjs +441 -93
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +486 -0
- package/src/commands/grants.mjs +14 -7
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +12 -8
- package/src/commands/pr.mjs +425 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +403 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +91 -29
- package/src/commands/submit.mjs +1360 -131
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +11 -5
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/dev-heartbeat.mjs +2 -1
- package/src/errors.mjs +8 -4
- package/src/git-credential.mjs +185 -0
- package/src/mcp.mjs +6 -1
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +26 -11
- package/src/obstacle-beacon.cjs +3 -3
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +30 -4
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
- package/src/commands/checkout.mjs +0 -330
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot sync` — the common candidate CONFLICT-RECOVERY path (branch-lifecycle
|
|
3
|
+
* contract, docs/architecture/branch-lifecycle-and-integration-preview.md,
|
|
4
|
+
* "Two developers touch the same file" / P1 item 12):
|
|
5
|
+
*
|
|
6
|
+
* Both candidate previews may be green in isolation. After the first
|
|
7
|
+
* integrates, the second may become conflicted. The queue blocks that PR
|
|
8
|
+
* and prints the conflicting paths. The second developer syncs from
|
|
9
|
+
* `preview`, resolves locally, reruns `tot preview`, and obtains evidence
|
|
10
|
+
* for the new head. Prior approval is stale and must not carry forward
|
|
11
|
+
* automatically.
|
|
12
|
+
*
|
|
13
|
+
* `tot sync` fetches the protected `preview` branch and MERGES it into the
|
|
14
|
+
* developer's current local branch — this repo's own canonical policy for
|
|
15
|
+
* bringing a protected line into a working branch (see playbook/merge.md:
|
|
16
|
+
* `git fetch` + `git merge --no-edit`, never a history-rewriting rebase of
|
|
17
|
+
* shared history). It never writes `preview`/`main` (fetch is read-only on
|
|
18
|
+
* the remote), never force-pushes anything, and never runs `git add -A` — on
|
|
19
|
+
* a conflict it stops immediately and leaves the merge in progress so the
|
|
20
|
+
* developer resolves it the normal git way (`git add <file>` + `git commit`,
|
|
21
|
+
* or `git merge --abort` to back out).
|
|
22
|
+
*
|
|
23
|
+
* Distinct from `tot preview`/`tot accept`: sync touches ONLY the developer's
|
|
24
|
+
* own local branch. It never pushes, never opens/updates a candidate, and
|
|
25
|
+
* never touches the shared `preview` aggregate or live. A successful sync
|
|
26
|
+
* moves local HEAD, which makes any prior preview/approval stale by
|
|
27
|
+
* definition (they were evidence for the OLD head) — so this always closes
|
|
28
|
+
* by pointing the developer at a fresh `tot preview`.
|
|
29
|
+
*
|
|
30
|
+
* Dependency-free (global `git`, no MCP/network beyond `git fetch`).
|
|
31
|
+
*/
|
|
32
|
+
import { execFileSync } from "node:child_process";
|
|
33
|
+
import { fail } from "../errors.mjs";
|
|
34
|
+
import { ensureTokenlessRemote } from "../git-credential.mjs";
|
|
35
|
+
|
|
36
|
+
/** The protected branch `tot sync` fetches + merges from by default. */
|
|
37
|
+
export const DEFAULT_SYNC_BRANCH = "preview";
|
|
38
|
+
|
|
39
|
+
const USAGE = `tot sync — fetch \`preview\` and merge it into your local branch
|
|
40
|
+
|
|
41
|
+
tot sync fetch origin/${DEFAULT_SYNC_BRANCH} and merge it into your current branch
|
|
42
|
+
tot sync --branch <name> sync against a different protected branch (default: ${DEFAULT_SYNC_BRANCH})
|
|
43
|
+
tot sync --help show this help
|
|
44
|
+
|
|
45
|
+
The common conflict-recovery path: after another candidate integrates first,
|
|
46
|
+
yours may conflict on \`tot accept\`. \`tot sync\` fetches the protected
|
|
47
|
+
\`${DEFAULT_SYNC_BRANCH}\` branch and merges it into your local branch so you can resolve the
|
|
48
|
+
conflict locally, THEN re-run \`tot preview\` for a fresh candidate head.
|
|
49
|
+
|
|
50
|
+
On a conflict, sync stops SAFELY: it prints the conflicting paths and leaves
|
|
51
|
+
the merge in progress for you to resolve by hand — it never force-pushes
|
|
52
|
+
\`${DEFAULT_SYNC_BRANCH}\`/main and never runs \`git add -A\`.
|
|
53
|
+
|
|
54
|
+
A successful sync moves your local HEAD, so any prior preview/approval — it
|
|
55
|
+
was evidence for the OLD head — is now stale. Re-run \`tot preview\` before
|
|
56
|
+
your next \`tot accept\`.`;
|
|
57
|
+
|
|
58
|
+
/** Parse `tot sync` argv. Pure — unit-testable. */
|
|
59
|
+
export function parseArgs(argv) {
|
|
60
|
+
const a = { branch: null, help: false };
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
const t = argv[i];
|
|
63
|
+
if (t === "--branch") a.branch = argv[++i];
|
|
64
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
65
|
+
}
|
|
66
|
+
return a;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Unmerged (conflicting) paths from `git diff --name-only --diff-filter=U`.
|
|
70
|
+
* Pure — unit-tested without git. */
|
|
71
|
+
export function parseConflictPaths(text) {
|
|
72
|
+
return String(text)
|
|
73
|
+
.split("\n")
|
|
74
|
+
.map((l) => l.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fetch `origin/<branch>` and merge it into the current branch. Never throws
|
|
80
|
+
* for an ordinary merge conflict — git's own nonzero exit on `merge` IS the
|
|
81
|
+
* conflict signal, and we turn it into a `"conflict"` result with the
|
|
82
|
+
* conflicting paths; a merge failure that ISN'T an ordinary conflict (no
|
|
83
|
+
* unmerged paths found) rethrows so the caller reports the real failure
|
|
84
|
+
* instead of a misleading "conflict".
|
|
85
|
+
*
|
|
86
|
+
* @param {(cargs:string[]) => string} git a THROWING `git -C <workspace>` runner
|
|
87
|
+
* (execFileSync-backed) — throws carry `.stderr`/`.message` like execFileSync.
|
|
88
|
+
* @param {{ branch?: string }} [opts]
|
|
89
|
+
* @returns {{ state: "up-to-date" } | { state: "synced", sha: string } | { state: "conflict", paths: string[] }}
|
|
90
|
+
*/
|
|
91
|
+
export function syncWithBranch(git, { branch = DEFAULT_SYNC_BRANCH } = {}) {
|
|
92
|
+
git(["fetch", "origin", branch]);
|
|
93
|
+
|
|
94
|
+
// How many commits on origin/<branch> the local branch is missing. 0 means
|
|
95
|
+
// local HEAD already contains everything from the protected branch —
|
|
96
|
+
// nothing to merge, regardless of how far ahead the local branch itself is.
|
|
97
|
+
const ahead = git(["rev-list", "--count", `HEAD..origin/${branch}`]).trim();
|
|
98
|
+
if (ahead === "0") return { state: "up-to-date" };
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
git(["merge", "--no-edit", `origin/${branch}`]);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const paths = parseConflictPaths(git(["diff", "--name-only", "--diff-filter=U"]));
|
|
104
|
+
if (paths.length === 0) throw e; // not an ordinary conflict — surface the real failure
|
|
105
|
+
return { state: "conflict", paths };
|
|
106
|
+
}
|
|
107
|
+
const sha = git(["rev-parse", "HEAD"]).trim();
|
|
108
|
+
return { state: "synced", sha };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Render a `syncWithBranch` result in house style; returns the process exit
|
|
112
|
+
* code. Pure given its inputs. */
|
|
113
|
+
export function reportSync(result, { branch }) {
|
|
114
|
+
if (result.state === "up-to-date") {
|
|
115
|
+
console.log(`\n ✓ already in sync with origin/${branch} — nothing to merge.`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (result.state === "synced") {
|
|
119
|
+
console.log(`\n ✓ synced with origin/${branch} — new HEAD ${result.sha.slice(0, 9)}.`);
|
|
120
|
+
console.log(` → next: any prior preview/approval was evidence for the OLD head and is now stale.`);
|
|
121
|
+
console.log(` re-run \`tot preview\` to get a fresh candidate + evidence for this head.`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
// conflict — stop safely; the merge is left in progress for the developer.
|
|
125
|
+
console.error(fail(`sync conflicts with origin/${branch} — ${result.paths.length} file(s)`) + "\n");
|
|
126
|
+
for (const p of result.paths) console.error(` ✗ conflict: ${p}`);
|
|
127
|
+
console.error(`\n → next: resolve each conflict above, then \`git add <file>\` and \`git commit\` to finish the merge`);
|
|
128
|
+
console.error(` (or \`git merge --abort\` to back out). Once resolved, re-run \`tot preview\` for a fresh candidate.`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string[]} argv
|
|
134
|
+
* @param {any} ctx
|
|
135
|
+
*/
|
|
136
|
+
export async function run(argv, ctx) {
|
|
137
|
+
const args = parseArgs(argv);
|
|
138
|
+
if (args.help) {
|
|
139
|
+
console.log(USAGE);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
if (ctx.mode !== "checkout") {
|
|
143
|
+
console.error(
|
|
144
|
+
fail(
|
|
145
|
+
"`tot sync` runs from inside a tenant checkout",
|
|
146
|
+
"tot clone <tenant> <dir> (then `cd` in, and re-run)",
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
return 2;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const workspace = ctx.workspacePath;
|
|
153
|
+
const branch = (args.branch || DEFAULT_SYNC_BRANCH).trim();
|
|
154
|
+
const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
155
|
+
const gitSafe = (cargs) => {
|
|
156
|
+
try {
|
|
157
|
+
return git(cargs);
|
|
158
|
+
} catch {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Self-heal a LEGACY checkout (unit u10, absorbs u7): `tot login` refreshes
|
|
164
|
+
// this CLI's own session, never the token baked into a checkout's remote at
|
|
165
|
+
// clone time — the exact reason a stale checkout's `git fetch origin` (below)
|
|
166
|
+
// used to 401 even right after signing back in. Best-effort, never blocks sync.
|
|
167
|
+
try {
|
|
168
|
+
ensureTokenlessRemote(git);
|
|
169
|
+
} catch {
|
|
170
|
+
/* best-effort — see above */
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Refuse a dirty tree up front — a merge on top of uncommitted edits is how
|
|
174
|
+
// local work gets silently entangled with the merge, and we never sweep
|
|
175
|
+
// anything in with `git add -A`. Commit or stash first, then re-run.
|
|
176
|
+
const status = gitSafe(["status", "--porcelain", "--untracked-files=all"]);
|
|
177
|
+
if (status.trim()) {
|
|
178
|
+
console.error(
|
|
179
|
+
fail(
|
|
180
|
+
"your working tree has uncommitted changes",
|
|
181
|
+
"commit them, or `git stash --include-untracked`, then re-run `tot sync`",
|
|
182
|
+
) + "\n",
|
|
183
|
+
);
|
|
184
|
+
for (const line of status.trim().split("\n")) console.error(` ${line}`);
|
|
185
|
+
return 2;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.error(`~ fetching origin/${branch}…`);
|
|
189
|
+
let result;
|
|
190
|
+
try {
|
|
191
|
+
result = syncWithBranch(git, { branch });
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.error(
|
|
194
|
+
fail(
|
|
195
|
+
`sync failed: ${String(e.stderr || e.message || e)}`,
|
|
196
|
+
"check your network / that the checkout's remote is reachable, then re-run",
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return reportSync(result, { branch });
|
|
203
|
+
}
|
|
@@ -68,15 +68,16 @@ export function run(argv, ctx) {
|
|
|
68
68
|
|
|
69
69
|
const target = resolveTarget(args, ctx);
|
|
70
70
|
if (target.error) {
|
|
71
|
-
console.error(fail(target.error, "tot
|
|
71
|
+
console.error(fail(target.error, "tot clone <tenant>, or pass --workspace <dir>"));
|
|
72
72
|
return 2;
|
|
73
73
|
}
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const dir = /** @type {string} */ (target.dir);
|
|
75
|
+
if (!existsSync(dir)) {
|
|
76
|
+
console.error(fail(`no tenant directory at ${dir}`, "confirm the path, or `tot clone <tenant>`"));
|
|
76
77
|
return 2;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
const { ok, findings } = validateTenant(
|
|
80
|
+
const { ok, findings } = validateTenant(dir, {
|
|
80
81
|
tenantId: target.tenantId ?? undefined,
|
|
81
82
|
scope: target.scope ?? undefined,
|
|
82
83
|
// A checkout / bare-tenant target is served by the ToT storefront platform, so
|
|
@@ -92,7 +93,12 @@ export function run(argv, ctx) {
|
|
|
92
93
|
|
|
93
94
|
const errors = findings.filter((f) => f.level === ERROR);
|
|
94
95
|
const warns = findings.filter((f) => f.level === WARN);
|
|
95
|
-
|
|
96
|
+
// Never present the checkout PATH as if it were the tenant name — when the
|
|
97
|
+
// tenant couldn't be resolved (e.g. an invalid/missing .tot/config.json),
|
|
98
|
+
// the findings below say why; the header should say so too, not disguise
|
|
99
|
+
// a directory as a domain.
|
|
100
|
+
const label = target.tenantId ? target.tenantId : `(tenant unresolved) ${target.dir}`;
|
|
101
|
+
console.log(`\ntot validate — ${label}\n`);
|
|
96
102
|
for (const f of findings) {
|
|
97
103
|
const tag = f.level === ERROR ? "✗" : "⚠";
|
|
98
104
|
console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { defaultCredentialsPath, readCredentials, isExpired, activeProfile } from "../token-store.mjs";
|
|
12
12
|
import { establishSession } from "../auth.mjs";
|
|
13
13
|
import { createMcpClient } from "../mcp.mjs";
|
|
14
|
-
import { normalizeStores, storeListError } from "./
|
|
14
|
+
import { normalizeStores, storeListError, noStoresGuidance } from "./clone.mjs";
|
|
15
15
|
import { recordServerPolicy } from "../update-check.mjs";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -67,7 +67,11 @@ export async function run(argv, _ctx) {
|
|
|
67
67
|
} else if (listErr) {
|
|
68
68
|
console.log(` (couldn't list your stores: ${listErr} — try \`tot login\` again)`);
|
|
69
69
|
} else {
|
|
70
|
-
|
|
70
|
+
// Status-aware (card c2): an UNLINKED identity is told to link (not "may
|
|
71
|
+
// still be propagating" — that's only the genuine zero-grants case).
|
|
72
|
+
const g = noStoresGuidance(listResp);
|
|
73
|
+
console.log(` (${g.headline})`);
|
|
74
|
+
console.log(` → next: ${g.next}`);
|
|
71
75
|
}
|
|
72
76
|
} catch {
|
|
73
77
|
console.log(" (couldn't reach the MCP to list your stores right now — your cached session is above)");
|
package/src/context.mjs
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* monorepo — inside a full storefront checkout (pnpm-workspace.yaml +
|
|
8
8
|
* apps/storefront + tenants/). This is us / a platform dev.
|
|
9
|
-
* `tot dev` here runs the in-tree astro dev; `tot
|
|
9
|
+
* `tot dev` here runs the in-tree astro dev; `tot clone`
|
|
10
10
|
* can suggest cloning a sibling dir.
|
|
11
11
|
* checkout — inside a STANDALONE tenant checkout: the flat, content-only
|
|
12
12
|
* shape `content/ public/ theme.json .tot/config.json` a
|
|
13
13
|
* developer clones. The tenant is read from .tot/config.json.
|
|
14
14
|
* `tot dev` here boots the bundled runner against this dir.
|
|
15
|
-
* loose — anywhere else. `tot
|
|
15
|
+
* loose — anywhere else. `tot clone <tenant>` still works (it's how
|
|
16
16
|
* you GET a checkout); commands that need a workspace say so.
|
|
17
17
|
*
|
|
18
18
|
* Detection walks UP from the cwd so `tot` works from any subdirectory of a
|
package/src/dev-heartbeat.mjs
CHANGED
|
@@ -35,7 +35,8 @@ const OS_INFO = { os: os.platform(), osVersion: os.release(), arch: os.arch() };
|
|
|
35
35
|
* an activity URL and a bearer token. Never throws — a failed/offline hosted
|
|
36
36
|
* worker just means the cockpit doesn't light up this beat.
|
|
37
37
|
* @param {{ activityUrl?: string, token?: string, url?: string,
|
|
38
|
-
* cliVersion?: string, runnerVersion?: string|null, editor?: string|null
|
|
38
|
+
* cliVersion?: string, runnerVersion?: string|null, editor?: string|null,
|
|
39
|
+
* cwd?: string|null }} args
|
|
39
40
|
*/
|
|
40
41
|
export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion, editor, cwd } = {}) {
|
|
41
42
|
if (!activityUrl || !token) return undefined;
|
package/src/errors.mjs
CHANGED
|
@@ -32,13 +32,17 @@ function versionFooter() {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* A failure worth surfacing with a concrete next step.
|
|
35
|
+
* @typedef {object} CliErrorOpts
|
|
36
|
+
* @property {string} [next] - the exact command (or one-line instruction) to run next.
|
|
37
|
+
* @property {number} [exitCode] - process exit code to use (default 1).
|
|
38
|
+
* @property {unknown} [cause]
|
|
39
|
+
*
|
|
35
40
|
* @param {string} what - what went wrong, in plain words.
|
|
36
|
-
* @param {
|
|
37
|
-
* next - the exact command (or one-line instruction) to run next.
|
|
38
|
-
* exitCode - process exit code to use (default 1).
|
|
41
|
+
* @param {CliErrorOpts} [opts]
|
|
39
42
|
*/
|
|
40
43
|
export class CliError extends Error {
|
|
41
|
-
constructor(what,
|
|
44
|
+
constructor(what, opts = {}) {
|
|
45
|
+
const { next, exitCode = 1, cause } = opts;
|
|
42
46
|
super(what);
|
|
43
47
|
this.name = "CliError";
|
|
44
48
|
this.what = what;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for `tot` acting as a git credential helper (unit u10,
|
|
3
|
+
* workstream tot-merge-conflict-resolution-ux — absorbs u7's `tot fetch`
|
|
4
|
+
* self-heal idea) — so a checkout never needs a LIVE forge token baked into
|
|
5
|
+
* `.git/config`'s remote URL. `tot clone` configures a fresh checkout's
|
|
6
|
+
* `credential.helper` to `CREDENTIAL_HELPER` (git's own extension point for
|
|
7
|
+
* exactly this — see `git help gitcredentials`), which git then invokes with
|
|
8
|
+
* `get`/`store`/`erase` on every network operation instead of reading a
|
|
9
|
+
* persisted secret. This module holds the pieces BOTH the `git-credential`
|
|
10
|
+
* command (src/commands/git-credential.mjs, which mints fresh tokens via the
|
|
11
|
+
* MCP) and the self-heal migration (called at the top of every command that
|
|
12
|
+
* touches git — submit/sync/… — so ANY CLI touch of a legacy checkout
|
|
13
|
+
* migrates it) share: parsing/formatting git's credential protocol, the
|
|
14
|
+
* on-disk credential cache, and rewriting a checkout's remote to drop its
|
|
15
|
+
* embedded token.
|
|
16
|
+
*
|
|
17
|
+
* Dependency-free — node:fs/os/path/crypto only. The MCP mint itself (real
|
|
18
|
+
* network I/O) lives in the command layer, which this module never imports.
|
|
19
|
+
*/
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { readCredentials, writeCredentials } from "./token-store.mjs";
|
|
24
|
+
|
|
25
|
+
/** The git config value that routes credential requests through `tot` — the
|
|
26
|
+
* `!` tells git to run this as a shell command (git appends the operation,
|
|
27
|
+
* e.g. `get`, as the final argument) — the same convention `gh auth
|
|
28
|
+
* git-credential` uses. Repo-LOCAL only (never --global): a checkout not
|
|
29
|
+
* built with `tot` must never have its credential resolution silently
|
|
30
|
+
* redirected. */
|
|
31
|
+
export const CREDENTIAL_HELPER = "!tot git-credential";
|
|
32
|
+
|
|
33
|
+
/** How long a minted credential is trusted before `tot git-credential get`
|
|
34
|
+
* mints a fresh one — comfortably under the forge push token's own
|
|
35
|
+
* multi-hour expiry (see pushPreviewRef in submit.mjs), so a long-running
|
|
36
|
+
* session still self-refreshes well before the cached one goes stale. */
|
|
37
|
+
export const CREDENTIAL_TTL_MS = 20 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split an authenticated forge remote URL (basic-auth `user:token@host`, as
|
|
41
|
+
* the MCP mints it via `tenant_checkout`) into its tokenless public URL +
|
|
42
|
+
* the embedded credential, so the token can be handed to git EPHEMERALLY for
|
|
43
|
+
* one operation instead of being persisted in `.git/config`. Returns null
|
|
44
|
+
* when the URL won't parse or carries no token — the caller then falls back
|
|
45
|
+
* to the checkout's existing remote. Pure — unit-tested.
|
|
46
|
+
* @param {string} remoteUrl
|
|
47
|
+
* @returns {{ publicUrl: string, username: string, token: string }|null}
|
|
48
|
+
*/
|
|
49
|
+
export function splitAuthedRemote(remoteUrl) {
|
|
50
|
+
try {
|
|
51
|
+
const u = new URL(String(remoteUrl));
|
|
52
|
+
const token = u.password ? decodeURIComponent(u.password) : "";
|
|
53
|
+
if (!token) return null;
|
|
54
|
+
const username = u.username ? decodeURIComponent(u.username) : "";
|
|
55
|
+
return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The `http.extraheader` value that hands a basic-auth credential to a
|
|
63
|
+
* SINGLE git invocation (base64 of `user:token`) — so a freshly-minted forge
|
|
64
|
+
* token authenticates one operation without ever being written to
|
|
65
|
+
* `.git/config`. Pure — unit-tested.
|
|
66
|
+
* @param {string} username
|
|
67
|
+
* @param {string} token
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function basicAuthExtraHeader(username, token) {
|
|
71
|
+
const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
|
|
72
|
+
return `Authorization: Basic ${b64}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse git's credential-helper protocol (key=value lines, terminated by a
|
|
77
|
+
* blank line or EOF) into a plain object. A malformed line is skipped rather
|
|
78
|
+
* than throwing — git's own helpers are lenient the same way. Pure.
|
|
79
|
+
* @param {string} text
|
|
80
|
+
* @returns {Record<string,string>}
|
|
81
|
+
*/
|
|
82
|
+
export function parseCredentialInput(text) {
|
|
83
|
+
/** @type {Record<string,string>} */
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const line of String(text).split("\n")) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (!trimmed) continue;
|
|
88
|
+
const i = trimmed.indexOf("=");
|
|
89
|
+
if (i <= 0) continue;
|
|
90
|
+
out[trimmed.slice(0, i)] = trimmed.slice(i + 1);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format a credential response for git's `get` operation — only the fields
|
|
97
|
+
* present are emitted (git only needs `username`/`password` filled in;
|
|
98
|
+
* echoing `protocol`/`host` back is harmless and conventional). Pure.
|
|
99
|
+
* @param {Record<string,string|undefined|null>} fields
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function formatCredentialOutput(fields) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
for (const key of ["protocol", "host", "path", "username", "password"]) {
|
|
105
|
+
if (fields[key] !== undefined && fields[key] !== null) lines.push(`${key}=${fields[key]}`);
|
|
106
|
+
}
|
|
107
|
+
return `${lines.join("\n")}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A filesystem-safe, collision-resistant cache key for one (tenant, tag) —
|
|
111
|
+
* hashed (not the raw tenant string) so an unusual tenant name can never
|
|
112
|
+
* escape `~/.tot/git-credentials/` or collide across tags. Pure. */
|
|
113
|
+
export function credentialCacheKey(tenant, tag) {
|
|
114
|
+
return createHash("sha256").update(`${tenant}|${tag}`).digest("hex").slice(0, 32);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Absolute path to one (tenant, tag)'s cached credential — same `~/.tot`
|
|
118
|
+
* root (and `TOT_HOME` override) as the OAuth session cache. */
|
|
119
|
+
export function credentialCachePath(tenant, tag, env = process.env) {
|
|
120
|
+
const home = env.TOT_HOME || homedir();
|
|
121
|
+
return join(home, ".tot", "git-credentials", `${credentialCacheKey(tenant, tag)}.json`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Is a cached credential still trusted? A cache with no `mintedAt` is
|
|
125
|
+
* treated as stale (mint fresh rather than trust an unknown age). Pure. */
|
|
126
|
+
export function isFreshCredential(cred, { now = Date.now(), ttlMs = CREDENTIAL_TTL_MS } = {}) {
|
|
127
|
+
return Boolean(cred && cred.username && cred.password && cred.mintedAt && now - cred.mintedAt < ttlMs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read a cached credential (reusing token-store.mjs's generic reader), or
|
|
131
|
+
* null if absent/unreadable/malformed/stale. Never throws. */
|
|
132
|
+
export function readCachedCredential(filePath, opts = {}) {
|
|
133
|
+
const cred = readCredentials(filePath);
|
|
134
|
+
return isFreshCredential(cred, opts) ? cred : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Cache a freshly-minted credential — atomic write, owner-only permissions
|
|
138
|
+
* (0600 in a 0700 dir, via token-store.mjs's writer): this file holds a LIVE
|
|
139
|
+
* forge push token, same security bar as the OAuth session cache. */
|
|
140
|
+
export function writeCachedCredential(filePath, { username, password }, { now = Date.now() } = {}) {
|
|
141
|
+
writeCredentials(filePath, { username, password, mintedAt: now });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Self-heal a LEGACY checkout: if `origin`'s remote still carries an
|
|
146
|
+
* embedded token (the pre-u10 `tot clone` shape, or one predating `tot
|
|
147
|
+
* login` entirely), strip it — rewriting the remote to the tokenless public
|
|
148
|
+
* URL — and install the credential helper so future git operations mint
|
|
149
|
+
* fresh creds through `tot` instead of relying on a token that silently
|
|
150
|
+
* expires. Meant to be called at the top of every command that touches git,
|
|
151
|
+
* best-effort (the caller decides how to handle a thrown error — this never
|
|
152
|
+
* blocks the actual command on a migration hiccup). A no-op on an
|
|
153
|
+
* already-migrated, tokenless, or non-http(s) (e.g. ssh) remote.
|
|
154
|
+
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
155
|
+
* @returns {{ migrated: boolean }}
|
|
156
|
+
*/
|
|
157
|
+
export function ensureTokenlessRemote(git) {
|
|
158
|
+
let remote;
|
|
159
|
+
try {
|
|
160
|
+
remote = git(["remote", "get-url", "origin"]).trim();
|
|
161
|
+
} catch {
|
|
162
|
+
return { migrated: false }; // no `origin` (or not a git repo) — nothing to migrate
|
|
163
|
+
}
|
|
164
|
+
let migrated = false;
|
|
165
|
+
try {
|
|
166
|
+
const u = new URL(remote);
|
|
167
|
+
if (u.password) {
|
|
168
|
+
git(["remote", "set-url", "origin", `${u.protocol}//${u.host}${u.pathname}`]);
|
|
169
|
+
migrated = true;
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
return { migrated }; // not a parseable URL (e.g. an ssh remote) — leave it alone entirely
|
|
173
|
+
}
|
|
174
|
+
let helper = "";
|
|
175
|
+
try {
|
|
176
|
+
helper = git(["config", "--local", "--get", "credential.helper"]).trim();
|
|
177
|
+
} catch {
|
|
178
|
+
/* unset — falls through to configuring it below */
|
|
179
|
+
}
|
|
180
|
+
if (helper !== CREDENTIAL_HELPER) {
|
|
181
|
+
git(["config", "--local", "credential.helper", CREDENTIAL_HELPER]);
|
|
182
|
+
migrated = true;
|
|
183
|
+
}
|
|
184
|
+
return { migrated };
|
|
185
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -142,7 +142,12 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
142
142
|
return parsed?.result;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Call an MCP tool and unwrap its structured / text result to a plain object.
|
|
147
|
+
* @param {string} name
|
|
148
|
+
* @param {unknown} [args]
|
|
149
|
+
* @returns {Promise<any>}
|
|
150
|
+
*/
|
|
146
151
|
async function callTool(name, args) {
|
|
147
152
|
const r = await callRaw("tools/call", { name, arguments: args });
|
|
148
153
|
if (r?.structuredContent) return r.structuredContent;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Regression guard: the `tot` CLI must never print/reference a raw Gitea
|
|
2
|
+
// forge URL to a developer's terminal. Gitea's API returns `html_url` when a
|
|
3
|
+
// PR is opened (e.g. `https://git.tokenoftrust.com/storefront/<repo>/pulls/<n>`)
|
|
4
|
+
// -- the CLI must surface the storefront-owned `/preview/<tenant>/pr/<n>` link
|
|
5
|
+
// instead (see apps/storefront's matching noGiteaLinks.test.ts and its header
|
|
6
|
+
// for the full architecture reasoning: apps/CLI never expose the forge
|
|
7
|
+
// directly, the MCP proxies every read/write). Precipitating incident
|
|
8
|
+
// (2026-08-18): a raw git.tokenoftrust.com PR URL reached an owner reviewing
|
|
9
|
+
// tokenoftrust.com. Test fixtures are exempt (they legitimately mock a Gitea
|
|
10
|
+
// URL to test the forge client), everything else in `src` must stay clean.
|
|
11
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { join, relative, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
|
|
17
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
18
|
+
const SRC_ROOT = dirname(SELF);
|
|
19
|
+
|
|
20
|
+
const TEST_FILE_RE = /\.test\.[cm]?js$/;
|
|
21
|
+
const FORGE_HOST_RE = /\bgit\.tokenoftrust\.com\b/i;
|
|
22
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
|
|
23
|
+
|
|
24
|
+
function walk(dir, out = []) {
|
|
25
|
+
for (const entry of readdirSync(dir)) {
|
|
26
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
27
|
+
const full = join(dir, entry);
|
|
28
|
+
const st = statSync(full);
|
|
29
|
+
if (st.isDirectory()) {
|
|
30
|
+
walk(full, out);
|
|
31
|
+
} else if (/\.[cm]?js$/.test(entry)) {
|
|
32
|
+
out.push(full);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("no Gitea forge links in the CLI source outside test fixtures", () => {
|
|
39
|
+
const offenders = [];
|
|
40
|
+
for (const file of walk(SRC_ROOT)) {
|
|
41
|
+
if (TEST_FILE_RE.test(file)) continue;
|
|
42
|
+
if (file === SELF) continue;
|
|
43
|
+
const content = readFileSync(file, "utf8");
|
|
44
|
+
content.split("\n").forEach((line, i) => {
|
|
45
|
+
if (FORGE_HOST_RE.test(line)) {
|
|
46
|
+
offenders.push(`${relative(SRC_ROOT, file)}:${i + 1}: ${line.trim()}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
offenders,
|
|
52
|
+
[],
|
|
53
|
+
`Found Gitea forge links in non-test CLI source:\n${offenders.join("\n")}`,
|
|
54
|
+
);
|
|
55
|
+
});
|
package/src/oauth.mjs
CHANGED
|
@@ -84,8 +84,15 @@ export async function registerClient(
|
|
|
84
84
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
85
85
|
body: JSON.stringify({
|
|
86
86
|
client_name: CLIENT_NAME,
|
|
87
|
+
// Include the device-code grant: the browserless rendezvous + B3 device flows
|
|
88
|
+
// redeem their device_code at /oauth/token with this grant, so a client
|
|
89
|
+
// registered WITHOUT it gets "unauthorized_client: grant_type is invalid".
|
|
90
|
+
grant_types: [
|
|
91
|
+
"authorization_code",
|
|
92
|
+
"refresh_token",
|
|
93
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
94
|
+
],
|
|
87
95
|
redirect_uris: [redirectUri],
|
|
88
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
89
96
|
response_types: ["code"],
|
|
90
97
|
token_endpoint_auth_method: "none",
|
|
91
98
|
scope: SCOPE,
|
|
@@ -181,7 +188,7 @@ export function startLoopbackListener({ host = "127.0.0.1" } = {}) {
|
|
|
181
188
|
let settle, reject;
|
|
182
189
|
const callback = new Promise((res, rej) => { settle = res; reject = rej; });
|
|
183
190
|
const server = http.createServer((req, res) => {
|
|
184
|
-
const u = new URL(req.url, `http://${host}`);
|
|
191
|
+
const u = new URL(req.url ?? "/", `http://${host}`);
|
|
185
192
|
if (u.pathname !== "/callback") {
|
|
186
193
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
187
194
|
res.end("not found");
|
|
@@ -197,12 +204,12 @@ export function startLoopbackListener({ host = "127.0.0.1" } = {}) {
|
|
|
197
204
|
settle({ code: u.searchParams.get("code"), state: u.searchParams.get("state") });
|
|
198
205
|
}
|
|
199
206
|
});
|
|
200
|
-
const listening = new Promise((res, rej) => {
|
|
207
|
+
const listening = /** @type {Promise<void>} */ (new Promise((res, rej) => {
|
|
201
208
|
server.once("error", rej);
|
|
202
209
|
server.listen(0, host, () => res());
|
|
203
|
-
});
|
|
210
|
+
}));
|
|
204
211
|
return {
|
|
205
|
-
async ready() { await listening; return server.address().port; },
|
|
212
|
+
async ready() { await listening; return /** @type {import("net").AddressInfo} */ (server.address()).port; },
|
|
206
213
|
waitForCallback() { return callback; },
|
|
207
214
|
close() { try { server.close(); } catch { /* already closed */ } },
|
|
208
215
|
};
|
|
@@ -240,7 +247,7 @@ export async function loginFlow({
|
|
|
240
247
|
clientId,
|
|
241
248
|
fetchImpl = fetch,
|
|
242
249
|
open = openBrowser,
|
|
243
|
-
log = () => {},
|
|
250
|
+
log = /** @type {(m?: string) => void} */ (() => {}),
|
|
244
251
|
now = () => Date.now(),
|
|
245
252
|
}) {
|
|
246
253
|
const meta = await discoverMetadata(mcpUrl, fetchImpl);
|
|
@@ -355,15 +362,19 @@ export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientI
|
|
|
355
362
|
*/
|
|
356
363
|
export async function rendezvousLoginFlow({
|
|
357
364
|
mcpUrl,
|
|
358
|
-
clientId,
|
|
359
365
|
code,
|
|
360
366
|
fetchImpl = fetch,
|
|
361
|
-
log = () => {},
|
|
367
|
+
log = /** @type {(m?: string) => void} */ (() => {}),
|
|
362
368
|
sleep = delay,
|
|
363
369
|
now = () => Date.now(),
|
|
364
370
|
}) {
|
|
365
371
|
const meta = await discoverMetadata(mcpUrl, fetchImpl);
|
|
366
|
-
|
|
372
|
+
// Always register a FRESH client for a rendezvous sign-in — do NOT reuse a cached
|
|
373
|
+
// clientId. A client cached by an older CLI build was registered without the
|
|
374
|
+
// device-code grant, so /oauth/token would reject the device grant with
|
|
375
|
+
// "unauthorized_client". Registration is cheap and a rendezvous has no consent
|
|
376
|
+
// screen to re-trigger (unlike the loopback/device flows, which reuse the cache).
|
|
377
|
+
const resolvedClientId = await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl);
|
|
367
378
|
const { verifier, challenge } = generatePkce();
|
|
368
379
|
|
|
369
380
|
const attach = await attachRendezvous(
|
|
@@ -463,11 +474,15 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifi
|
|
|
463
474
|
* expires), honoring the server's `interval` and the `slow_down` backoff
|
|
464
475
|
* (RFC 8628 §3.5: +5s, keep polling — not a failure). Injectable `sleep`/
|
|
465
476
|
* `now` so it's testable with no real waiting.
|
|
477
|
+
* @param {string} tokenEndpoint
|
|
478
|
+
* @param {{ deviceCode: any, clientId: any, codeVerifier?: any, intervalSec?: any, expiresInSec?: any }} params
|
|
479
|
+
* @param {typeof fetch} [fetchImpl]
|
|
480
|
+
* @param {{ sleep?: Function, now?: () => number }} [timing]
|
|
466
481
|
* @returns {Promise<object>} the raw token response (→ credentialsFromToken)
|
|
467
482
|
*/
|
|
468
483
|
export async function pollDeviceToken(
|
|
469
484
|
tokenEndpoint,
|
|
470
|
-
{ deviceCode, clientId, codeVerifier, intervalSec, expiresInSec },
|
|
485
|
+
{ deviceCode, clientId, codeVerifier = undefined, intervalSec, expiresInSec },
|
|
471
486
|
fetchImpl = fetch,
|
|
472
487
|
{ sleep = delay, now = () => Date.now() } = {},
|
|
473
488
|
) {
|
|
@@ -494,7 +509,7 @@ export async function deviceLoginFlow({
|
|
|
494
509
|
mcpUrl,
|
|
495
510
|
clientId,
|
|
496
511
|
fetchImpl = fetch,
|
|
497
|
-
log = () => {},
|
|
512
|
+
log = /** @type {(m?: string) => void} */ (() => {}),
|
|
498
513
|
sleep = delay,
|
|
499
514
|
now = () => Date.now(),
|
|
500
515
|
}) {
|