@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.
Files changed (54) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +148 -57
  3. package/package.json +6 -1
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +498 -59
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +297 -0
  13. package/src/commands/cleanup.mjs +264 -0
  14. package/src/commands/clone.mjs +307 -25
  15. package/src/commands/dev.mjs +440 -156
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +180 -0
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +428 -0
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +62 -25
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +322 -0
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +51 -14
  32. package/src/commands/start.mjs +101 -59
  33. package/src/commands/submit.mjs +1183 -169
  34. package/src/commands/sync.mjs +203 -0
  35. package/src/commands/validate.mjs +10 -4
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +257 -0
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/no-gitea-links.test.mjs +55 -0
  45. package/src/oauth.mjs +18 -14
  46. package/src/obstacle-beacon.cjs +2 -2
  47. package/src/obstacle.mjs +1 -1
  48. package/src/plan.mjs +83 -15
  49. package/src/sample.mjs +4 -4
  50. package/src/validate.mjs +187 -15
  51. package/src/vendor/private-apps-devkit.mjs +3 -3
  52. package/src/viewer-session.mjs +118 -0
  53. package/template/private-app/README.md +12 -6
  54. package/src/commands/retire.mjs +0 -203
@@ -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"):
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: `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
+ }
@@ -71,12 +71,13 @@ export function run(argv, ctx) {
71
71
  console.error(fail(target.error, "tot clone <tenant>, or pass --workspace <dir>"));
72
72
  return 2;
73
73
  }
74
- if (!existsSync(target.dir)) {
75
- console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot clone <tenant>`"));
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(target.dir, {
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
- console.log(`\ntot validate${target.tenantId ?? target.dir}\n`);
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}` : ""}`);
@@ -67,7 +67,7 @@ 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
- // Status-aware (card c2): an UNLINKED identity is told to link (not "may
70
+ // Status-aware: an UNLINKED identity is told to link (not "may
71
71
  // still be propagating" — that's only the genuine zero-grants case).
72
72
  const g = noStoresGuidance(listResp);
73
73
  console.log(` (${g.headline})`);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLI-side heartbeat for the local→hosted activity bridge (G1).
2
+ * CLI-side heartbeat for the local→hosted activity bridge.
3
3
  *
4
4
  * While `tot dev` / `tot start` is running, the CLI (which knows its own
5
5
  * version, the resolved runner version, the port + tenant, and the cached
@@ -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 }} args
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/dev-logs.mjs CHANGED
@@ -18,7 +18,7 @@ import { milestoneBanner, DEVELOPER_COCKPIT } from "./banner.mjs";
18
18
  * noise, and pass anything else through (indented) so nothing important is
19
19
  * hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
20
20
  *
21
- * The FIRST save→reload is the aha milestone (u2): with a `cockpitUrl` it's a
21
+ * The FIRST save→reload is the aha milestone: with a `cockpitUrl` it's a
22
22
  * PROMINENT banner that sends the developer back to their Developer Cockpit to
23
23
  * see the change; every reload after that is the quiet "↻ your store reloaded"
24
24
  * line so a working dev loop doesn't get spammed with banners.
