@tokenoftrust/cli 3.1.0 → 3.3.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.
package/README.md CHANGED
@@ -34,6 +34,7 @@ published npm SDK.
34
34
  | `tot clone [<tenant>] [<dir>]` | **built** | Clone a store you're entitled to build on (mirrors `git clone`), with an authenticated remote configured. Dir defaults to `<tenant>`. No arg → list your stores. |
35
35
  | `tot validate` | next | Lint your store before you preview. |
36
36
  | `tot dev` | **built** | Run your store locally with save→reload — natively. |
37
+ | `tot image add <file...>` | **built** | Upload images into your store's content-addressed image pool and print each one's `/_a/<sha256><ext>` URL to reference in content. Image files never go in the store repo. |
37
38
  | `tot preview` | **built** | Bundle your edits into a compliance-reviewed preview (validates, auto-commits the known content trees, pushes the preview ref, opens/updates a candidate PR, reports the reconcile/compliance verdict + preview URL). `tot submit` / `tot deploy` still work as teaching aliases for this same flow. |
38
39
  | `tot ship` | **built** | Promote a reconciled preview live: always shows a diff-vs-live and asks for one `[y/N]` confirm (no `--yes`, refuses outside a terminal); records an approval request if you're not authorised to ship yourself. |
39
40
  | `tot pr [list\|view\|close]` | **built** | See and manage the candidate PRs `tot preview` opens (`gh pr`-shaped). |
package/bin/tot.mjs CHANGED
@@ -62,6 +62,7 @@ const COMMANDS = [
62
62
  { invocation: "capabilities", blurb: "what the platform supports, refuses, and has not built yet" },
63
63
  { invocation: "report go-live --pages <dir>", blurb: "your store's go-live readiness report, from its page-check results" },
64
64
  { invocation: "dev", blurb: "run your store locally with save→reload" },
65
+ { invocation: "image add <file...>", blurb: "upload images to your store's image pool; prints the /_a/ URL to use in content" },
65
66
  { invocation: "preview", blurb: "push your store to a reviewable preview" },
66
67
  { invocation: "status", blurb: "the pipeline verdict for your active tenant — progressing / stuck / idle, and why" },
67
68
  { invocation: "ship", blurb: "publish the store live — its green aggregate and approved mounted spaces as one release (plan → confirm → ship)" },
@@ -218,6 +219,11 @@ async function dispatch(cmd, rest, ctx) {
218
219
  return run(rest, ctx);
219
220
  }
220
221
 
222
+ if (cmd === "image") {
223
+ const { run } = await import("../src/commands/image.mjs");
224
+ return run(rest, ctx);
225
+ }
226
+
221
227
  // dev → preview → ship. `preview` is the first-class verb; `submit`/`deploy`
222
228
  // reach the SAME preview flow as teaching aliases (preview.mjs prints a one-line
223
229
  // hint nudging the developer onto `tot preview` / `tot ship`).
@@ -358,6 +364,10 @@ function safeSubcommand(cmd, rest) {
358
364
  const sub = rest.find((t) => t && !t.startsWith("-"));
359
365
  if (sub === "scaffold" || sub === "dev") return sub;
360
366
  }
367
+ if (cmd === "image") {
368
+ const sub = rest.find((t) => t && !t.startsWith("-"));
369
+ if (sub === "add") return sub;
370
+ }
361
371
  return undefined;
362
372
  }
