@cerkel/cli 0.0.0-stage → 1.0.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 +502 -2
- package/dist/api-url.js +42 -0
- package/dist/api.js +48 -0
- package/dist/bin.js +157 -0
- package/dist/chunk.js +31 -0
- package/dist/cli.js +497 -0
- package/dist/client.js +125 -0
- package/dist/command.js +2 -0
- package/dist/commands/args.js +107 -0
- package/dist/commands/auth.js +255 -0
- package/dist/commands/call.js +67 -0
- package/dist/commands/collection.js +121 -0
- package/dist/commands/content.js +281 -0
- package/dist/commands/customer.js +126 -0
- package/dist/commands/dev.js +261 -0
- package/dist/commands/draft.js +101 -0
- package/dist/commands/file.js +53 -0
- package/dist/commands/index.js +43 -0
- package/dist/commands/inventory.js +103 -0
- package/dist/commands/offer.js +92 -0
- package/dist/commands/order-args.js +37 -0
- package/dist/commands/order.js +219 -0
- package/dist/commands/price.js +55 -0
- package/dist/commands/product.js +265 -0
- package/dist/commands/promo.js +140 -0
- package/dist/commands/redirect.js +94 -0
- package/dist/commands/report.js +93 -0
- package/dist/commands/section.js +233 -0
- package/dist/commands/store.js +23 -0
- package/dist/commands/theme-tools.js +432 -0
- package/dist/commands/theme.js +388 -0
- package/dist/commands/whatsapp.js +39 -0
- package/dist/commands/zone.js +90 -0
- package/dist/commerce.js +138 -0
- package/dist/content-files.js +246 -0
- package/dist/context.js +5 -0
- package/dist/credentials.js +201 -0
- package/dist/diff.js +146 -0
- package/dist/env.js +24 -0
- package/dist/errors.js +73 -0
- package/dist/format.js +13 -0
- package/dist/layout.js +122 -0
- package/dist/logins.js +49 -0
- package/dist/raizn-alias.js +6 -0
- package/dist/redact.js +27 -0
- package/dist/session.js +266 -0
- package/dist/state.js +153 -0
- package/dist/sync.js +121 -0
- package/dist/zip.js +88 -0
- package/package.json +37 -3
package/dist/errors.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure the CLI reports is one of these. Commands throw them; `main` turns them into one
|
|
3
|
+
* redacted line and an exit code. Nothing else prints an error.
|
|
4
|
+
*/
|
|
5
|
+
export class CliError extends Error {
|
|
6
|
+
exitCode;
|
|
7
|
+
name = "CliError";
|
|
8
|
+
constructor(message, exitCode = 1) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.exitCode = exitCode;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 401 from the server. Never retried: a revoked token does not come back by asking again.
|
|
15
|
+
* `reason` is the server's machine word for it (`data.reason`: `revoked`, `cli_store_required`, …)
|
|
16
|
+
* when it sent one.
|
|
17
|
+
*/
|
|
18
|
+
export class AuthError extends CliError {
|
|
19
|
+
reason;
|
|
20
|
+
name = "AuthError";
|
|
21
|
+
constructor(message, reason = null) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.reason = reason;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* This login (or RAIZN_TOKEN) cannot be used for the store the command is for: it is not tied to a
|
|
28
|
+
* store any more, or it is tied to another one. The message is one line that already says what to
|
|
29
|
+
* run, so it is printed exactly as it is. Like every AuthError it is never retried.
|
|
30
|
+
*/
|
|
31
|
+
export class StoreLoginError extends AuthError {
|
|
32
|
+
name = "StoreLoginError";
|
|
33
|
+
}
|
|
34
|
+
/** The server's two words for "this token is not bound to exactly one store". */
|
|
35
|
+
export const isNotOneStore = (reason) => reason === "cli_store_required" || reason === "store_required";
|
|
36
|
+
/** 429 from the server. `retryAfterSeconds` is what the server asked for, not a guess. */
|
|
37
|
+
export class RateLimitError extends CliError {
|
|
38
|
+
retryAfterSeconds;
|
|
39
|
+
name = "RateLimitError";
|
|
40
|
+
constructor(message, retryAfterSeconds) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The server looked at the request and said no (`permission_denied`, `not_a_draft`, …). The code
|
|
47
|
+
* and message are the server's, shown verbatim: the CLI's own checks are a convenience, the
|
|
48
|
+
* server's answer is the truth.
|
|
49
|
+
*/
|
|
50
|
+
export class ToolRefusal extends CliError {
|
|
51
|
+
tool;
|
|
52
|
+
code;
|
|
53
|
+
serverMessage;
|
|
54
|
+
details;
|
|
55
|
+
name = "ToolRefusal";
|
|
56
|
+
constructor(tool, code, serverMessage, details) {
|
|
57
|
+
super(`The server refused ${tool}: ${code}: ${serverMessage}`);
|
|
58
|
+
this.tool = tool;
|
|
59
|
+
this.code = code;
|
|
60
|
+
this.serverMessage = serverMessage;
|
|
61
|
+
this.details = details;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Transport failures: network down, 413, 5xx, a body that is not JSON-RPC. */
|
|
65
|
+
export class ServerError extends CliError {
|
|
66
|
+
status;
|
|
67
|
+
name = "ServerError";
|
|
68
|
+
constructor(message, status) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.status = status;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const retryAt = (seconds, now) => new Date(now.getTime() + seconds * 1000).toLocaleTimeString("en-GB", { hour12: false });
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Left-aligned columns, two spaces apart. Plain text so it pipes into grep/awk cleanly. */
|
|
2
|
+
export function table(rows) {
|
|
3
|
+
const widths = [];
|
|
4
|
+
for (const r of rows)
|
|
5
|
+
r.forEach((c, i) => (widths[i] = Math.max(widths[i] ?? 0, [...c].length)));
|
|
6
|
+
return rows.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c + " ".repeat((widths[i] ?? 0) - [...c].length))).join(" ").trimEnd());
|
|
7
|
+
}
|
|
8
|
+
/** `path:line:col severity message` — the shape editors and CI annotators already parse. */
|
|
9
|
+
export function formatIssue(path, issue, withSeverity = true) {
|
|
10
|
+
const loc = issue.line ? `${path}:${issue.line}:${issue.column ?? 1}` : path;
|
|
11
|
+
return withSeverity ? `${loc} ${issue.severity} ${issue.message}` : `${loc} ${issue.message}`;
|
|
12
|
+
}
|
|
13
|
+
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The Shopify-style layout (contract § File layout). Layout paths are always POSIX
|
|
5
|
+
* (`templates/index.json`); they become OS paths only at the moment we touch the disk.
|
|
6
|
+
*/
|
|
7
|
+
/** A section type — the server's rule (`@raizn/core/theme/files` `SECTION_TYPE_RE`), 2–80 characters. */
|
|
8
|
+
export const SECTION_TYPE_RE = /^[a-z0-9][a-z0-9_-]{1,79}$/;
|
|
9
|
+
/** A block or snippet name: may start with `_` (Horizon's private blocks, `blocks/_card.liquid`). */
|
|
10
|
+
export const PART_NAME_RE = /^_?[a-z0-9][a-z0-9_-]{0,79}$/;
|
|
11
|
+
/** A stored asset: lower-case `.css` or `.svg` (images and fonts live in the store's media library). */
|
|
12
|
+
const ASSET_RE = /^assets\/[a-z0-9][a-z0-9._-]{0,99}\.(css|svg)$/;
|
|
13
|
+
const TEMPLATE_RE = /^templates\/[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)?\.json$/;
|
|
14
|
+
const LAYOUT_RE = /^layout\/(header|footer)\.json$/;
|
|
15
|
+
const SECTION_RE = /^sections\/([a-z0-9][a-z0-9_-]{1,79})\.liquid$/;
|
|
16
|
+
const PART_RE = /^(blocks|snippets)\/_?[a-z0-9][a-z0-9_-]{0,79}\.liquid$/;
|
|
17
|
+
/** The theme's built-in texts, one file per storefront language (contract § File layout, "Theme content"). */
|
|
18
|
+
const LOCALES_RE = /^locales\/(ar|en|ckb)\.json$/;
|
|
19
|
+
const FIXED = new Set(["config/settings.json", "assets/custom.css"]);
|
|
20
|
+
/**
|
|
21
|
+
* Before migration 0079 a section was three files: `.liquid`, `.schema.json` and `.css`. They are
|
|
22
|
+
* not theme files any more (the server refuses them), but an old checkout still has them on disk;
|
|
23
|
+
* they are recognised ONLY so pull can clean them up and push can say what to do with them.
|
|
24
|
+
*/
|
|
25
|
+
const LEGACY_PART_RE = /^sections\/([a-z0-9][a-z0-9_-]{1,79})\.(schema\.json|css)$/;
|
|
26
|
+
/** Top-level folders the layout uses (Shopify's), in its order; the walker never descends anywhere else. */
|
|
27
|
+
export const LAYOUT_DIRS = ["layout", "templates", "sections", "blocks", "snippets", "assets", "config", "locales"];
|
|
28
|
+
/**
|
|
29
|
+
* A theme file path, mirroring the server's grammar (docs/liquid-themes.md §1): lower case, one
|
|
30
|
+
* folder deep, no traversal. Anything the server would refuse is not a layout path here either.
|
|
31
|
+
*/
|
|
32
|
+
export function isLayoutPath(p) {
|
|
33
|
+
if (typeof p !== "string" || p.length > 200)
|
|
34
|
+
return false;
|
|
35
|
+
return FIXED.has(p) || TEMPLATE_RE.test(p) || LAYOUT_RE.test(p) || SECTION_RE.test(p) || PART_RE.test(p) || ASSET_RE.test(p) || LOCALES_RE.test(p);
|
|
36
|
+
}
|
|
37
|
+
/** `sections/<type>.schema.json` / `.css` from a pre-0079 checkout → the section type; null otherwise. */
|
|
38
|
+
export function legacySectionPartOf(p) {
|
|
39
|
+
return LEGACY_PART_RE.exec(p)?.[1] ?? null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `assets/*.generated.css` is written by the platform (the theme's compiled stylesheet): pulled for
|
|
43
|
+
* reference, never pushed or deleted from here.
|
|
44
|
+
*/
|
|
45
|
+
export const isGeneratedPath = (p) => /^assets\/[a-z0-9][a-z0-9._-]*\.generated\.css$/.test(p);
|
|
46
|
+
/**
|
|
47
|
+
* Files the server can delete from a draft: sections, blocks, snippets and stored assets. Templates,
|
|
48
|
+
* the layout groups, `config/settings.json`, `assets/custom.css` and locales always exist.
|
|
49
|
+
*/
|
|
50
|
+
export const isDeletablePath = (p) => !FIXED.has(p) && !isGeneratedPath(p) && (SECTION_RE.test(p) || PART_RE.test(p) || ASSET_RE.test(p));
|
|
51
|
+
/** `sections/promo.liquid` → `promo`; null for anything that is not a section file. */
|
|
52
|
+
export function sectionTypeOf(p) {
|
|
53
|
+
return SECTION_RE.exec(p)?.[1] ?? null;
|
|
54
|
+
}
|
|
55
|
+
/** A section is ONE file: its Liquid, then `{% stylesheet %}`, then `{% schema %}`. */
|
|
56
|
+
export const sectionPath = (type) => `sections/${type}.liquid`;
|
|
57
|
+
/**
|
|
58
|
+
* Server-side write order: stored files (sections, blocks, snippets, assets) → settings/CSS/texts →
|
|
59
|
+
* templates; chunking follows it so a template never arrives in an earlier request than the new
|
|
60
|
+
* section it places.
|
|
61
|
+
*/
|
|
62
|
+
export function writeRank(p) {
|
|
63
|
+
if (FIXED.has(p))
|
|
64
|
+
return 1;
|
|
65
|
+
if (p.startsWith("sections/") || p.startsWith("blocks/") || p.startsWith("snippets/") || p.startsWith("assets/"))
|
|
66
|
+
return 0;
|
|
67
|
+
if (p.startsWith("config/") || p.startsWith("locales/"))
|
|
68
|
+
return 1;
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Layout path → absolute OS path under `root`. Paths come from the server too, so this is also
|
|
73
|
+
* the guard against `../` or an absolute path escaping the checkout: only exact layout shapes pass
|
|
74
|
+
* (plus the legacy section parts, which have the same safe shape).
|
|
75
|
+
*/
|
|
76
|
+
export function toDiskPath(root, layoutPath) {
|
|
77
|
+
if (!isLayoutPath(layoutPath) && !legacySectionPartOf(layoutPath))
|
|
78
|
+
throw new Error(`Not a theme file path: ${layoutPath}`);
|
|
79
|
+
return path.join(root, ...layoutPath.split("/"));
|
|
80
|
+
}
|
|
81
|
+
/** Absolute or cwd-relative OS path → layout path, or null when it is outside the checkout. */
|
|
82
|
+
export function toLayoutPath(root, diskPath) {
|
|
83
|
+
const rel = path.relative(root, path.resolve(diskPath));
|
|
84
|
+
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
|
|
85
|
+
return null;
|
|
86
|
+
return rel.split(path.sep).join(path.posix.sep);
|
|
87
|
+
}
|
|
88
|
+
// --- content ---------------------------------------------------------------
|
|
89
|
+
/**
|
|
90
|
+
* A git checkout with `core.autocrlf` rewrites LF to CRLF on disk. Hashing and sending the
|
|
91
|
+
* LF form means such a checkout neither looks modified nor pushes carriage returns.
|
|
92
|
+
*/
|
|
93
|
+
export function normaliseEol(buf) {
|
|
94
|
+
if (!buf.includes(0x0d))
|
|
95
|
+
return buf;
|
|
96
|
+
const out = Buffer.allocUnsafe(buf.length);
|
|
97
|
+
let j = 0;
|
|
98
|
+
for (let i = 0; i < buf.length; i++) {
|
|
99
|
+
const b = buf[i];
|
|
100
|
+
if (b === 0x0d && buf[i + 1] === 0x0a)
|
|
101
|
+
continue;
|
|
102
|
+
out[j++] = b;
|
|
103
|
+
}
|
|
104
|
+
return out.subarray(0, j);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* One leading UTF-8 BOM (EF BB BF) is dropped before hashing and sending. Windows PowerShell 5.1
|
|
108
|
+
* (`Set-Content -Encoding utf8`) adds one; it carries no meaning, the server's JSON parser refuses
|
|
109
|
+
* it, and a file that differs only by it is not a change. Only the first one goes: a second
|
|
110
|
+
* U+FEFF is content. Pulls still write exactly what the server sent.
|
|
111
|
+
*/
|
|
112
|
+
export function stripBom(buf) {
|
|
113
|
+
return buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf ? buf.subarray(3) : buf;
|
|
114
|
+
}
|
|
115
|
+
export const hashBytes = (buf) => createHash("sha256").update(normaliseEol(stripBom(buf))).digest("hex");
|
|
116
|
+
/**
|
|
117
|
+
* Bytes → the string we send: one leading BOM dropped, CRLF → LF, everything else exactly as
|
|
118
|
+
* saved (`Buffer#toString` does not eat a further U+FEFF the way TextDecoder would).
|
|
119
|
+
*/
|
|
120
|
+
export const textOf = (buf) => normaliseEol(stripBom(buf)).toString("utf8");
|
|
121
|
+
/** The server's content → the exact bytes we write. No BOM added, no newline appended. */
|
|
122
|
+
export const bytesOf = (content) => Buffer.from(content, "utf8");
|
package/dist/logins.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** A store slug as the server names stores: lowercase letters, digits and hyphens. */
|
|
2
|
+
export const STORE_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
3
|
+
export const isStoreSlug = (s) => STORE_SLUG_RE.test(s);
|
|
4
|
+
const VERSION = 2;
|
|
5
|
+
export const emptyBook = () => ({ logins: {}, legacy: null });
|
|
6
|
+
export function parseBook(raw) {
|
|
7
|
+
if (!raw || !raw.trim())
|
|
8
|
+
return emptyBook();
|
|
9
|
+
const text = raw.trim();
|
|
10
|
+
if (text.startsWith("{")) {
|
|
11
|
+
try {
|
|
12
|
+
const o = JSON.parse(text);
|
|
13
|
+
if (o && o.v === VERSION && o.logins && typeof o.logins === "object" && !Array.isArray(o.logins)) {
|
|
14
|
+
const logins = {};
|
|
15
|
+
for (const [slug, token] of Object.entries(o.logins)) {
|
|
16
|
+
if (isStoreSlug(slug) && typeof token === "string" && token)
|
|
17
|
+
logins[slug] = token;
|
|
18
|
+
}
|
|
19
|
+
return { logins, legacy: typeof o.legacy === "string" && o.legacy ? o.legacy : null };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* not a book: fall through and treat it as a bare token */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { logins: {}, legacy: text };
|
|
27
|
+
}
|
|
28
|
+
/** null = nothing left to keep (the caller deletes the entry). Logins are written in slug order. */
|
|
29
|
+
export function serialiseBook(book) {
|
|
30
|
+
const slugs = Object.keys(book.logins).filter((s) => book.logins[s]).sort();
|
|
31
|
+
if (slugs.length === 0 && !book.legacy)
|
|
32
|
+
return null;
|
|
33
|
+
const logins = Object.fromEntries(slugs.map((s) => [s, book.logins[s]]));
|
|
34
|
+
return JSON.stringify({ v: VERSION, logins, ...(book.legacy ? { legacy: book.legacy } : {}) });
|
|
35
|
+
}
|
|
36
|
+
export const bookTokens = (book) => [...Object.values(book.logins), ...(book.legacy ? [book.legacy] : [])];
|
|
37
|
+
export const bookStores = (book) => Object.keys(book.logins).sort();
|
|
38
|
+
export async function readBook(creds, origin) {
|
|
39
|
+
return parseBook(await creds.get(origin));
|
|
40
|
+
}
|
|
41
|
+
/** Saves the book for this origin; an empty book removes the entry altogether. */
|
|
42
|
+
export async function writeBook(creds, origin, book) {
|
|
43
|
+
const value = serialiseBook(book);
|
|
44
|
+
if (value === null) {
|
|
45
|
+
await creds.delete(origin);
|
|
46
|
+
return "removed";
|
|
47
|
+
}
|
|
48
|
+
return creds.set(origin, value);
|
|
49
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `raizn` is the CLI's old name (@raizn/cli). This alias keeps old scripts working for a while: one
|
|
3
|
+
// line on stderr (so --json output and pipes are untouched), then exactly what `cerkel` would do.
|
|
4
|
+
process.stderr.write("raizn is now cerkel: run `cerkel …` instead (this alias goes away in a later release).\n");
|
|
5
|
+
await import("./bin.js");
|
|
6
|
+
export {};
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the CLI prints goes through a redactor. The token is the one secret this tool holds,
|
|
3
|
+
* and the places it could leak are exactly the ones nobody reviews: an error echoed from a proxy,
|
|
4
|
+
* a stack trace pasted into a chat, CI logs kept for months.
|
|
5
|
+
*/
|
|
6
|
+
export const REDACTED = "***";
|
|
7
|
+
// A full MCP token is `rzn_` + 43 base64url characters. The 8-character prefix the server shows
|
|
8
|
+
// (e.g. `rzn_ab12`) is too short to match, so it stays readable — it names a token, it does not
|
|
9
|
+
// grant anything.
|
|
10
|
+
const TOKEN_SHAPE = /rzn_[A-Za-z0-9_-]{16,}/g;
|
|
11
|
+
// Anything shaped like an Authorization header value, whichever scheme casing a proxy echoes back.
|
|
12
|
+
const BEARER_SHAPE = /\b(Bearer)\s+[^\s"',;]+/gi;
|
|
13
|
+
const AUTH_HEADER_SHAPE = /\b(authorization)(["']?\s*[:=]\s*["']?)(?:[A-Za-z]+\s+)?[^\s"',;]+/gi;
|
|
14
|
+
export function makeRedactor(secrets) {
|
|
15
|
+
return (text) => {
|
|
16
|
+
let out = text;
|
|
17
|
+
for (const s of secrets()) {
|
|
18
|
+
// Very short strings would shred ordinary words; a real token is never this short.
|
|
19
|
+
if (s && s.length >= 8)
|
|
20
|
+
out = out.split(s).join(REDACTED);
|
|
21
|
+
}
|
|
22
|
+
return out
|
|
23
|
+
.replace(TOKEN_SHAPE, REDACTED)
|
|
24
|
+
.replace(AUTH_HEADER_SHAPE, `$1$2${REDACTED}`)
|
|
25
|
+
.replace(BEARER_SHAPE, `$1 ${REDACTED}`);
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { DEFAULT_API_URL, apiOrigin, parseApiUrl } from "./api-url.js";
|
|
2
|
+
import { listStores, oneStoreOf } from "./api.js";
|
|
3
|
+
import { McpClient } from "./client.js";
|
|
4
|
+
import { AuthError, CliError, StoreLoginError, ToolRefusal, isNotOneStore } from "./errors.js";
|
|
5
|
+
import { bookStores, bookTokens, readBook, writeBook } from "./logins.js";
|
|
6
|
+
import { envApiUrl, envToken, envTokenSecrets } from "./env.js";
|
|
7
|
+
import { makeRedactor } from "./redact.js";
|
|
8
|
+
import { findRoot, readState } from "./state.js";
|
|
9
|
+
/** "This login is not tied to a store any more": naming the folder's / requested store, or none. */
|
|
10
|
+
export function notTiedLine(source, store, envVar = "CERKEL_TOKEN") {
|
|
11
|
+
return source === "env"
|
|
12
|
+
? `${envVar} is not tied to one store. Create a token for one store in the Cerkel admin at Settings → MCP.`
|
|
13
|
+
: `This login is not tied to a store any more — run \`cerkel login --store ${store ?? "<slug>"}\`.`;
|
|
14
|
+
}
|
|
15
|
+
/** "This login is for X — run cerkel login --store Y": X is the login's own store, Y the one asked for. */
|
|
16
|
+
export function mismatchLine(source, own, asked, envVar = "CERKEL_TOKEN") {
|
|
17
|
+
if (source === "env") {
|
|
18
|
+
return own ? `${envVar} is for ${own}, not ${asked}. Use a token for ${asked}.` : `${envVar} cannot reach ${asked}. Use a token for ${asked}.`;
|
|
19
|
+
}
|
|
20
|
+
return own ? `This login is for ${own} — run \`cerkel login --store ${asked}\`.` : `This login cannot reach ${asked} — run \`cerkel login --store ${asked}\`.`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A client whose refusals speak about logins and stores in the CLI's words: a login the server no
|
|
24
|
+
* longer accepts because it is not tied to one store, a store this login is not for. Everything else
|
|
25
|
+
* is the server's own message, untouched.
|
|
26
|
+
*/
|
|
27
|
+
class StoreClient extends McpClient {
|
|
28
|
+
login;
|
|
29
|
+
constructor(opts, login) {
|
|
30
|
+
super(opts);
|
|
31
|
+
this.login = login;
|
|
32
|
+
}
|
|
33
|
+
async call(tool, args) {
|
|
34
|
+
try {
|
|
35
|
+
return await super.call(tool, args);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
throw await this.explain(e, args);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async explain(e, args) {
|
|
42
|
+
const asked = typeof args["store"] === "string" ? args["store"] : this.login.store;
|
|
43
|
+
if (e instanceof AuthError && !(e instanceof StoreLoginError)) {
|
|
44
|
+
if (isNotOneStore(e.reason))
|
|
45
|
+
return new StoreLoginError(notTiedLine(this.login.source, asked, this.login.envVar), e.reason);
|
|
46
|
+
if (this.login.source === "stored")
|
|
47
|
+
return new AuthError(`${e.message} Run \`cerkel login --store ${asked ?? "<slug>"}\` to sign in again.`, e.reason);
|
|
48
|
+
return e;
|
|
49
|
+
}
|
|
50
|
+
// The server's refusal is the same for every store but this login's, real or not; say which
|
|
51
|
+
// store THIS login is for (asking the server with the same token), and nothing about any other.
|
|
52
|
+
if (e instanceof ToolRefusal && e.code === "store_not_allowed" && asked) {
|
|
53
|
+
return new StoreLoginError(mismatchLine(this.login.source, await this.ownStore(), asked, this.login.envVar));
|
|
54
|
+
}
|
|
55
|
+
return e;
|
|
56
|
+
}
|
|
57
|
+
async ownStore() {
|
|
58
|
+
try {
|
|
59
|
+
return oneStoreOf(await super.call("list_stores", {}))?.slug ?? null;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export class Session {
|
|
67
|
+
ctx;
|
|
68
|
+
apiFlag;
|
|
69
|
+
secrets = [];
|
|
70
|
+
redact;
|
|
71
|
+
clients = new Map();
|
|
72
|
+
books = new Map();
|
|
73
|
+
apiUrl = null;
|
|
74
|
+
constructor(ctx, apiFlag) {
|
|
75
|
+
this.ctx = ctx;
|
|
76
|
+
this.apiFlag = apiFlag;
|
|
77
|
+
this.redact = makeRedactor(() => this.secrets);
|
|
78
|
+
this.secrets.push(...envTokenSecrets(ctx.env));
|
|
79
|
+
}
|
|
80
|
+
/** Every printed line goes through the redactor — not only errors — so no path can leak it. */
|
|
81
|
+
out(line = "") {
|
|
82
|
+
this.ctx.io.out(this.redact(line));
|
|
83
|
+
}
|
|
84
|
+
err(line = "") {
|
|
85
|
+
this.ctx.io.err(this.redact(line));
|
|
86
|
+
}
|
|
87
|
+
addSecret(s) {
|
|
88
|
+
if (s)
|
|
89
|
+
this.secrets.push(s);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `--api`, then the checkout's recorded API, then CERKEL_API_URL (or the old RAIZN_API_URL), then the default. The
|
|
93
|
+
* checkout outranks the environment because its theme id only means something on the server
|
|
94
|
+
* it was pulled from; an explicit flag that disagrees with it is refused rather than obeyed.
|
|
95
|
+
*/
|
|
96
|
+
api(state) {
|
|
97
|
+
if (this.apiUrl)
|
|
98
|
+
return this.apiUrl;
|
|
99
|
+
const flag = this.apiFlag ? parseApiUrl(this.apiFlag) : null;
|
|
100
|
+
const recorded = state?.api ? parseApiUrl(state.api) : null;
|
|
101
|
+
if (flag && recorded && flag.href !== recorded.href) {
|
|
102
|
+
throw new CliError(`This folder was pulled from ${recorded.href}, not ${flag.href}. Drop --api, or pull into a different folder.`);
|
|
103
|
+
}
|
|
104
|
+
this.apiUrl = flag ?? recorded ?? parseApiUrl(envApiUrl(this.ctx.env) ?? DEFAULT_API_URL);
|
|
105
|
+
return this.apiUrl;
|
|
106
|
+
}
|
|
107
|
+
/** This origin's logins, read once per command. Every token in it is redacted from all output. */
|
|
108
|
+
book(url) {
|
|
109
|
+
const origin = apiOrigin(url);
|
|
110
|
+
let b = this.books.get(origin);
|
|
111
|
+
if (!b) {
|
|
112
|
+
b = readBook(this.ctx.credentials, origin).then((book) => {
|
|
113
|
+
for (const t of bookTokens(book))
|
|
114
|
+
this.addSecret(t);
|
|
115
|
+
return book;
|
|
116
|
+
});
|
|
117
|
+
this.books.set(origin, b);
|
|
118
|
+
}
|
|
119
|
+
return b;
|
|
120
|
+
}
|
|
121
|
+
async saveBook(url, book) {
|
|
122
|
+
const origin = apiOrigin(url);
|
|
123
|
+
for (const t of bookTokens(book))
|
|
124
|
+
this.addSecret(t);
|
|
125
|
+
const where = await writeBook(this.ctx.credentials, origin, book);
|
|
126
|
+
this.books.set(origin, Promise.resolve(book));
|
|
127
|
+
return where;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The login for `store` (a --store flag, or the theme folder's store) — or, with no store to go
|
|
131
|
+
* on, the only login there is. CERKEL_TOKEN (CI; or the old RAIZN_TOKEN) always wins, so a pipeline never picks up
|
|
132
|
+
* someone's laptop token. Never names a store the caller did not name: with several logins and no
|
|
133
|
+
* store it asks for --store.
|
|
134
|
+
*/
|
|
135
|
+
async login(url, store) {
|
|
136
|
+
const env = envToken(this.ctx.env);
|
|
137
|
+
if (env)
|
|
138
|
+
return { token: env.value, source: "env", store, envVar: env.name };
|
|
139
|
+
let book = await this.book(url);
|
|
140
|
+
// A 0.1.x token is only consulted when the per-store logins cannot answer, and what is wrong
|
|
141
|
+
// with it only surfaces when nothing else can be used.
|
|
142
|
+
let problem = null;
|
|
143
|
+
if (book.legacy && !(store && book.logins[store]))
|
|
144
|
+
({ book, problem } = await this.adoptLegacy(url, book, store));
|
|
145
|
+
if (store) {
|
|
146
|
+
const token = book.logins[store];
|
|
147
|
+
if (token)
|
|
148
|
+
return { token, source: "stored", store };
|
|
149
|
+
throw problem ?? new CliError(`Not logged in to ${store}. Run \`cerkel login --store ${store}\`.`);
|
|
150
|
+
}
|
|
151
|
+
const stores = bookStores(book);
|
|
152
|
+
if (stores.length === 1)
|
|
153
|
+
return { token: book.logins[stores[0]], source: "stored", store: stores[0] };
|
|
154
|
+
if (stores.length === 0)
|
|
155
|
+
throw problem ?? new CliError(`Not logged in to ${apiOrigin(url)}. Run \`cerkel login --store <slug>\` (or set CERKEL_TOKEN in CI).`);
|
|
156
|
+
throw new CliError("You are logged in to more than one store. Pass --store <slug>, or run this inside a theme folder (`cerkel logins` lists your logins).");
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* A token saved by raizn 0.1.x is not filed under a store. Ask the server which store it is for:
|
|
160
|
+
* one that answers with exactly one store is filed under it, so no command asks again; one the
|
|
161
|
+
* server no longer accepts — a 0.1.x login reached every store, which is refused for good — is
|
|
162
|
+
* dropped, and `problem` says to log in to one store. A network failure throws and keeps it for
|
|
163
|
+
* next time.
|
|
164
|
+
*/
|
|
165
|
+
async adoptLegacy(url, book, store) {
|
|
166
|
+
const legacy = book.legacy;
|
|
167
|
+
const next = { logins: { ...book.logins }, legacy: null };
|
|
168
|
+
let who;
|
|
169
|
+
try {
|
|
170
|
+
who = await listStores(this.clientWith(url, legacy));
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
if (!(e instanceof AuthError))
|
|
174
|
+
throw e;
|
|
175
|
+
await this.saveBook(url, next);
|
|
176
|
+
return {
|
|
177
|
+
book: next,
|
|
178
|
+
problem: isNotOneStore(e.reason)
|
|
179
|
+
? new StoreLoginError(notTiedLine("stored", store), e.reason)
|
|
180
|
+
: new AuthError(`Your saved login no longer works: ${e.message} Run \`cerkel login --store ${store ?? "<slug>"}\`.`, e.reason),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const own = oneStoreOf(who);
|
|
184
|
+
// A token for several stores (an old Settings → MCP token in its grace) is not something cerkel uses.
|
|
185
|
+
const adopted = own ? { logins: { [own.slug]: legacy, ...next.logins }, legacy: null } : next;
|
|
186
|
+
await this.saveBook(url, adopted);
|
|
187
|
+
return { book: adopted, problem: own ? null : new StoreLoginError(notTiedLine("stored", store)) };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* `adoptLegacy` for `cerkel login`, `logout` and `logins`, which must not fail because of an old
|
|
191
|
+
* login: files it under its store when it can, drops it when the server refuses it and says so,
|
|
192
|
+
* and leaves it alone when the server cannot be reached.
|
|
193
|
+
*/
|
|
194
|
+
async tidyLegacy(url) {
|
|
195
|
+
const book = await this.book(url);
|
|
196
|
+
if (!book.legacy)
|
|
197
|
+
return;
|
|
198
|
+
try {
|
|
199
|
+
const { problem } = await this.adoptLegacy(url, book, null);
|
|
200
|
+
if (problem)
|
|
201
|
+
this.err("Removed an older saved login that is not tied to one store (cerkel now keeps one login per store).");
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* offline: keep it for next time */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
clientWith(url, token) {
|
|
208
|
+
this.addSecret(token);
|
|
209
|
+
return new McpClient({ url, token, fetch: this.ctx.fetch, userAgent: `cerkel-cli/${this.ctx.version}` });
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The client for one store: `store`, or the folder's (`state.store`), or — with neither — the
|
|
213
|
+
* only login. Its refusals are translated into the CLI's lines about logins and stores.
|
|
214
|
+
*/
|
|
215
|
+
client(state, store) {
|
|
216
|
+
const forStore = store ?? state?.store ?? null;
|
|
217
|
+
const key = forStore ?? "";
|
|
218
|
+
let c = this.clients.get(key);
|
|
219
|
+
if (!c) {
|
|
220
|
+
c = (async () => {
|
|
221
|
+
const url = this.api(state);
|
|
222
|
+
const login = await this.login(url, forStore);
|
|
223
|
+
this.addSecret(login.token);
|
|
224
|
+
return new StoreClient({ url, token: login.token, fetch: this.ctx.fetch, userAgent: `cerkel-cli/${this.ctx.version}` }, login);
|
|
225
|
+
})();
|
|
226
|
+
this.clients.set(key, c);
|
|
227
|
+
}
|
|
228
|
+
return c;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The store a command means when it names none and runs outside a theme folder: the store of the
|
|
232
|
+
* one login there is (RAIZN_TOKEN: the store the server says it is for).
|
|
233
|
+
*/
|
|
234
|
+
async defaultStore() {
|
|
235
|
+
const login = await this.login(this.api(), null);
|
|
236
|
+
if (login.store)
|
|
237
|
+
return login.store;
|
|
238
|
+
const own = oneStoreOf(await listStores(await this.client(null, null)));
|
|
239
|
+
if (!own)
|
|
240
|
+
throw new CliError("Which store? Pass --store <slug>.");
|
|
241
|
+
return own.slug;
|
|
242
|
+
}
|
|
243
|
+
/** The checkout this command runs in, found by walking up from the working directory. */
|
|
244
|
+
async checkout() {
|
|
245
|
+
const root = await findRoot(this.ctx.cwd);
|
|
246
|
+
if (!root)
|
|
247
|
+
return null;
|
|
248
|
+
const state = await readState(root);
|
|
249
|
+
return state ? { root, state } : null;
|
|
250
|
+
}
|
|
251
|
+
async requireCheckout(opts) {
|
|
252
|
+
const co = await this.checkout();
|
|
253
|
+
if (!co)
|
|
254
|
+
throw new CliError("This folder is not a theme checkout. Run `cerkel theme pull --store <slug>` first.");
|
|
255
|
+
if (opts.editable && co.state.readOnly) {
|
|
256
|
+
throw new CliError(`${co.root} is a read-only copy of the LIVE theme. Nothing can be written from here. Run \`cerkel theme pull --store ${co.state.store}\` in another folder to edit a draft.`);
|
|
257
|
+
}
|
|
258
|
+
// A convenience, not the control: the server refuses every write to a non-draft (`not_a_draft`).
|
|
259
|
+
// This only saves a round trip when state.json says the folder holds something else.
|
|
260
|
+
if (opts.editable && co.state.role !== "draft") {
|
|
261
|
+
const what = co.state.role === "live" ? "the LIVE theme" : `a theme whose role is "${String(co.state.role)}", not a draft`;
|
|
262
|
+
throw new CliError(`${co.root} holds ${what} (${co.state.themeId}). Nothing can be written from here. Pull a draft with \`cerkel theme pull --store ${co.state.store}\` in another folder.`);
|
|
263
|
+
}
|
|
264
|
+
return co;
|
|
265
|
+
}
|
|
266
|
+
}
|