@@ -35,7 +35,7 @@ export function streamDevLogs(
35
35
  // the runner/Vite "HH:MM:SS " timestamp prefix (see `body` below), so a
36
36
  // timestamped internal line like "10:50:17 [vite] connected" is still dropped.
37
37
  // The node:* / ExperimentalWarning / (node:NNNN) / "--trace-warnings" and the
38
- // boot "fatal: not a git repository" lines are dropped too (b4) — scary,
38
+ // boot "fatal: not a git repository" lines are dropped too — scary,
39
39
  // non-actionable boot noise that reads as a broken first run.
40
40
  const NOISE =
41
41
  /^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d|\(node:\d+\)|ExperimentalWarning|node:internal\/|\(Use `node --trace-warnings|fatal: not a git repository)/i;
package/src/errors.mjs CHANGED
@@ -32,13 +32,20 @@ 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 {{ next?: string, exitCode?: number, cause?: unknown }} [opts]
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, { next, exitCode = 1, cause } = {}) {
44
+ /** True when retrying the operation cannot change its outcome. */
45
+ permanent;
46
+
47
+ constructor(what, opts = {}) {
48
+ const { next, exitCode = 1, cause } = opts;
42
49
  super(what);
43
50
  this.name = "CliError";
44
51
  this.what = what;
@@ -0,0 +1,257 @@
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, dirname } from "node:path";
23
+ import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
24
+ import { readCredentials, writeCredentials } from "./token-store.mjs";
25
+
26
+ /** The git config value that routes credential requests through `tot` — the
27
+ * `!` tells git to run this as a shell command (git appends the operation,
28
+ * e.g. `get`, as the final argument) — the same convention `gh auth
29
+ * git-credential` uses. Repo-LOCAL only (never --global): a checkout not
30
+ * built with `tot` must never have its credential resolution silently
31
+ * redirected. */
32
+ export const CREDENTIAL_HELPER = "!tot git-credential";
33
+
34
+ /** How long a minted credential is trusted before `tot git-credential get`
35
+ * mints a fresh one — comfortably under the forge push token's own
36
+ * multi-hour expiry (see pushPreviewRef in submit.mjs), so a long-running
37
+ * session still self-refreshes well before the cached one goes stale. */
38
+ export const CREDENTIAL_TTL_MS = 20 * 60 * 1000;
39
+
40
+ /**
41
+ * Split an authenticated forge remote URL (basic-auth `user:token@host`, as
42
+ * the MCP mints it via `tenant_checkout`) into its tokenless public URL +
43
+ * the embedded credential, so the token can be handed to git EPHEMERALLY for
44
+ * one operation instead of being persisted in `.git/config`. Returns null
45
+ * when the URL won't parse or carries no token — the caller then falls back
46
+ * to the checkout's existing remote. Pure — unit-tested.
47
+ * @param {string} remoteUrl
48
+ * @returns {{ publicUrl: string, username: string, token: string }|null}
49
+ */
50
+ export function splitAuthedRemote(remoteUrl) {
51
+ try {
52
+ const u = new URL(String(remoteUrl));
53
+ const token = u.password ? decodeURIComponent(u.password) : "";
54
+ if (!token) return null;
55
+ const username = u.username ? decodeURIComponent(u.username) : "";
56
+ return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The `http.extraheader` value that hands a basic-auth credential to a
64
+ * SINGLE git invocation (base64 of `user:token`) — so a freshly-minted forge
65
+ * token authenticates one operation without ever being written to
66
+ * `.git/config`. Pure — unit-tested.
67
+ * @param {string} username
68
+ * @param {string} token
69
+ * @returns {string}
70
+ */
71
+ export function basicAuthExtraHeader(username, token) {
72
+ const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
73
+ return `Authorization: Basic ${b64}`;
74
+ }
75
+
76
+ /**
77
+ * Parse git's credential-helper protocol (key=value lines, terminated by a
78
+ * blank line or EOF) into a plain object. A malformed line is skipped rather
79
+ * than throwing — git's own helpers are lenient the same way. Pure.
80
+ * @param {string} text
81
+ * @returns {Record<string,string>}
82
+ */
83
+ export function parseCredentialInput(text) {
84
+ /** @type {Record<string,string>} */
85
+ const out = {};
86
+ for (const line of String(text).split("\n")) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed) continue;
89
+ const i = trimmed.indexOf("=");
90
+ if (i <= 0) continue;
91
+ out[trimmed.slice(0, i)] = trimmed.slice(i + 1);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Format a credential response for git's `get` operation — only the fields
98
+ * present are emitted (git only needs `username`/`password` filled in;
99
+ * echoing `protocol`/`host` back is harmless and conventional). Pure.
100
+ * @param {Record<string,string|undefined|null>} fields
101
+ * @returns {string}
102
+ */
103
+ export function formatCredentialOutput(fields) {
104
+ const lines = [];
105
+ for (const key of ["protocol", "host", "path", "username", "password"]) {
106
+ if (fields[key] !== undefined && fields[key] !== null) lines.push(`${key}=${fields[key]}`);
107
+ }
108
+ return `${lines.join("\n")}\n`;
109
+ }
110
+
111
+ /** A filesystem-safe, collision-resistant cache key for one (tenant, tag) —
112
+ * hashed (not the raw tenant string) so an unusual tenant name can never
113
+ * escape `~/.tot/git-credentials/` or collide across tags. Pure. */
114
+ export function credentialCacheKey(tenant, tag) {
115
+ return createHash("sha256").update(`${tenant}|${tag}`).digest("hex").slice(0, 32);
116
+ }
117
+
118
+ /** Absolute path to one (tenant, tag)'s cached credential — same `~/.tot`
119
+ * root (and `TOT_HOME` override) as the OAuth session cache. */
120
+ export function credentialCachePath(tenant, tag, env = process.env) {
121
+ const home = env.TOT_HOME || homedir();
122
+ return join(home, ".tot", "git-credentials", `${credentialCacheKey(tenant, tag)}.json`);
123
+ }
124
+
125
+ /** Is a cached credential still trusted? A cache with no `mintedAt` is
126
+ * treated as stale (mint fresh rather than trust an unknown age). Pure. */
127
+ export function isFreshCredential(cred, { now = Date.now(), ttlMs = CREDENTIAL_TTL_MS } = {}) {
128
+ return Boolean(cred && cred.username && cred.password && cred.mintedAt && now - cred.mintedAt < ttlMs);
129
+ }
130
+
131
+ /** Read a cached credential (reusing token-store.mjs's generic reader), or
132
+ * null if absent/unreadable/malformed/stale. Never throws. */
133
+ export function readCachedCredential(filePath, opts = {}) {
134
+ const cred = readCredentials(filePath);
135
+ return isFreshCredential(cred, opts) ? cred : null;
136
+ }
137
+
138
+ /** Cache a freshly-minted credential — atomic write, owner-only permissions
139
+ * (0600 in a 0700 dir, via token-store.mjs's writer): this file holds a LIVE
140
+ * forge push token, same security bar as the OAuth session cache. */
141
+ export function writeCachedCredential(filePath, { username, password }, { now = Date.now() } = {}) {
142
+ writeCredentials(filePath, { username, password, mintedAt: now });
143
+ }
144
+
145
+ /** Single-quote a string for safe interpolation into the /bin/sh shim. Pure. */
146
+ function shq(s) {
147
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
148
+ }
149
+
150
+ /** Absolute path to the stable, node-version-independent credential-helper shim
151
+ * under `~/.tot/bin` (honors `TOT_HOME`). A checkout's git config points at THIS by
152
+ * absolute path — never a bare `!tot` — so `nvm use` (which swaps the per-node `tot`)
153
+ * can't silently redirect forge auth to an older/missing CLI. Pure. */
154
+ export function forgeShimPath(env = process.env) {
155
+ const home = env.TOT_HOME || homedir();
156
+ return join(home, ".tot", "bin", "tot-git-credential");
157
+ }
158
+
159
+ /** The shim's contents: pin the ABSOLUTE node + CLI entry active at write time, with a
160
+ * PATH-search fallback for node, and FAIL LOUD to stderr (never silently) when no node
161
+ * is found — so a broken helper says how to fix itself instead of yielding an opaque
162
+ * "Repository not found". Pure — unit-tested. */
163
+ export function renderForgeShim(nodePath, entryPath) {
164
+ return [
165
+ "#!/bin/sh",
166
+ "# Managed by tot — stable forge credential helper (regenerated each run; do not edit).",
167
+ "# Decouples git auth from which node/tot is active in the shell (nvm-proof).",
168
+ `NODE=${shq(nodePath)}`,
169
+ '[ -x "$NODE" ] || NODE="$(command -v node 2>/dev/null)"',
170
+ 'if [ -z "$NODE" ]; then',
171
+ ' echo "tot: no node runtime for the git credential helper — reinstall: npm i -g @tokenoftrust/cli@latest" >&2',
172
+ " exit 1",
173
+ "fi",
174
+ `exec "$NODE" ${shq(entryPath)} git-credential "$@"`,
175
+ "",
176
+ ].join("\n");
177
+ }
178
+
179
+ /** Write/refresh the shim (0700 dir, 0755 file). Best-effort — returns the shim path,
180
+ * or "" if it couldn't be written (caller then falls back to the bare `!tot` helper). */
181
+ export function writeForgeShim(env = process.env, nodePath = process.execPath, entryPath = process.argv[1]) {
182
+ try {
183
+ if (!nodePath || !entryPath) return "";
184
+ const p = forgeShimPath(env);
185
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
186
+ writeFileSync(p, renderForgeShim(nodePath, entryPath), { mode: 0o755 });
187
+ chmodSync(p, 0o755);
188
+ return p;
189
+ } catch {
190
+ return "";
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Install (or refresh) the HOST-SCOPED forge credential helper on a checkout so that:
196
+ * - it runs the stable `~/.tot` shim by ABSOLUTE path (nvm-proof), not a bare `!tot`;
197
+ * - a leading EMPTY reset value clears any inherited GLOBAL helper for this host — the
198
+ * fix for `credential.helper = osxkeychain` (every Mac dev) running first, returning
199
+ * a stale forge cred, and shadowing our helper so `git pull` 404s even after login.
200
+ * Idempotent: a no-op when the host-scoped list is already `["", <ourHelper>]`. Returns
201
+ * whether it changed anything. `writeShim` is injected for tests. Pure git I/O otherwise.
202
+ * @param {(cargs: string[]) => string} git
203
+ * @param {{ host?: string, env?: NodeJS.ProcessEnv, nodePath?: string, entryPath?: string, writeShim?: typeof writeForgeShim }} [options]
204
+ * @returns {boolean} changed
205
+ */
206
+ export function installForgeCredentialHelper(git, {
207
+ host, env = process.env, nodePath = process.execPath, entryPath = process.argv[1],
208
+ writeShim = writeForgeShim,
209
+ } = {}) {
210
+ if (!host) return false;
211
+ const shim = writeShim(env, nodePath, entryPath);
212
+ const helperValue = shim ? `!${shim}` : CREDENTIAL_HELPER;
213
+ const key = `credential.https://${host}.helper`;
214
+ let raw = null;
215
+ try { raw = git(["config", "--local", "--get-all", key]); } catch { raw = null; }
216
+ // git prints one value per line (an empty value = an empty line); our desired list is
217
+ // ["", helperValue] → "\n<helperValue>". Already correct ⇒ nothing to do.
218
+ if (raw !== null && raw.replace(/\n+$/, "") === `\n${helperValue}`) return false;
219
+ try { git(["config", "--local", "--unset-all", key]); } catch { /* none set yet */ }
220
+ git(["config", "--local", "--add", key, ""]); // reset: clear inherited (osxkeychain) for this host
221
+ git(["config", "--local", "--add", key, helperValue]); // our helper is now the sole one
222
+ return true;
223
+ }
224
+
225
+ /**
226
+ * Self-heal a LEGACY checkout, best-effort: strip any embedded token from `origin`
227
+ * (the pre-u10 shape whose token silently expires), then install the stable, host-scoped
228
+ * credential helper (see installForgeCredentialHelper) so future git ops mint fresh creds
229
+ * through the `~/.tot` shim — nvm-proof and un-shadowable by osxkeychain. Called at the top
230
+ * of every command that touches git. A no-op on an already-migrated remote; leaves a
231
+ * non-http(s) (e.g. ssh) remote entirely alone. Never blocks the command on a hiccup.
232
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
233
+ * @param {{ env?: NodeJS.ProcessEnv, nodePath?: string, entryPath?: string, writeShim?: typeof writeForgeShim }} [opts]
234
+ * @returns {{ migrated: boolean }}
235
+ */
236
+ export function ensureTokenlessRemote(git, opts = {}) {
237
+ let remote;
238
+ try {
239
+ remote = git(["remote", "get-url", "origin"]).trim();
240
+ } catch {
241
+ return { migrated: false }; // no `origin` (or not a git repo) — nothing to migrate
242
+ }
243
+ let migrated = false;
244
+ let host = "";
245
+ try {
246
+ const u = new URL(remote);
247
+ host = u.host;
248
+ if (u.password) {
249
+ git(["remote", "set-url", "origin", `${u.protocol}//${u.host}${u.pathname}`]);
250
+ migrated = true;
251
+ }
252
+ } catch {
253
+ return { migrated }; // not a parseable URL (e.g. an ssh remote) — leave it alone entirely
254
+ }
255
+ if (installForgeCredentialHelper(git, { ...opts, host })) migrated = true;
256
+ return { migrated };
257
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The "last tenant" cache for `tot start` (A4 — smart zero-arg default). After
2
+ * The "last tenant" cache for `tot start` (smart zero-arg default). After
3
3
  * a multi-store identity picks (or is told) a tenant, remember it here so a
4
4
  * bare `tot start` on the next run just goes instead of re-prompting.
5
5
  *
package/src/mcp.mjs CHANGED
@@ -142,7 +142,12 @@ export function createMcpClient(baseUrl, opts = {}) {
142
142
  return parsed?.result;
143
143
  }
144
144
 
145
- /** Call an MCP tool and unwrap its structured / text result to a plain object. */
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;