363
373
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
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,200 @@
1
+ /**
2
+ * `tot image add <file...>` — put local images into your store's image pool and print the URL to
3
+ * reference in content.
4
+ *
5
+ * A store's repo never holds image bytes: the platform's content-addressed pool does, and content
6
+ * references an image as `/_a/<sha256><ext>`. This command is the on-ramp for an image you hold
7
+ * locally (a blog photo, a new product shot): it uploads the bytes and prints that URL. The URL is
8
+ * the image's own content hash, so the same bytes always print the same URL and are uploaded once —
9
+ * the command asks the store first and skips the upload when the pool already has them.
10
+ *
11
+ * AUTH — your own `tot login`, exchanged for a storefront viewer session (`resolveViewerTransport`).
12
+ * The store's admission rule decides who may upload (the people who may change what its preview
13
+ * serves); the CLI decides nothing.
14
+ *
15
+ * OUTPUT — one URL per file on stdout, in argument order, so `url="$(tot image add hero.jpg)"`
16
+ * works; progress and refusals go to stderr. `--json` prints `[{ file, url, outcome }]` instead.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { readFile, stat } from "node:fs/promises";
20
+ import { resolveViewerTransport } from "../viewer-session.mjs";
21
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
22
+
23
+ /** The server's upload limit (`MAX_UPLOAD_BYTES`): refused here before any bytes are sent. */
24
+ export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
25
+
26
+ /** Parse `tot image` argv. Pure — unit-tested. */
27
+ export function parseImageArgs(argv) {
28
+ /** @type {{ sub: string|null, files: string[], tenant: string|null, url: string|null, json: boolean, help: boolean }} */
29
+ const a = { sub: null, files: [], tenant: null, url: null, json: false, help: false };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const t = argv[i];
32
+ if (t === "--tenant") a.tenant = argv[++i];
33
+ else if (t === "--url") a.url = argv[++i];
34
+ else if (t === "--json") a.json = true;
35
+ else if (t === "--help" || t === "-h") a.help = true;
36
+ else if (a.sub === null) a.sub = t;
37
+ else a.files.push(t);
38
+ }
39
+ return a;
40
+ }
41
+
42
+ export function renderUsage() {
43
+ return `tot image add — put images into your store's image pool
44
+
45
+ Usage:
46
+ tot image add <file...> [--tenant <appDomain>] [--json]
47
+
48
+ Uploads each image and prints the URL to reference in your content, one per line:
49
+
50
+ /_a/<sha256>.<ext>
51
+
52
+ Use it anywhere content names an image (an <img src>, a page block, a product image).
53
+ Never commit image files to your store repo — the pool holds them, your repo holds
54
+ these URLs. The same file always prints the same URL; uploading it again is a no-op.
55
+
56
+ Accepted: PNG, JPEG, GIF, WebP, AVIF, up to 8 MiB. SVG is not accepted yet.
57
+
58
+ Options:
59
+ --tenant <appDomain> Store to upload to. Defaults to the current checkout's store.
60
+ --url <origin> Storefront origin. Defaults to $TOT_STOREFRONT_URL or the
61
+ hosted storefront.
62
+ --json Print [{ file, url, outcome }] instead of bare URLs.
63
+ --help, -h Show this help.`;
64
+ }
65
+
66
+ /**
67
+ * @param {string[]} argv
68
+ * @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
69
+ * @param {{ resolveViewerTransport?: typeof resolveViewerTransport, fetchImpl?: typeof fetch,
70
+ * env?: NodeJS.ProcessEnv, out?: (s: string) => void, err?: (s: string) => void,
71
+ * readFileImpl?: (path: string) => Promise<Uint8Array>, statImpl?: (path: string) => Promise<{ size: number, isFile(): boolean }> }} [deps]
72
+ */
73
+ export async function run(argv, ctx, deps = {}) {
74
+ const out = deps.out || ((s) => process.stdout.write(`${s}\n`));
75
+ const err = deps.err || ((s) => process.stderr.write(`${s}\n`));
76
+ const env = deps.env || process.env;
77
+ const fetchImpl = deps.fetchImpl || fetch;
78
+ const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
79
+ const read = deps.readFileImpl || readFile;
80
+ const statFile = deps.statImpl || stat;
81
+
82
+ const args = parseImageArgs(argv);
83
+ if (args.help || args.sub === null) {
84
+ (args.help ? out : err)(renderUsage());
85
+ return args.help ? 0 : 2;
86
+ }
87
+ if (args.sub !== "add") {
88
+ err(`✗ unknown \`tot image\` command: ${args.sub}\n\n → next: tot image add <file...>`);
89
+ return 2;
90
+ }
91
+ if (args.files.length === 0) {
92
+ err("✗ no image to add.\n\n → next: tot image add <file...>");
93
+ return 2;
94
+ }
95
+ const tenant = String(args.tenant || ctx?.tenant || "").trim().toLowerCase();
96
+ if (!tenant) {
97
+ err("✗ no store to upload to.\n\n → next: pass --tenant <appDomain>, or run inside a store checkout.");
98
+ return 2;
99
+ }
100
+
101
+ // Read and hash every file first: a typo in the last path fails before anything is uploaded.
102
+ /** @type {{ file: string, bytes: Uint8Array, address: string }[]} */
103
+ const images = [];
104
+ for (const file of args.files) {
105
+ let info;
106
+ try {
107
+ info = await statFile(file);
108
+ } catch {
109
+ err(`✗ ${file}: no such file.`);
110
+ return 2;
111
+ }
112
+ if (!info.isFile()) {
113
+ err(`✗ ${file}: not a file.`);
114
+ return 2;
115
+ }
116
+ if (info.size > MAX_IMAGE_BYTES) {
117
+ err(`✗ ${file}: larger than the 8 MiB upload limit.\n\n → next: compress or resize it, then re-run.`);
118
+ return 2;
119
+ }
120
+ const bytes = await read(file);
121
+ images.push({ file, bytes, address: createHash("sha256").update(bytes).digest("hex") });
122
+ }
123
+
124
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || null;
125
+ const transport = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
126
+ if (!transport.ok) {
127
+ err(`✗ ${transport.message}\n\n → next: ${transport.hint}`);
128
+ return 1;
129
+ }
130
+ const endpoint = `${transport.base}/api/changes/images`;
131
+
132
+ /** @type {Map<string, { url: string, outcome: string }>} */
133
+ const done = new Map();
134
+ /** @type {{ file: string, url: string|null, outcome: string, error?: string }[]} */
135
+ const results = [];
136
+ let failed = false;
137
+ for (const image of images) {
138
+ let result = done.get(image.address) ?? null;
139
+ if (!result) {
140
+ const answer = await addOne(image, { endpoint, headers: transport.authHeaders, fetchImpl });
141
+ if (!answer.ok) {
142
+ err(`✗ ${image.file}: ${answer.error}${answer.hint ? `\n\n → next: ${answer.hint}.` : ""}`);
143
+ results.push({ file: image.file, url: null, outcome: "refused", error: answer.error });
144
+ failed = true;
145
+ if (answer.fatal) break;
146
+ continue;
147
+ }
148
+ result = { url: answer.url, outcome: answer.outcome };
149
+ done.set(image.address, result);
150
+ }
151
+ results.push({ file: image.file, ...result });
152
+ if (!args.json) {
153
+ out(result.url);
154
+ err(` ✓ ${image.file} → ${result.url}${result.outcome === "created" ? "" : " (already in the pool)"}`);
155
+ }
156
+ }
157
+ if (args.json) out(JSON.stringify(results));
158
+ return failed ? 1 : 0;
159
+ }
160
+
161
+ /**
162
+ * Ask whether the pool already holds the bytes; upload them only when it does not.
163
+ * @returns {Promise<{ ok: true, url: string, outcome: string } | { ok: false, error: string, hint?: string, fatal?: boolean }>}
164
+ */
165
+ async function addOne(image, { endpoint, headers, fetchImpl }) {
166
+ let res;
167
+ try {
168
+ res = await fetchImpl(`${endpoint}?address=${image.address}`, { method: "GET", headers });
169
+ } catch (e) {
170
+ return { ok: false, error: `could not reach the store: ${e?.message || e}`, hint: "check --url / your network", fatal: true };
171
+ }
172
+ let data = await res.json().catch(() => null);
173
+ if (res.status === 401 || res.status === 403) {
174
+ return { ok: false, error: "your account can't upload images to this store.", hint: refusalHint(res.status, missingGrantsOf(data)), fatal: true };
175
+ }
176
+ if (res.ok && data?.outcome === "present" && typeof data.url === "string") return { ok: true, url: data.url, outcome: "present" };
177
+
178
+ try {
179
+ res = await fetchImpl(endpoint, {
180
+ method: "POST",
181
+ // `application/octet-stream` keeps this out of the form-POST class the origin check guards.
182
+ headers: { ...headers, "content-type": "application/octet-stream", "x-tot-content-sha256": image.address },
183
+ body: image.bytes,
184
+ });
185
+ } catch (e) {
186
+ return { ok: false, error: `could not reach the store: ${e?.message || e}`, hint: "check --url / your network", fatal: true };
187
+ }
188
+ data = await res.json().catch(() => null);
189
+ if (res.status === 401 || res.status === 403) {
190
+ return { ok: false, error: "your account can't upload images to this store.", hint: refusalHint(res.status, missingGrantsOf(data)), fatal: true };
191
+ }
192
+ if (res.ok && typeof data?.url === "string") return { ok: true, url: data.url, outcome: data.outcome === "present" ? "present" : "created" };
193
+ const error = typeof data?.error === "string" ? data.error : `the store refused the upload (HTTP ${res.status}).`;
194
+ return {
195
+ ok: false, error,
196
+ hint: res.status === 429 ? "upload the rest tomorrow (UTC)"
197
+ : res.status >= 500 ? "retry shortly; if it persists, report it with `tot feedback`" : undefined,
198
+ fatal: res.status === 429,
199
+ };
200
+ }
@@ -9,11 +9,11 @@
9
9
  *
