@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.
Files changed (47) hide show
  1. package/README.md +12 -9
  2. package/bin/tot.mjs +219 -44
  3. package/package.json +7 -2
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +2 -2
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +137 -0
  8. package/src/commands/accept.mjs +736 -0
  9. package/src/commands/app/dev.mjs +7 -3
  10. package/src/commands/app/index.mjs +2 -2
  11. package/src/commands/branches.mjs +297 -0
  12. package/src/commands/cleanup.mjs +269 -0
  13. package/src/commands/clone.mjs +713 -0
  14. package/src/commands/dev.mjs +441 -93
  15. package/src/commands/doctor.mjs +4 -3
  16. package/src/commands/git-credential.mjs +180 -0
  17. package/src/commands/go-live.mjs +486 -0
  18. package/src/commands/grants.mjs +14 -7
  19. package/src/commands/hotfix.mjs +428 -0
  20. package/src/commands/link.mjs +225 -0
  21. package/src/commands/login.mjs +12 -8
  22. package/src/commands/pr.mjs +425 -0
  23. package/src/commands/preview-build.mjs +225 -0
  24. package/src/commands/preview.mjs +80 -0
  25. package/src/commands/retire.mjs +203 -0
  26. package/src/commands/revert.mjs +322 -0
  27. package/src/commands/rollback.mjs +403 -0
  28. package/src/commands/ship.mjs +517 -0
  29. package/src/commands/start.mjs +91 -29
  30. package/src/commands/submit.mjs +1360 -131
  31. package/src/commands/sync.mjs +203 -0
  32. package/src/commands/validate.mjs +11 -5
  33. package/src/commands/whoami.mjs +6 -2
  34. package/src/context.mjs +2 -2
  35. package/src/dev-heartbeat.mjs +2 -1
  36. package/src/errors.mjs +8 -4
  37. package/src/git-credential.mjs +185 -0
  38. package/src/mcp.mjs +6 -1
  39. package/src/no-gitea-links.test.mjs +55 -0
  40. package/src/oauth.mjs +26 -11
  41. package/src/obstacle-beacon.cjs +3 -3
  42. package/src/obstacle.mjs +1 -1
  43. package/src/plan.mjs +262 -0
  44. package/src/sample.mjs +30 -4
  45. package/src/validate.mjs +56 -0
  46. package/src/viewer-session.mjs +118 -0
  47. package/src/commands/checkout.mjs +0 -330
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `tot doctor` — is this machine ready to run the loop?
3
3
  *
4
- * Standalone (unlike the in-monorepo doctor): checks the things `tot checkout`
4
+ * Standalone (unlike the in-monorepo doctor): checks the things `tot clone`
5
5
  * and `tot dev`/`tot start` actually need — Node, git, an MCP URL, whether an
6
6
  * auth identity is available, and Docker — plus reports the detected context
7
7
  * so a developer knows which mode `tot` will use here.
@@ -187,7 +187,8 @@ export async function run(argv, ctx) {
187
187
  }
