@tokenoftrust/cli 2.4.7 → 2.4.9

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 CHANGED
@@ -73,4 +73,4 @@ A clean short name (`staff`, `dev`, `test-7`) becomes a readable `credentials.<n
73
73
 
74
74
  ## Design notes
75
75
 
76
- Dependency-free by design (global `fetch` + `git` + the system `tar`), so `npm i -g @tokenoftrust/cli` stays light. `tot dev` is the only command that pulls in the storefront runner: for a standalone checkout it downloads the moat-free runner artifact (`dev_renderer_artifact`, entitlement-gated) once per version to `~/.tot/cache/renderer/<version>/`, `pnpm install`s it there (via corepack — no separate pnpm install needed), and reuses that cache on every later run. See `src/` for the small modules: `context.mjs` (detection), `mcp.mjs` (transport), `auth.mjs` (the identity seam), `commands/`.
76
+ Dependency-free by design (global `fetch` + `git` + the system `tar`), so `npm i -g @tokenoftrust/cli` stays light. `tot dev` is the only command that pulls in the storefront runner: for a standalone checkout it downloads the moat-free runner once per version — from the public npm package `@tokenoftrust/storefront-runner`, after the entitlement-gated `dev_renderer_artifact` answers `use_npm` (its artifact store is not kept in step with npm) — to `~/.tot/cache/renderer/`, `pnpm install`s it there (via corepack — no separate pnpm install needed), and reuses that cache on every later run. See `src/` for the small modules: `context.mjs` (detection), `mcp.mjs` (transport), `auth.mjs` (the identity seam), `commands/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.4.7",
3
+ "version": "2.4.9",
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",
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The next step to print when the storefront refuses a call with 401/403.
3
+ *
4
+ * The server's admission rule (apps/storefront lib/pipeline/admission.ts) names exactly what is
5
+ * missing in `admission.missing[].grant`, and its reason is already in the refusal's `error`.
6
+ * The hint follows that answer instead of restating a policy of its own: a client copy of the
7
+ * rule goes stale — every refusal here used to say "you need a live ship-on-behalf grant", which
8
+ * is wrong for an unpaid store, where an invited developer needs no grant at all.
9
+ */
10
+
11
+ /** The grant names a refusal body reports missing, or `[]` when it names none. */
12
+ export function missingGrantsOf(body) {
13
+ const missing = body && typeof body === "object" ? body.admission?.missing : undefined;
14
+ return Array.isArray(missing)
15
+ ? missing.map((m) => m?.grant).filter((g) => typeof g === "string")
16
+ : [];
17
+ }
18
+
19
+ /** One actionable next step for a refused call, from the grants the server named. */
20
+ export function refusalHint(status, missingGrants = []) {
21
+ if (status === 401 || missingGrants.includes("session")) return "run `tot login`, then re-run";
22
+ if (missingGrants.includes("tenant-binding")) {
23
+ return "your sign-in is for a different store — check --tenant, then re-run";
24
+ }
25
+ if (missingGrants.includes("tenant-standing")) {
26
+ return "ask the store owner to invite you to this store from its Team page, then re-run";
27
+ }
28
+ if (missingGrants.includes("ship-on-behalf")) {
29
+ return "this store's live site is active, so ask the store owner for a ship-on-behalf grant from its Team page, then re-run";
30
+ }
31
+ if (missingGrants.includes("owner-standing")) return "only the store owner can do this";
32
+ if (missingGrants.includes("console-capability")) {
33
+ return "the operator secret is acting with preview-access — set X-Tot-Capability: ship-on-behalf (or ACCEPT_OPERATOR_CAPABILITY), then re-run";
34
+ }
35
+ return "ask the store owner for access to this store — or use an operator secret authorised for it";
36
+ }
@@ -47,8 +47,9 @@ import { createMcpClient } from "../mcp.mjs";
47
47
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
48
48
  import { scopeToTenant } from "../tenant-scope.mjs";
49
49
  // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
50
- // login and integrate as themselves (server gates on their live ship-on-behalf grant).
50
+ // login and integrate as themselves (the server's admission rule decides).
51
51
  import { resolveViewerTransport } from "../viewer-session.mjs";
52
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
52
53
  // The doctor PUSH: on a failed accept/integrate, auto-append the COMPACT
53
54
  // "here's what's blocking you" summary over the SAME transport we just used, so a
54
55
  // developer doesn't have to remember to run `tot preview doctor` themselves.