10
10
  * - one per entry of {@link FORBIDDEN_PUBLIC_DIRS}: any file under a directory a media download
11
11
  * writes (`public/catalog-images/` is `catalog-images-in-repo`, `public/blog/wp-content/uploads/`
12
- * is `blog-media-in-repo`). Decided from paths alone, so a caller without sizes still refuses
13
- * it. This list is the one source the validators and the reconcile read; the Gitea
14
- * pre-receive size guard names the same directories.
12
+ * is `blog-media-in-repo`), each with its own remedy. Decided from paths alone, so a caller
13
+ * without sizes still refuses it. This list is the one source the validators and the reconcile
14
+ * read; the Gitea pre-receive size guard names the same directories.
15
15
  * - `public-binary-oversized` — one raster or media file over {@link PUBLIC_BINARY_LIMITS}
16
- * `maxFileBytes`.
16
+ * `maxFileBytes`. An image's remedy is {@link IMAGE_ADD_COMMAND}.
17
17
  * - `public-tree-over-budget` — the whole `public/` tree over `maxTotalBytes`.
18
18
  *
19
19
  * The size findings need `bytes`; an entry without it is judged by path only.
@@ -31,14 +31,24 @@ export const PUBLIC_BINARY_LIMITS = Object.freeze({
31
31
  maxTotalBytes: 32 * 1024 * 1024,
32
32
  });
