@tokenoftrust/cli 1.4.0-rc.22 → 1.4.0-rc.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.22",
3
+ "version": "1.4.0-rc.24",
4
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",
@@ -444,6 +444,41 @@ export function baseCommitsBehind(git, branch) {
444
444
  }
445
445
  }
446
446
 
447
+ /**
448
+ * Resolve the git ref to diff HEAD against for the candidate's file patch (and its
449
+ * summary). The candidate PR is `preview + your file changes`, and `candidate_open`
450
+ * SERVER-CUTS the candidate branch back to the current preview tip and re-applies the
451
+ * patch — so the patch MUST be the FULL delta of your branch vs its fork point off
452
+ * `preview`, never "what changed since my last candidate push". Diffing against your
453
+ * own candidate tracking ref is what silently produced an empty patch (and the wrong
454
+ * "no file changes" skip, so no PR opened) after a push that landed but failed to open
455
+ * its PR: that ref already equals HEAD, so the since-last-push delta is empty even
456
+ * though the change vs preview is 19 files. Prefer the merge-base (fork point) with
457
+ * `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
458
+ * (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
459
+ * runner — unit-tested.
460
+ * @param {(cargs:string[])=>string} git
461
+ * @param {string} ref the candidate ref (e.g. "candidate/local-abc")
462
+ * @param {string} [baseBranch] the preview base branch (default FRESHNESS_BASE_BRANCH)
463
+ * @returns {string}
464
+ */
465
+ export function resolvePatchBase(git, ref, baseBranch = FRESHNESS_BASE_BRANCH) {
466
+ const verify = (r) => {
467
+ try { return git(["rev-parse", "--verify", "--quiet", r]).trim(); } catch { return ""; }
468
+ };
469
+ const previewRef = `refs/remotes/origin/${baseBranch}`;
470
+ if (verify(previewRef)) {
471
+ try {
472
+ const forkPoint = git(["merge-base", "HEAD", previewRef]).trim();
473
+ if (forkPoint) return forkPoint;
474
+ } catch { /* unrelated histories — fall through */ }
475
+ }
476
+ const trackingRef = `refs/remotes/origin/${ref}`;
477
+ if (verify(trackingRef)) return trackingRef;
478
+ if (verify("HEAD~1")) return "HEAD~1";
479
+ return "";
480
+ }
481
+
447
482
  // ─── born-rebased at submit (unit c3 — shift-left prevention #2) ─────────────────
448
483
 
449
484
  /** The three strategies the born-rebased rebuild accepts, mirroring `tot accept
@@ -1355,12 +1390,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1355
1390
  };
1356
1391
  function buildSummaryAndPatch(ref) {
1357
1392
  const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
1358
- const trackingRef = `refs/remotes/origin/${ref}`;
1359
- const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
1360
- ? trackingRef
1361
- : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
1362
- ? "HEAD~1"
1363
- : "";
1393
+ // Diff against the fork point off `preview` — the FULL branch delta — so the patch
1394
+ // is complete for candidate_open's server-cut (which resets the branch to preview
1395
+ // and re-applies this patch). NOT the candidate's own tracking ref: after a push
1396
+ // that landed but failed to open its PR, that ref equals HEAD → empty patch →
1397
+ // wrong "no file changes" → no PR. See resolvePatchBase.
1398
+ const base = resolvePatchBase(gitSafe, ref);
1364
1399
  const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
1365
1400
  const patchEntries = parseNameStatus(gitSafe(statusCmd));
1366
1401
  const files = patchEntries.map((e) => e.path);
@@ -19,7 +19,8 @@
19
19
  */
20
20
  import { homedir } from "node:os";
21
21
  import { createHash } from "node:crypto";
22
- import { join } from "node:path";
22
+ import { join, dirname } from "node:path";
23
+ import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
23
24
  import { readCredentials, writeCredentials } from "./token-store.mjs";
24
25
 
25
26
  /** The git config value that routes credential requests through `tot` — the
@@ -141,20 +142,96 @@ export function writeCachedCredential(filePath, { username, password }, { now =
141
142
  writeCredentials(filePath, { username, password, mintedAt: now });
142
143
  }
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
+ * @returns {boolean} changed
203
+ */
204
+ export function installForgeCredentialHelper(git, {
205
+ host, env = process.env, nodePath = process.execPath, entryPath = process.argv[1],
206
+ writeShim = writeForgeShim,
207
+ } = {}) {
208
+ if (!host) return false;
209
+ const shim = writeShim(env, nodePath, entryPath);
210
+ const helperValue = shim ? `!${shim}` : CREDENTIAL_HELPER;
211
+ const key = `credential.https://${host}.helper`;
212
+ let raw = null;
213
+ try { raw = git(["config", "--local", "--get-all", key]); } catch { raw = null; }
214
+ // git prints one value per line (an empty value = an empty line); our desired list is
215
+ // ["", helperValue] → "\n<helperValue>". Already correct ⇒ nothing to do.
216
+ if (raw !== null && raw.replace(/\n+$/, "") === `\n${helperValue}`) return false;
217
+ try { git(["config", "--local", "--unset-all", key]); } catch { /* none set yet */ }
218
+ git(["config", "--local", "--add", key, ""]); // reset: clear inherited (osxkeychain) for this host
219
+ git(["config", "--local", "--add", key, helperValue]); // our helper is now the sole one
220
+ return true;
221
+ }
222
+
144
223
  /**
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.
224
+ * Self-heal a LEGACY checkout, best-effort: strip any embedded token from `origin`
225
+ * (the pre-u10 shape whose token silently expires), then install the stable, host-scoped
226
+ * credential helper (see installForgeCredentialHelper) so future git ops mint fresh creds
227
+ * through the `~/.tot` shim nvm-proof and un-shadowable by osxkeychain. Called at the top
228
+ * of every command that touches git. A no-op on an already-migrated remote; leaves a
229
+ * non-http(s) (e.g. ssh) remote entirely alone. Never blocks the command on a hiccup.
154
230
  * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
231
+ * @param {{ env?: NodeJS.ProcessEnv, nodePath?: string, entryPath?: string, writeShim?: typeof writeForgeShim }} [opts]
155
232
  * @returns {{ migrated: boolean }}
156
233
  */
157
- export function ensureTokenlessRemote(git) {
234
+ export function ensureTokenlessRemote(git, opts = {}) {
158
235
  let remote;
159
236
  try {
160
237
  remote = git(["remote", "get-url", "origin"]).trim();
@@ -162,8 +239,10 @@ export function ensureTokenlessRemote(git) {
162
239
  return { migrated: false }; // no `origin` (or not a git repo) — nothing to migrate
163
240
  }
164
241
  let migrated = false;
242
+ let host = "";
165
243
  try {
166
244
  const u = new URL(remote);
245
+ host = u.host;
167
246
  if (u.password) {
168
247
  git(["remote", "set-url", "origin", `${u.protocol}//${u.host}${u.pathname}`]);
169
248
  migrated = true;
@@ -171,15 +250,6 @@ export function ensureTokenlessRemote(git) {
171
250
  } catch {
172
251
  return { migrated }; // not a parseable URL (e.g. an ssh remote) — leave it alone entirely
173
252
  }
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
- }
253
+ if (installForgeCredentialHelper(git, { ...opts, host })) migrated = true;
184
254
  return { migrated };
185
255
  }