188
188
  console.log(
189
189
  failed === 0
190
- ? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot checkout\n"
190
+ ? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot clone." +
191
+ "\n The loop: tot dev → tot preview → tot ship.\n"
191
192
  : args.fix
192
193
  ? `\n✖ ${failed} check(s) still failing — see the exact next command(s) above, then re-run \`tot doctor\`.\n`
193
194
  : `\n✖ ${failed} check(s) failed — try \`tot doctor --fix\`, or fix the above, then re-run \`tot doctor\`.\n`,
@@ -199,5 +200,5 @@ function describeContext(ctx) {
199
200
  if (ctx.mode === "monorepo") return `storefront monorepo (${ctx.repoRoot})`;
200
201
  if (ctx.mode === "checkout")
201
202
  return `tenant checkout${ctx.tenant ? ` for "${ctx.tenant}"` : ""} (${ctx.workspacePath})`;
202
- return "loose (not inside a checkout — `tot checkout <tenant> --clone <dir>` to get one)";
203
+ return "loose (not inside a checkout — `tot clone <tenant>` to get one)";
203
204
  }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `tot git-credential` — git's own credential-helper protocol (see `git help
3
+ * gitcredentials`), implemented over the developer's cached `tot login`
4
+ * session (unit u10, workstream tot-merge-conflict-resolution-ux). `tot
5
+ * clone` configures a fresh checkout's `credential.helper` to run this (see
6
+ * ../git-credential.mjs's CREDENTIAL_HELPER), so `git fetch`/`git push`/`git
7
+ * pull` — run DIRECTLY by the developer, not just through `tot preview` —
8
+ * transparently mint a fresh forge token instead of ever needing one
9
+ * persisted in `.git/config`. This is the fix for the "the panel-prescribed
10
+ * `git fetch origin` fails even after `tot login`" dead-end: `tot login`
11
+ * only ever refreshed the CLI's OWN MCP session, never the token baked into
12
+ * a checkout's remote URL at clone time.
13
+ *
14
+ * git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
15
+ * request on stdin (key=value lines, blank-line/EOF terminated). Only `get`
16
+ * does real work — this CLI never persists a forge credential of its own
17
+ * beyond the short-lived cache in ../git-credential.mjs, so `store`/`erase`
18
+ * are no-ops (git calls them after a successful/failed auth respectively; we
19
+ * just drain stdin and exit 0, the correct behavior for a stateless helper).
20
+ *
21
+ * FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
22
+ * problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
23
+ * checkout) — git then falls through to its next configured helper or its
24
+ * own prompt, exactly as if this helper weren't configured. A stack trace or
25
+ * a malformed credential line here would otherwise corrupt EVERY git
26
+ * operation in the checkout. It also NEVER triggers an interactive sign-in —
27
+ * this runs as a non-interactive subprocess of `git`, so a missing session
28
+ * fails through rather than trying to open a browser mid-`git fetch`.
29
+ *
30
+ * Dependency-free (global fetch + `git`, via the same MCP client + auth
31
+ * module every other command uses).
32
+ */
33
+ import { readFileSync } from "node:fs";
34
+ import { execFileSync } from "node:child_process";
35
+ import { createMcpClient } from "../mcp.mjs";
36
+ import { establishSession } from "../auth.mjs";
37
+ import { checkoutTenant } from "./clone.mjs";
38
+ import { detectContext } from "../context.mjs";
39
+ import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
40
+ import {
41
+ parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
42
+ credentialCachePath, readCachedCredential, writeCachedCredential,
43
+ } from "../git-credential.mjs";
44
+
45
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
46
+
47
+ /** Does `remoteUrl`'s host equal `host` (case-insensitive)? An unparseable or
48
+ * missing remote URL never matches — fail closed. Exported for testing. */
49
+ export function hostMatches(remoteUrl, host) {
50
+ if (!remoteUrl || !host) return false;
51
+ try {
52
+ // scp-like remotes (git@host:owner/repo.git) have no scheme — synthesize one so URL can parse the host.
53
+ const normalized = /^[^/]+@[^/:]+:/.test(remoteUrl) ? `ssh://${remoteUrl.replace(":", "/")}` : remoteUrl;
54
+ return new URL(normalized).host.toLowerCase() === host.toLowerCase();
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** Read the credential request git writes to stdin — blocking is correct
61
+ * here: git writes the request then closes its end, so this returns as
62
+ * soon as it's fully sent. Never blocks on an interactive terminal (git
63
+ * NEVER attaches a TTY to a credential helper's stdin — only a human
64
+ * poking at this command directly by hand would) and never throws (an
65
+ * unreadable/absent stdin is treated as an empty request). Exported so a
66
+ * test can inject a canned request instead of touching real fd 0. */
67
+ export function readStdin() {
68
+ if (process.stdin.isTTY) return "";
69
+ try {
70
+ return readFileSync(0, "utf8");
71
+ } catch {
72
+ return "";
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Mint (or reuse a cached) forge credential for the tenant the CURRENT
78
+ * directory checks out — the same `tenant_checkout` MCP call `tot clone`
79
+ * itself uses, so the credential is exactly as push-capable. Returns null on
80
+ * ANY failure (wrong dir, not signed in, MCP unreachable) rather than
81
+ * throwing — `get` treats null as "say nothing, exit non-zero".
82
+ *
83
+ * `createClient`/`establish`/`checkout` are injected (default to the real MCP
84
+ * client + auth + clone.mjs's checkoutTenant) purely so this is testable
85
+ * without a live MCP — same DI shape as chooseChangeId's injected `mint` /
86
+ * resolveDeveloperSession's injected `fetchImpl` elsewhere in this CLI.
87
+ * SCOPED TO THE CHECKOUT'S OWN REMOTE: `expectedHost` (git's requested host,
88
+ * from the credential-helper request) is checked against the host of this
89
+ * checkout's `origin` remote before a credential is ever minted or returned
90
+ * — a `get` for any OTHER host returns null (silent fail), never handing the
91
+ * tenant's forge token to a host this checkout doesn't itself push to. This
92
+ * matters because `credential.helper` is invoked per-URL by git, and a
93
+ * globally-scoped helper (or a checkout with a submodule / unrelated remote)
94
+ * must not become a way to exfiltrate the token to an arbitrary host.
95
+ * @param {{
96
+ * env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string|null,
97
+ * createClient?: typeof createMcpClient,
98
+ * establish?: typeof establishSession,
99
+ * checkout?: typeof checkoutTenant,
100
+ * }} [opts]
101
+ * @returns {Promise<{username:string,password:string}|null>}
102
+ */
103
+ export async function mintOrCacheCredential({
104
+ env = process.env, cwd = process.cwd(), expectedHost = null,
105
+ createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
106
+ } = {}) {
107
+ const ctx = detectContext(cwd);
108
+ if (ctx.mode !== "checkout" || !ctx.tenant) return null;
109
+
110
+ const git = (cargs) =>
111
+ execFileSync("git", ["-C", ctx.workspacePath, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
112
+ let originUrl = null;
113
+ let repoName = null;
114
+ try {
115
+ originUrl = git(["remote", "get-url", "origin"]).trim();
116
+ repoName = repoNameFromRemote(originUrl);
117
+ } catch {
118
+ /* fall through — tagFromRepoName degrades to "main" on a null repo name */
119
+ }
120
+ if (expectedHost && !hostMatches(originUrl, expectedHost)) return null;
121
+ const tag = tagFromRepoName(repoName, ctx.tenant);
122
+
123
+ const cachePath = credentialCachePath(ctx.tenant, tag, env);
124
+ const cached = readCachedCredential(cachePath);
125
+ if (cached) return { username: cached.username, password: cached.password };
126
+
127
+ const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
128
+ const client = createClient(baseUrl);
129
+ try {
130
+ await establish(client, { env });
131
+ } catch {
132
+ return null; // not signed in (or session unrefreshable) — nothing this helper can do
133
+ }
134
+ try {
135
+ const res = await checkout(client, { tenant: ctx.tenant, tag, cloneDir: null });
136
+ const cred = splitAuthedRemote(res.gitRemote || "");
137
+ if (!cred) return null;
138
+ const fresh = { username: cred.username, password: cred.token };
139
+ writeCachedCredential(cachePath, fresh);
140
+ return fresh;
141
+ } catch {
142
+ return null; // MCP unreachable / tenant_checkout refused — say nothing, exit non-zero
143
+ }
144
+ }
145
+
146
+ const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
147
+
148
+ Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
149
+ in every checkout it creates — you should never need to run this by hand.
150
+ See \`git help gitcredentials\` for the protocol this implements.`;
151
+
152
+ /**
153
+ * @param {string[]} argv argv[0] is git's operation: get|store|erase
154
+ * @param {any} _ctx unused — this command derives its OWN context from cwd
155
+ * (mintOrCacheCredential), since git invokes it with the credentialed
156
+ * repo's directory as cwd, which may differ from wherever `tot` itself
157
+ * was dispatched from.
158
+ * @param {{ env?: NodeJS.ProcessEnv, cwd?: string, readStdin?: typeof readStdin }
159
+ * & Parameters<typeof mintOrCacheCredential>[0]} [opts]
160
+ */
161
+ export async function run(argv, _ctx, opts = {}) {
162
+ const { env = process.env, readStdin: read = readStdin } = opts;
163
+ const op = argv[0];
164
+ if (!op || op === "--help" || op === "-h") {
165
+ console.log(USAGE);
166
+ return op ? 0 : 2;
167
+ }
168
+ // git always writes a request to stdin, even for store/erase — drain it either way
169
+ // so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
170
+ const request = parseCredentialInput(read());
171
+
172
+ if (op !== "get") return 0; // store/erase: stateless, nothing to persist or drop.
173
+
174
+ const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
175
+ if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
176
+ process.stdout.write(formatCredentialOutput({
177
+ protocol: request.protocol, host: request.host, username: cred.username, password: cred.password,
178
+ }));
179
+ return 0;
180
+ }