33
33
 
34
+ /** The kit command that moves downloaded catalog images and blog media back to their source URLs. */
35
+ export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
36
+
37
+ /** The command that puts an image the author holds into the platform pool and prints its `/_a/` URL. */
38
+ export const IMAGE_ADD_COMMAND = "tot image add <file>";
39
+
34
40
  /**
35
41
  * The directories under `public/` a media download writes. No file under one may be committed.
42
+ * `remedy` is the fix for files of that kind.
36
43
  *
37
- * @type {ReadonlyArray<Readonly<{ dir: string, code: string, noun: string }>>}
44
+ * @type {ReadonlyArray<Readonly<{ dir: string, code: string, noun: string, remedy: string }>>}
38
45
  */
39
46
  export const FORBIDDEN_PUBLIC_DIRS = Object.freeze([
40
- Object.freeze({ dir: "catalog-images/", code: "catalog-images-in-repo", noun: "catalog image" }),
41
- Object.freeze({ dir: "blog/wp-content/uploads/", code: "blog-media-in-repo", noun: "blog media" }),
47
+ Object.freeze({ dir: "catalog-images/", code: "catalog-images-in-repo", noun: "catalog image", remedy: UNHOST_IMAGES_COMMAND }),
48
+ Object.freeze({
49
+ dir: "blog/wp-content/uploads/", code: "blog-media-in-repo", noun: "blog media",
50
+ remedy: `a new post image: ${IMAGE_ADD_COMMAND}, then reference the /_a/ URL it prints; media a migration downloaded: ${UNHOST_IMAGES_COMMAND}`,
51
+ }),
42
52
  ]);
