@tokenoftrust/cli 1.3.4 → 1.4.0-rc.1
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 +23 -3
- package/bin/tot.mjs +7 -0
- package/package.json +1 -1
- package/src/auth.mjs +55 -77
- package/src/commands/checkout.mjs +24 -17
- package/src/commands/dev.mjs +2 -2
- package/src/commands/doctor.mjs +18 -12
- package/src/commands/feedback.mjs +4 -4
- package/src/commands/grants.mjs +264 -0
- package/src/commands/login.mjs +69 -13
- package/src/commands/start.mjs +30 -32
- package/src/commands/submit.mjs +195 -17
- package/src/commands/whoami.mjs +9 -12
- package/src/mcp.mjs +2 -2
- package/src/prompt.mjs +32 -0
- package/src/token-store.mjs +45 -2
- package/src/validate.mjs +8 -1
package/src/commands/submit.mjs
CHANGED
|
@@ -4,13 +4,21 @@
|
|
|
4
4
|
* From inside a tenant checkout:
|
|
5
5
|
* 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
|
|
6
6
|
* 2. push your committed work to the tenant repo's `preview` ref (triggers reconcile),
|
|
7
|
+
* 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
|
|
8
|
+
* `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
|
|
9
|
+
* Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
|
|
10
|
+
* print the resulting changeId/PR number/URL,
|
|
7
11
|
* 3. report back — reconcile result + compliance verdict + the preview URL — from the MCP.
|
|
8
12
|
*
|
|
9
|
-
* This is submit-for-PREVIEW, not ship-to-live (`change_accept` /
|
|
10
|
-
* is the separate ship gate
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* This is submit-for-PREVIEW, not ship-to-live (`change_accept`/`candidate_accept` /
|
|
14
|
+
* a future `tot ship` is the separate ship gate — this command is submit-only, never
|
|
15
|
+
* accept/reject). Step 2b is best-effort: `candidate_open` failing (older MCP,
|
|
16
|
+
* version-control not configured, preview-access capability) is reported and
|
|
17
|
+
* swallowed — it never blocks the preview push that already landed. Step 3 calls
|
|
18
|
+
* the MCP `preview_status` read-back: given the commit just pushed it returns
|
|
19
|
+
* { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
|
|
20
|
+
* pending. If that tool isn't present (older MCP) the command still validates +
|
|
21
|
+
* pushes and reports "reconcile pending" — degrading visibly, never a crash.
|
|
14
22
|
*
|
|
15
23
|
* Polling (E2): every call carries `waitMs` so a preview_status-aware MCP long-polls
|
|
16
24
|
* (blocks up to waitMs, waking immediately on arrival) instead of us sleeping blind
|
|
@@ -30,6 +38,7 @@
|
|
|
30
38
|
* Dependency-free (global fetch + `git`).
|
|
31
39
|
*/
|
|
32
40
|
import { execFileSync } from "node:child_process";
|
|
41
|
+
import { createHash } from "node:crypto";
|
|
33
42
|
import { setTimeout as delay } from "node:timers/promises";
|
|
34
43
|
import { createMcpClient } from "../mcp.mjs";
|
|
35
44
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
@@ -104,9 +113,9 @@ export function buildChangeSummary({ message, summary, headSubject = "", statLin
|
|
|
104
113
|
return { title, body, autoTitle: !(message && message.trim()) };
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
/** Print the change summary block
|
|
108
|
-
*
|
|
109
|
-
*
|
|
116
|
+
/** Print the change summary block — the SAME title/body carried into `candidate_open`
|
|
117
|
+
* (below) as the PR title/description, so what the approver reads in the PR matches
|
|
118
|
+
* what's printed here. */
|
|
110
119
|
function printChangeSummary({ title, body, autoTitle }) {
|
|
111
120
|
console.log(`\n Change summary (for the approver / the change record):`);
|
|
112
121
|
console.log(` ${title}`);
|
|
@@ -114,6 +123,160 @@ function printChangeSummary({ title, body, autoTitle }) {
|
|
|
114
123
|
if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
// ─── PR-backed candidate (g1b candidate_open, unit c1) ──────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse `git diff/show --name-status` output into `{ status, path, from? }`
|
|
130
|
+
* entries — `status` is the single letter (A/M/D/R/C…); a rename/copy line
|
|
131
|
+
* (`R100\t<old>\t<new>`) carries both `from` (the old path) and `path` (the new
|
|
132
|
+
* one). Pure — unit-tested without git.
|
|
133
|
+
* @param {string} text
|
|
134
|
+
* @returns {{status:string, path:string, from?:string}[]}
|
|
135
|
+
*/
|
|
136
|
+
export function parseNameStatus(text) {
|
|
137
|
+
return text
|
|
138
|
+
.split("\n")
|
|
139
|
+
.map((l) => l.trim())
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.map((line) => {
|
|
142
|
+
const parts = line.split("\t");
|
|
143
|
+
const status = parts[0]?.[0] || "";
|
|
144
|
+
if ((status === "R" || status === "C") && parts.length >= 3) {
|
|
145
|
+
return { status, from: parts[1], path: parts[2] };
|
|
146
|
+
}
|
|
147
|
+
return { status, path: parts[1] };
|
|
148
|
+
})
|
|
149
|
+
.filter((e) => e.path);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the g1b `candidate_open` FileChange[] patch from parsed name-status
|
|
154
|
+
* entries — one entry per changed path, `delete:true` for removals (and a
|
|
155
|
+
* rename's old path). Content is read from the committed HEAD blob via
|
|
156
|
+
* `readBlob` (injected — never the working tree, so the patch matches exactly
|
|
157
|
+
* what was pushed). Binary content (doesn't round-trip as clean utf8, or
|
|
158
|
+
* contains a NUL) is sent base64; everything else goes as plain utf8 text.
|
|
159
|
+
* @param {{status:string, path:string, from?:string}[]} entries
|
|
160
|
+
* @param {(path: string) => Buffer} readBlob
|
|
161
|
+
* @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
|
|
162
|
+
*/
|
|
163
|
+
export function buildFilePatch(entries, readBlob) {
|
|
164
|
+
const patch = [];
|
|
165
|
+
for (const e of entries) {
|
|
166
|
+
if (e.status === "R") patch.push({ path: e.from, delete: true });
|
|
167
|
+
if (e.status === "D") {
|
|
168
|
+
patch.push({ path: e.path, delete: true });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const buf = readBlob(e.path);
|
|
172
|
+
const asUtf8 = buf.toString("utf8");
|
|
173
|
+
const isCleanUtf8 = !asUtf8.includes("") && Buffer.from(asUtf8, "utf8").equals(buf);
|
|
174
|
+
patch.push(
|
|
175
|
+
isCleanUtf8
|
|
176
|
+
? { path: e.path, content: asUtf8 }
|
|
177
|
+
: { path: e.path, content: buf.toString("base64"), contentEncoding: "base64" },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return patch;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Derive the g1b forge repo name (`"<tenant>-<tag>"`, e.g. `"acme.com-main"`)
|
|
185
|
+
* from the checkout's authenticated origin remote — the SAME name
|
|
186
|
+
* `tenant_checkout` minted it as, so no separate lookup or stored tag is
|
|
187
|
+
* needed. Returns null when the remote URL can't be parsed (candidate_open is
|
|
188
|
+
* then skipped, reported, never crashed). Pure — unit-tested.
|
|
189
|
+
* @param {string} remoteUrl
|
|
190
|
+
* @returns {string|null}
|
|
191
|
+
*/
|
|
192
|
+
export function repoNameFromRemote(remoteUrl) {
|
|
193
|
+
try {
|
|
194
|
+
const last = new URL(remoteUrl).pathname.split("/").filter(Boolean).pop();
|
|
195
|
+
return last ? last.replace(/\.git$/, "") : null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A STABLE per-developer-per-tenant candidate handle — so repeat `tot submit`
|
|
203
|
+
* runs update the SAME PR instead of opening a new one each time
|
|
204
|
+
* (`candidate_open` is idempotent on `changeId`). No local state file needed:
|
|
205
|
+
* it's a deterministic hash of the tenant + the acting identity, recomputed
|
|
206
|
+
* fresh every run. Two different developers submitting to the same tenant get
|
|
207
|
+
* two different (non-colliding) candidates. Pure — unit-tested.
|
|
208
|
+
* @param {string} tenant
|
|
209
|
+
* @param {string} actorKey
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
export function deriveChangeId(tenant, actorKey) {
|
|
213
|
+
const hash = createHash("sha256").update(`${tenant}|${actorKey}`).digest("hex").slice(0, 16);
|
|
214
|
+
return `local-${hash}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The stable identity key behind `deriveChangeId` — the signed-in developer's
|
|
218
|
+
* email, falling back to the token, then a generic label. Single-plane: the only
|
|
219
|
+
* identity `tot` carries is the developer's own OAuth session. */
|
|
220
|
+
export function actorKeyFor(session) {
|
|
221
|
+
return session?.email || session?.token || "developer";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
|
|
226
|
+
* unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
|
|
227
|
+
* milestone, symmetric with the hosted s6 draft-as-PR path). Builds the patch
|
|
228
|
+
* from the SAME name-status diff `buildChangeSummary` reports on, reuses its
|
|
229
|
+
* title/body as the PR title/description, and prints the resulting
|
|
230
|
+
* changeId/PR number/URL. Best-effort: any failure (no repo could be derived,
|
|
231
|
+
* older MCP, version control not configured, preview-access capability, …) is
|
|
232
|
+
* reported and swallowed — it never blocks the preview push that already
|
|
233
|
+
* landed or the reconcile/compliance read-back that follows.
|
|
234
|
+
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
235
|
+
* @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
|
|
236
|
+
* patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer }} opts
|
|
237
|
+
*/
|
|
238
|
+
export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob }) {
|
|
239
|
+
if (!repo) {
|
|
240
|
+
console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const patch = buildFilePatch(patchEntries, readBlob);
|
|
245
|
+
if (patch.length === 0) {
|
|
246
|
+
console.log(` ~ no file changes to open a PR-backed candidate for.`);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const result = await client.callTool("candidate_open", {
|
|
250
|
+
repo,
|
|
251
|
+
changeId,
|
|
252
|
+
// Defensively bounded to candidate_open's own schema limits (title<=200,
|
|
253
|
+
// body<=4000) so an over-long commit subject / summary degrades to a
|
|
254
|
+
// truncated PR title/body instead of an entirely-avoidable tool refusal.
|
|
255
|
+
title: changeSummary.title.slice(0, 200),
|
|
256
|
+
body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
|
|
257
|
+
patch,
|
|
258
|
+
});
|
|
259
|
+
reportCandidate(result, changeId);
|
|
260
|
+
return result;
|
|
261
|
+
} catch (e) {
|
|
262
|
+
console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
|
|
263
|
+
console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Print the candidate_open result: the PR the approver reviews, or the MCP's own
|
|
269
|
+
* refusal message when it couldn't open/update one. */
|
|
270
|
+
function reportCandidate(result, changeId) {
|
|
271
|
+
if (result && typeof result.prNumber === "number") {
|
|
272
|
+
console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
|
|
273
|
+
if (result.url) console.log(` ${result.url}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const msg = result?.message || (result?.raw && String(result.raw)) || JSON.stringify(result ?? null);
|
|
277
|
+
console.log(` ~ PR-backed candidate not opened: ${msg}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
117
280
|
/** @param {string[]} argv @param {any} ctx */
|
|
118
281
|
export async function run(argv, ctx) {
|
|
119
282
|
const env = process.env;
|
|
@@ -162,9 +325,10 @@ export async function run(argv, ctx) {
|
|
|
162
325
|
// Build the "what changed" summary the approver is greeted with — BEFORE the
|
|
163
326
|
// push, because `git push` fast-forwards the local origin/<ref> tracking ref and
|
|
164
327
|
// would zero out the "vs what's live in preview" diff. Explicit -m/--summary win;
|
|
165
|
-
// otherwise it's generated from git so the change record is never blank.
|
|
166
|
-
//
|
|
167
|
-
//
|
|
328
|
+
// otherwise it's generated from git so the change record is never blank. This
|
|
329
|
+
// same diff (name-status, so candidate_open also knows adds/deletes/renames)
|
|
330
|
+
// doubles as the source of the PR-backed candidate's file patch (step 2b, below)
|
|
331
|
+
// — one git read, two consumers, so the PR always matches what's printed here.
|
|
168
332
|
const gitSafe = (cargs) => {
|
|
169
333
|
try {
|
|
170
334
|
return git(cargs);
|
|
@@ -179,8 +343,9 @@ export async function run(argv, ctx) {
|
|
|
179
343
|
: gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
|
|
180
344
|
? "HEAD~1"
|
|
181
345
|
: "";
|
|
182
|
-
const
|
|
183
|
-
const
|
|
346
|
+
const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
|
|
347
|
+
const patchEntries = parseNameStatus(gitSafe(statusCmd));
|
|
348
|
+
const files = patchEntries.map((e) => e.path);
|
|
184
349
|
const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
|
|
185
350
|
const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
|
|
186
351
|
|
|
@@ -200,17 +365,30 @@ export async function run(argv, ctx) {
|
|
|
200
365
|
console.log(`\n+ submitted ${short} to ${args.ref}.`);
|
|
201
366
|
printChangeSummary(changeSummary);
|
|
202
367
|
|
|
203
|
-
//
|
|
368
|
+
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
369
|
+
// compliance + preview URL from the MCP (both graceful seams, same session).
|
|
204
370
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
205
371
|
const client = createMcpClient(baseUrl);
|
|
206
372
|
let progress = null;
|
|
207
373
|
try {
|
|
208
374
|
// Attach auth before the first server call (developer bearer pre-initialize,
|
|
209
375
|
// operator credential_validate post-initialize) — see establishSession.
|
|
210
|
-
await establishSession(client, { env, prefer: args.identity || undefined });
|
|
211
|
-
// Set the active tenant so preview_status
|
|
212
|
-
// the session's tenant
|
|
376
|
+
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
377
|
+
// Set the active tenant so preview_status/candidate_open read the right scope
|
|
378
|
+
// (both key on the session's bound tenant/app — no tenant arg of their own).
|
|
213
379
|
await client.callTool("client_switch", { tenant });
|
|
380
|
+
|
|
381
|
+
// 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
|
|
382
|
+
// failure here (older MCP, VC not configured, preview-access capability) is
|
|
383
|
+
// reported and swallowed, never blocking the preview push that already landed.
|
|
384
|
+
await submitCandidate(client, {
|
|
385
|
+
repo: repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim()),
|
|
386
|
+
changeId: deriveChangeId(tenant, actorKeyFor(session)),
|
|
387
|
+
changeSummary,
|
|
388
|
+
patchEntries,
|
|
389
|
+
readBlob: (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] }),
|
|
390
|
+
});
|
|
391
|
+
|
|
214
392
|
let status;
|
|
215
393
|
if (args.noWait) {
|
|
216
394
|
status = normalizePreviewStatus(await client.callTool("preview_status", { commit }));
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
* `tot whoami` — who is `tot` signed in as, and what can they touch.
|
|
3
3
|
*
|
|
4
4
|
* Reads the cached session (~/.tot/credentials.json). If a live token is present
|
|
5
|
-
* it best-effort asks the MCP for the stores this
|
|
5
|
+
* it best-effort asks the MCP for the stores this developer may act on (proving the
|
|
6
6
|
* token still works), degrading cleanly to the cached status if the MCP is
|
|
7
|
-
* unreachable or the
|
|
8
|
-
* are reported too, since they take precedence for tool calls.
|
|
7
|
+
* unreachable or the developer isn't entitled yet.
|
|
9
8
|
*
|
|
10
9
|
* Dependency-free.
|
|
11
10
|
*/
|
|
12
|
-
import { defaultCredentialsPath, readCredentials, isExpired } from "../token-store.mjs";
|
|
13
|
-
import {
|
|
11
|
+
import { defaultCredentialsPath, readCredentials, isExpired, activeProfile } from "../token-store.mjs";
|
|
12
|
+
import { establishSession } from "../auth.mjs";
|
|
14
13
|
import { createMcpClient } from "../mcp.mjs";
|
|
15
14
|
import { normalizeStores, storeListError } from "./checkout.mjs";
|
|
16
15
|
import { recordServerPolicy } from "../update-check.mjs";
|
|
@@ -35,15 +34,13 @@ export async function run(argv, _ctx) {
|
|
|
35
34
|
const creds = readCredentials(defaultCredentialsPath(env));
|
|
36
35
|
const status = sessionStatus(creds);
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
console.log(" these take precedence for `tot` tool calls.");
|
|
41
|
-
}
|
|
37
|
+
const profile = activeProfile(env);
|
|
38
|
+
if (profile) console.log(`profile: ${profile} (TOT_PROFILE — this terminal's own identity)`);
|
|
42
39
|
|
|
43
40
|
if (!status.signedIn) {
|
|
44
41
|
console.log("developer: not signed in.");
|
|
45
|
-
|
|
46
|
-
return
|
|
42
|
+
console.log(" → next: tot login");
|
|
43
|
+
return 1;
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
const when = status.expiresAt ? new Date(status.expiresAt).toISOString() : "unknown";
|
|
@@ -58,7 +55,7 @@ export async function run(argv, _ctx) {
|
|
|
58
55
|
// Developer bearer must be attached BEFORE initialize so the server binds this
|
|
59
56
|
// identity at handshake time — otherwise client_list resolves anonymously and
|
|
60
57
|
// (misleadingly) reports no stores (establishSession orders this correctly).
|
|
61
|
-
await establishSession(client, { env
|
|
58
|
+
await establishSession(client, { env });
|
|
62
59
|
const listResp = await client.callTool("client_list", {});
|
|
63
60
|
// Update-awareness Layer 2: an authed response may carry a version-support
|
|
64
61
|
// policy (wire contract: `cliPolicy`). Safe no-op when absent.
|
package/src/mcp.mjs
CHANGED
|
@@ -93,8 +93,8 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
93
93
|
let rpcId = 0;
|
|
94
94
|
let sessionId = null;
|
|
95
95
|
// The developer OAuth bearer (set at login-resolve time via setToken, or up
|
|
96
|
-
// front via opts.token)
|
|
97
|
-
//
|
|
96
|
+
// front via opts.token) — the only identity `tot` carries. Anonymous calls
|
|
97
|
+
// (e.g. the pre-login handshake) simply omit it.
|
|
98
98
|
let bearer = typeof opts.token === "string" ? opts.token : null;
|
|
99
99
|
|
|
100
100
|
/** Attach (or clear) the developer bearer for subsequent calls. */
|
package/src/prompt.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny terminal-prompt helpers, shared across commands. Every prompt respects a
|
|
3
|
+
* non-TTY (CI, piped stdin) by returning the default instead of hanging — the one
|
|
4
|
+
* rule that keeps `tot` safe to run non-interactively.
|
|
5
|
+
*
|
|
6
|
+
* Dependency-free (node:readline/promises).
|
|
7
|
+
*/
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
|
|
10
|
+
/** True only when BOTH stdin and stdout are a TTY — safe to prompt. */
|
|
11
|
+
export function isInteractive() {
|
|
12
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ask a yes/no question. Returns `defaultYes` immediately on a non-TTY so nothing
|
|
17
|
+
* ever blocks in CI. Enter (empty answer) takes the default.
|
|
18
|
+
* @param {string} question
|
|
19
|
+
* @param {boolean} defaultYes
|
|
20
|
+
* @returns {Promise<boolean>}
|
|
21
|
+
*/
|
|
22
|
+
export async function promptYesNo(question, defaultYes) {
|
|
23
|
+
if (!isInteractive()) return defaultYes;
|
|
24
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
25
|
+
try {
|
|
26
|
+
const ans = (await rl.question(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
27
|
+
if (!ans) return defaultYes;
|
|
28
|
+
return ans === "y" || ans === "yes";
|
|
29
|
+
} finally {
|
|
30
|
+
rl.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/token-store.mjs
CHANGED
|
@@ -29,17 +29,60 @@
|
|
|
29
29
|
* Dependency-free (node:fs/os/path).
|
|
30
30
|
*
|
|
31
31
|
* `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
|
|
32
|
+
*
|
|
33
|
+
* PROFILES (`TOT_PROFILE`) — OPTIONAL, for parallel work. Almost every developer
|
|
34
|
+
* leaves this UNSET and uses the single default `credentials.json` — that path is
|
|
35
|
+
* unchanged and is the norm. It exists only when you want MORE THAN ONE identity
|
|
36
|
+
* live at once (a staff `@tokenoftrust.com` sign-in and a plain developer one, or
|
|
37
|
+
* many parallel test identities): export a profile per shell and each gets its OWN
|
|
38
|
+
* credential file under the same `~/.tot` (the renderer cache, last-tenant, etc.
|
|
39
|
+
* stay shared — only the identity splits). The value is an OPAQUE label — any
|
|
40
|
+
* string works, so `export TOT_PROFILE=$(uuidgen)` per terminal/test is fine:
|
|
41
|
+
* - a clean short token (`staff`, `dev`, `test-7`) is used verbatim for a
|
|
42
|
+
* readable `credentials.<profile>.json`;
|
|
43
|
+
* - any other value (symbols, uppercase, long) is HASHED to a stable, safe
|
|
44
|
+
* `credentials.h<hash>.json` — so an arbitrary opaque id can never collide
|
|
45
|
+
* with another or escape `~/.tot`, while `tot whoami` still shows what you set.
|
|
32
46
|
*/
|
|
33
47
|
import {
|
|
34
48
|
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync,
|
|
35
49
|
} from "node:fs";
|
|
36
50
|
import { homedir } from "node:os";
|
|
51
|
+
import { createHash } from "node:crypto";
|
|
37
52
|
import { join, dirname } from "node:path";
|
|
38
53
|
|
|
39
|
-
/**
|
|
54
|
+
/** A short token safe to drop straight into a filename (readable profiles). */
|
|
55
|
+
const CLEAN_PROFILE_RE = /^[a-z0-9_-]{1,64}$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The active profile's DISPLAY label — the raw `TOT_PROFILE`, trimmed — or null
|
|
59
|
+
* when unset. This is what `tot whoami` shows; it is NOT the filename (see
|
|
60
|
+
* profileSlug, which makes any value filesystem-safe).
|
|
61
|
+
*/
|
|
62
|
+
export function activeProfile(env = process.env) {
|
|
63
|
+
return String(env.TOT_PROFILE || "").trim() || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The active profile's safe filename token, or null for the default session. A
|
|
68
|
+
* clean short label passes through verbatim (readable files); ANY other opaque
|
|
69
|
+
* value is hashed — collision-resistant and incapable of a `/` or `..` path
|
|
70
|
+
* escape, so `TOT_PROFILE` can be a literally arbitrary identifier. Hashing the
|
|
71
|
+
* RAW value (not a stripped form) keeps distinct tokens distinct.
|
|
72
|
+
*/
|
|
73
|
+
export function profileSlug(env = process.env) {
|
|
74
|
+
const raw = String(env.TOT_PROFILE || "").trim();
|
|
75
|
+
if (!raw) return null;
|
|
76
|
+
const lower = raw.toLowerCase();
|
|
77
|
+
if (CLEAN_PROFILE_RE.test(lower)) return lower;
|
|
78
|
+
return `h${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Absolute path to the credential file for this environment (+ TOT_PROFILE). */
|
|
40
82
|
export function defaultCredentialsPath(env = process.env) {
|
|
41
83
|
const home = env.TOT_HOME || homedir();
|
|
42
|
-
|
|
84
|
+
const slug = profileSlug(env);
|
|
85
|
+
return join(home, ".tot", slug ? `credentials.${slug}.json` : "credentials.json");
|
|
43
86
|
}
|
|
44
87
|
|
|
45
88
|
/**
|
package/src/validate.mjs
CHANGED
|
@@ -184,7 +184,12 @@ function validateScriptsDoc(doc, file, config) {
|
|
|
184
184
|
return out;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
const SUPPORTED_BLOCK_CONTRACTS = new Set([
|
|
187
|
+
const SUPPORTED_BLOCK_CONTRACTS = new Set([
|
|
188
|
+
"block-palette@1",
|
|
189
|
+
"block-palette@2",
|
|
190
|
+
"block-palette@3",
|
|
191
|
+
"block-palette@4",
|
|
192
|
+
]);
|
|
188
193
|
const KNOWN_BLOCKS = new Set([
|
|
189
194
|
"hero",
|
|
190
195
|
"promo_tiles",
|
|
@@ -192,6 +197,7 @@ const KNOWN_BLOCKS = new Set([
|
|
|
192
197
|
"featured_products",
|
|
193
198
|
"editorial",
|
|
194
199
|
"newsletter",
|
|
200
|
+
"membership_band",
|
|
195
201
|
"marketing_hero",
|
|
196
202
|
"trust_bar",
|
|
197
203
|
"split_compare",
|
|
@@ -209,6 +215,7 @@ const REQUIRED_BLOCK_PROPS = {
|
|
|
209
215
|
featured_collections: ["handles"],
|
|
210
216
|
editorial: ["title"],
|
|
211
217
|
newsletter: ["title"],
|
|
218
|
+
membership_band: ["tiers"],
|
|
212
219
|
marketing_hero: ["headline"],
|
|
213
220
|
trust_bar: ["items"],
|
|
214
221
|
split_compare: ["title", "before", "after"],
|