@@ -529,7 +530,7 @@ export async function runIntegrate(
529
530
  // - OPERATOR SECRET (operators/CI): Bearer + X-Tot-Owner on the generic host.
530
531
  // - VIEWER SESSION (an invited developer, no secret): mint a `tot_session` from
531
532
  // their OWN `tot` login on the TENANT'S host and send it as a cookie. The
532
- // server authorizes on their live ship-on-behalf grant either way.
533
+ // server's admission rule authorizes either way.
533
534
  // No `content-type` here: it's added per-POST; the mergeable preflight GET wants none.
534
535
  let authHeaders;
535
536
  if (secret) {
@@ -688,7 +689,7 @@ function reportIntegrateHttpError(result, res, { tenant, label }) {
688
689
  fail(
689
690
  `the integration queue refused the request: ${msg}`,
690
691
  res.status === 401 || res.status === 403
691
- ? "you need a live ship-on-behalf grant on this tenant (ask the store owner) — or an operator secret authorised for it"
692
+ ? refusalHint(res.status, missingGrantsOf(result.raw))
692
693
  : res.status === 404
693
694
  ? `check that ${label} has a built candidate in ${tenant}'s queue (\`tot pr list --tenant ${tenant}\`)`
694
695
  : "check --tenant / --url / --pr, then re-run",
@@ -185,7 +185,8 @@ async function runStandalone(workspace, args, ctx) {
185
185
  // Entitled artifact unavailable — stay NATIVE on the public npm runner
186
186
  // (@tokenoftrust/storefront-runner, the SAME renderer bits), so
187
187
  try {
188
- console.error(`~ couldn't set up your store preview the usual way (${e.message}) — using the public preview engine instead.`);
188
+ const notice = artifactFallbackNotice(e);
189
+ if (notice) console.error(notice);
189
190
  return await runNativePublic(workspace, args, ctx);
190
191
  } catch (e2) {
191
192
  console.error(formatError(e2));
@@ -284,7 +285,31 @@ function runMonorepo(ctx, argv) {
284
285
  * specifically and falls back to Docker instead of hard-failing. Exported so
285
286
  * `tot start` can catch it too and fall back the same way.
286
287
  */
287
- export class NativeArtifactUnavailableError extends Error {}
288
+ export class NativeArtifactUnavailableError extends Error {
289
+ /** @param {string} message @param {{ expected?: boolean }} [opts] */
290
+ constructor(message, { expected = false } = {}) {
291
+ super(message);
292
+ // True when the MCP answered that npm is the renderer source: the normal
293
+ // fallback, not a fault, so it is not reported to the developer.
294
+ this.expected = expected;
295
+ }
296
+ }
297
+
298
+ // dev_renderer_artifact statuses meaning "the artifact store holds no current
299
+ // runner — install it from npm". `use_npm` is the current non-error answer;
300
+ // `stale_artifact` / `not_found` are the error-shaped answers older servers send.
301
+ const USE_NPM_STATUSES = new Set(["use_npm", "stale_artifact", "not_found"]);
302
+
303
+ /**
304
+ * The line `tot dev` prints when it falls back from the entitled artifact to the
305
+ * public npm runner, or null when the fallback is the expected path.
306
+ * @param {NativeArtifactUnavailableError} e
307
+ * @returns {string|null}
308
+ */
309
+ export function artifactFallbackNotice(e) {
310
+ if (e.expected) return null;
311
+ return `~ couldn't set up your store preview the usual way (${e.message}) — using the public preview engine instead.`;
312
+ }
288
313
 
289
314
  /**
290
315
  * The local landing URL, shared by the native and Docker paths. The store's path is its canonical
@@ -724,12 +749,18 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
724
749
  await establishSession(client, { env: process.env });
725
750
  }
726
751
  const res = await client.callTool("dev_renderer_artifact", {});
752
+ if (USE_NPM_STATUSES.has(res?.status)) {
753
+ throw new NativeArtifactUnavailableError(res.message || "the renderer is served from npm", { expected: true });
754
+ }
727
755
  if (!res?.url || !res?.version) {
728
756
  throw new Error(res?.error || "no renderer-artifact URL returned");
729
757
  }
730
758
  credential = res;
731
759
  } catch (e) {
732
- throw new NativeArtifactUnavailableError(String(e?.message || e));
760
+ if (e instanceof NativeArtifactUnavailableError) throw e;
761
+ throw new NativeArtifactUnavailableError(String(e?.message || e), {
762
+ expected: USE_NPM_STATUSES.has(e?.diagnostic?.serverStatus),
763
+ });
733
764
  }
734
765
 
735
766
  try {
@@ -38,6 +38,7 @@
38
38
  */
39
39
  import { fail } from "../errors.mjs";
40
40
  import { resolveOperatorSecret } from "./ship.mjs";
41
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
41
42
  import { resolveViewerTransport } from "../viewer-session.mjs";
42
43
  import {
43
44
  DOCTOR_PATH,
@@ -165,7 +166,7 @@ export async function runDoctor(
165
166
  console.error(
166
167
  fail(
167
168
  `not authorized to diagnose ${tenant}'s merges: ${data?.error || `HTTP ${res.status}`}`,
168
- "you need a live ship-on-behalf grant on this tenant (ask the store owner), or an operator secret authorised for it — then re-run",
169
+ refusalHint(res.status, missingGrantsOf(data)),
169
170
  ),
170
171
  );
171
172
  return 2;
@@ -45,8 +45,9 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
45
45
  // speak ONE operator-auth contract.
46
46
  import { resolveOperatorSecret } from "./ship.mjs";
47
47
  // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
48
- // login. The server still gates the request on their live ship-on-behalf grant.
48
+ // login. The server's admission rule still gates the request.
49
49
  import { resolveViewerTransport } from "../viewer-session.mjs";
50
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
50
51
  import { isJobAccepted, waitForPipelineJob } from "../pipeline-job.mjs";
51
52
 
52
53
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
@@ -337,7 +338,7 @@ export async function runRevert(
337
338
  fail(
338
339
  `the revert request was refused: ${msg}`,
339
340
  res.status === 401 || res.status === 403
340
- ? "you need a live ship-on-behalf grant on this tenant (ask the store owner) — or an operator secret authorised for it"
341
+ ? refusalHint(res.status, missingGrantsOf(data))
341
342
  : "check --tenant / --url / --preview, then re-run",
342
343
  ),
343
344
  );
@@ -47,7 +47,8 @@
47
47
  *
48
48
  * TRANSPORT: operators/CI may use a Bearer operator secret. A signed-in owner or
49
49
  * developer instead mints a storefront viewer session from their OWN `tot login`
50
- * identity; the server still enforces the live owner / `ship-on-behalf` grant.
50
+ * identity; the server's admission rule decides who may ship (owner; a live
51
+ * `ship-on-behalf` grant; or, while the store is unpaid, any developer invited to it).
51
52
  */
52
53
  import { createHash } from "node:crypto";
53
54
  import { fail } from "../errors.mjs";
@@ -76,10 +77,10 @@ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live
76
77
  record the required reason, then continue publishing
77
78
  tot ship --no-open don't open the live URL in your browser
78
79
 
79
- Authentication: a signed-in owner/developer with a live ship-on-behalf grant
80
- can use their own \`tot login\` session. Operators/CI may instead provide a
81
- secret via PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET,
82
- TOT_OPERATOR_SECRET, or --secret.
80
+ Authentication: use your own \`tot login\` session if you own the store, hold
81
+ a live ship-on-behalf grant, or — while the store is unpaid — were invited to
82
+ it as a developer. Operators/CI may instead provide a secret via
83
+ PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET, or --secret.
83
84
 
84
85
  \`tot ship\` has ONE meaning: it publishes the tenant's shared preview
85
86
  aggregate — the batch of PRs that integrated cleanly and went GREEN — to
@@ -6,9 +6,10 @@
6
6
  * a storefront VIEWER session from the developer's OWN MCP token (POST
7
7
  * /api/dev/cli-session on the tenant's own host, which resolves WHO the opaque token
8
8
  * is via the MCP and hands back a `tot_session` cookie), then hands the caller a
9
- * cookie-based transport pointed at the tenant host. Authorization is still the
10
- * developer's live `ship-on-behalf` grant, enforced server-side at the ship route —
11
- * this only carries their identity, it grants nothing.
9
+ * cookie-based transport pointed at the tenant host. Authorization is the server's
10
+ * admission rule at the ship route (owner; a live `ship-on-behalf` grant; or, while the
11
+ * store is unpaid, any developer invited to it) — this only carries their identity, it
12
+ * grants nothing.
12
13
  *
13
14
  * Minted FRESH per call (no disk cache): a `tot accept` is interactive + infrequent,
14
15
  * and minting-per-call means a revoked session/grant is never honored past its life.