43
53
 
44
54
  /** The finding codes decided from paths alone. */
@@ -49,11 +59,11 @@ export function isForbiddenPublicPath(rel) {
49
59
  return FORBIDDEN_PUBLIC_DIRS.some((d) => rel.startsWith(d.dir));
50
60
  }
51
61
 
52
- /** The kit command that moves downloaded catalog images and blog media back to their source URLs. */
53
- export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
54
-
55
62
  const RASTER_OR_MEDIA = /\.(?:jpe?g|png|gif|webp|avif|bmp|tiff?|heic|heif|ico|jxl|mp4|m4v|mov|webm|mkv|avi|mpe?g|mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
56
63
 
64
+ /** The formats {@link IMAGE_ADD_COMMAND} accepts. */
65
+ const UPLOADABLE_IMAGE = /\.(?:jpe?g|png|gif|webp|avif)$/i;
66
+
57
67
  const mb = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
58
68
 
59
69
  /**
@@ -66,7 +76,7 @@ export function publicBinaryFindings(entries, limits = {}) {
66
76
  const maxFileBytes = limits.maxFileBytes ?? PUBLIC_BINARY_LIMITS.maxFileBytes;
67
77
  const maxTotalBytes = limits.maxTotalBytes ?? PUBLIC_BINARY_LIMITS.maxTotalBytes;
68
78
  const findings = [];
69
- for (const { dir, code, noun } of FORBIDDEN_PUBLIC_DIRS) {
79
+ for (const { dir, code, noun, remedy } of FORBIDDEN_PUBLIC_DIRS) {
70
80
  const files = entries.filter((e) => e.rel.startsWith(dir));
71
81
  if (files.length === 0) continue;
72
82
  const bytes = files.reduce((n, e) => n + (e.bytes ?? 0), 0);
@@ -74,8 +84,8 @@ export function publicBinaryFindings(entries, limits = {}) {
74
84
  code,
75
85
  path: `public/${dir}`,
76
86
  message: `${files.length} ${noun} file(s)${bytes ? ` (${mb(bytes)})` : ""} are committed to the store repo; ` +
77
- "images belong in the platform pool, not the repo; until platform capture owns them, keep each image at its source URL",
78
- remedy: UNHOST_IMAGES_COMMAND,
87
+ "images belong in the platform pool, not the repo",
88
+ remedy,
79
89
  });
80
90
  }
81
91
  for (const e of entries) {
@@ -86,7 +96,9 @@ export function publicBinaryFindings(entries, limits = {}) {
86
96
  path: `public/${e.rel}`,
87
97
  message: `${mb(e.bytes)} raster/media file exceeds the ${mb(maxFileBytes)} per-file limit for the store repo; ` +
88
98
  "images and media belong in the platform pool",
89
- remedy: "reference the file by its source URL, or compress it under the limit",
99
+ remedy: UPLOADABLE_IMAGE.test(e.rel)
100
+ ? `${IMAGE_ADD_COMMAND}, then reference the /_a/ URL it prints instead of the file`
101
+ : "reference the file by its source URL, or compress it under the limit",
90
102
  });
91
103
  }
92
104
  }
@@ -98,7 +110,8 @@ export function publicBinaryFindings(entries, limits = {}) {
98
110
  path: "public/",
99
111
  message: `public/ holds ${mb(total)} across ${entries.length} file(s), over the ${mb(maxTotalBytes)} store-repo budget; ` +
100
112
  "images and media belong in the platform pool",
101
- remedy: "reference large or bulk assets by their source URL instead of committing them",
113
+ remedy: `upload images with ${IMAGE_ADD_COMMAND} and reference the /_a/ URLs it prints; ` +
114
+ "reference other large assets by their source URL",
102
115
  });
103
116
  }
104
117
  }