@raizn/cli 0.1.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 +160 -0
- package/dist/api-url.js +42 -0
- package/dist/api.js +29 -0
- package/dist/bin.js +138 -0
- package/dist/chunk.js +31 -0
- package/dist/cli.js +262 -0
- package/dist/client.js +112 -0
- package/dist/commands/auth.js +141 -0
- package/dist/commands/dev.js +228 -0
- package/dist/commands/section.js +155 -0
- package/dist/commands/theme.js +297 -0
- package/dist/context.js +5 -0
- package/dist/credentials.js +153 -0
- package/dist/diff.js +126 -0
- package/dist/errors.js +54 -0
- package/dist/format.js +13 -0
- package/dist/layout.js +78 -0
- package/dist/redact.js +27 -0
- package/dist/session.js +93 -0
- package/dist/state.js +121 -0
- package/dist/sync.js +82 -0
- package/package.json +39 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { AuthError, RateLimitError, ServerError, ToolRefusal } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The whole wire protocol: one JSON-RPC `tools/call` per POST, bearer token in the header,
|
|
4
|
+
* `structuredContent` back. See docs/theme-cli/contract.md.
|
|
5
|
+
*/
|
|
6
|
+
export const PROTOCOL_VERSION = "2025-06-18";
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
8
|
+
const DEFAULT_RETRY_SECONDS = 60;
|
|
9
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
10
|
+
/** Tools that report per-file outcomes: `isError` there means "some file failed", not "refused". */
|
|
11
|
+
const BATCH_TOOLS = new Set(["put_theme_files", "validate_theme_files"]);
|
|
12
|
+
export class McpClient {
|
|
13
|
+
opts;
|
|
14
|
+
nextId = 1;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.opts = opts;
|
|
17
|
+
}
|
|
18
|
+
get url() {
|
|
19
|
+
return this.opts.url;
|
|
20
|
+
}
|
|
21
|
+
async call(tool, args) {
|
|
22
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: this.nextId++, method: "tools/call", params: { name: tool, arguments: args } });
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await this.opts.fetch(this.opts.url.href, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: {
|
|
28
|
+
authorization: `Bearer ${this.opts.token}`,
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
accept: "application/json",
|
|
31
|
+
"mcp-protocol-version": PROTOCOL_VERSION,
|
|
32
|
+
...(this.opts.userAgent ? { "user-agent": this.opts.userAgent } : {}),
|
|
33
|
+
},
|
|
34
|
+
body,
|
|
35
|
+
// A redirect could carry the request (and our Authorization header) somewhere other than
|
|
36
|
+
// the URL the person configured. The endpoint never redirects, so one is a misconfiguration.
|
|
37
|
+
redirect: "error",
|
|
38
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
const cause = e instanceof Error ? (e.cause instanceof Error ? e.cause.message : e.message) : String(e);
|
|
43
|
+
throw new ServerError(`Could not reach ${this.opts.url.origin}: ${cause}`, null);
|
|
44
|
+
}
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = text ? JSON.parse(text) : null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A proxy's HTML error page: fall through to the status-based messages below.
|
|
52
|
+
parsed = null;
|
|
53
|
+
}
|
|
54
|
+
const rpcError = isRecord(parsed) && isRecord(parsed["error"]) ? parsed["error"] : null;
|
|
55
|
+
const serverMessage = typeof rpcError?.message === "string" ? rpcError.message : null;
|
|
56
|
+
if (res.status === 401) {
|
|
57
|
+
throw new AuthError(serverMessage ?? "The server rejected this token (401). Run `raizn login` with a valid token.");
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 429) {
|
|
60
|
+
throw new RateLimitError(serverMessage ?? "Rate limit exceeded for this token.", retryAfterFrom(res, rpcError));
|
|
61
|
+
}
|
|
62
|
+
if (res.status === 413) {
|
|
63
|
+
throw new ServerError(serverMessage ?? "The request was too large for the server (413).", 413);
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
throw new ServerError(serverMessage ? `Server error ${res.status}: ${serverMessage}` : `Server error ${res.status} from ${this.opts.url.origin}.`, res.status);
|
|
67
|
+
}
|
|
68
|
+
if (rpcError) {
|
|
69
|
+
throw new ServerError(`Server error ${rpcError.code ?? ""}: ${serverMessage ?? "unknown error"}`.replace(/ {2,}/g, " "), res.status);
|
|
70
|
+
}
|
|
71
|
+
if (!isRecord(parsed) || !isRecord(parsed["result"])) {
|
|
72
|
+
throw new ServerError(`Unexpected response from ${this.opts.url.origin}: not a JSON-RPC result.`, res.status);
|
|
73
|
+
}
|
|
74
|
+
const result = parsed["result"];
|
|
75
|
+
const structured = result.structuredContent ?? fromText(result);
|
|
76
|
+
if (isRecord(structured) && typeof structured["error"] === "string") {
|
|
77
|
+
throw new ToolRefusal(tool, structured["error"], typeof structured["message"] === "string" ? structured["message"] : "", structured["details"]);
|
|
78
|
+
}
|
|
79
|
+
if (result.isError && !BATCH_TOOLS.has(tool)) {
|
|
80
|
+
const msg = result.content?.find((c) => typeof c.text === "string")?.text ?? "the tool reported an error";
|
|
81
|
+
throw new ToolRefusal(tool, "error", msg, undefined);
|
|
82
|
+
}
|
|
83
|
+
return structured;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Older servers put the JSON only in `content[0].text`; read it from there when that is all we have. */
|
|
87
|
+
function fromText(result) {
|
|
88
|
+
const text = result.content?.find((c) => c.type === "text" && typeof c.text === "string")?.text;
|
|
89
|
+
if (!text)
|
|
90
|
+
return undefined;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(text);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function retryAfterFrom(res, err) {
|
|
99
|
+
const header = res.headers.get("retry-after");
|
|
100
|
+
if (header) {
|
|
101
|
+
const n = Number(header);
|
|
102
|
+
if (Number.isFinite(n) && n >= 0)
|
|
103
|
+
return Math.ceil(n);
|
|
104
|
+
const at = Date.parse(header);
|
|
105
|
+
if (Number.isFinite(at))
|
|
106
|
+
return Math.max(1, Math.ceil((at - Date.now()) / 1000));
|
|
107
|
+
}
|
|
108
|
+
const fromBody = err?.data?.retryAfterSeconds;
|
|
109
|
+
if (typeof fromBody === "number" && Number.isFinite(fromBody) && fromBody >= 0)
|
|
110
|
+
return Math.ceil(fromBody);
|
|
111
|
+
return DEFAULT_RETRY_SECONDS;
|
|
112
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { apiOrigin } from "../api-url.js";
|
|
3
|
+
import { listStores } from "../api.js";
|
|
4
|
+
import { AuthError, CliError } from "../errors.js";
|
|
5
|
+
import { table } from "../format.js";
|
|
6
|
+
function describe(s, who, origin) {
|
|
7
|
+
const you = who.you;
|
|
8
|
+
const name = [you?.name, you?.email ? `<${you.email}>` : null].filter(Boolean).join(" ");
|
|
9
|
+
s.out(`Logged in to ${origin}${name ? ` as ${name}` : ""}`);
|
|
10
|
+
if (who.token?.prefix || who.token?.name) {
|
|
11
|
+
s.out(`Token: ${who.token.prefix ?? ""}…${who.token.name ? ` (${who.token.name})` : ""}`);
|
|
12
|
+
}
|
|
13
|
+
s.out(`Permissions: ${who.grantedPermissions.length ? who.grantedPermissions.join(", ") : "(none)"}`);
|
|
14
|
+
if (who.stores.length === 0) {
|
|
15
|
+
s.out("Stores: none — this token cannot reach any store yet.");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
s.out("Stores:");
|
|
19
|
+
for (const line of table(who.stores.map((st) => [` ${st.slug}`, st.name])))
|
|
20
|
+
s.out(line);
|
|
21
|
+
}
|
|
22
|
+
/** How long the browser half may take: signing in with a WhatsApp code and an authenticator included. */
|
|
23
|
+
export const BROWSER_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
24
|
+
const b64url = (b) => b.toString("base64url");
|
|
25
|
+
/**
|
|
26
|
+
* Shopify-CLI-style login: open the admin in the browser, the person signs in as they always do
|
|
27
|
+
* and presses Allow, and the admin hands a token back to a listener on 127.0.0.1. PKCE means the
|
|
28
|
+
* one-time code in that redirect is useless to anyone but this process (see the admin's
|
|
29
|
+
* lib/cli-login.ts for the server half).
|
|
30
|
+
*/
|
|
31
|
+
async function browserLogin(s, origin) {
|
|
32
|
+
const verifier = b64url(randomBytes(32));
|
|
33
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
34
|
+
const state = b64url(randomBytes(24));
|
|
35
|
+
const lb = await s.ctx.listenLoopback();
|
|
36
|
+
let q;
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL("/cli/authorize", origin);
|
|
39
|
+
url.search = new URLSearchParams({ port: String(lb.port), state, challenge, device: s.ctx.hostname.slice(0, 60) }).toString();
|
|
40
|
+
s.err("Opening your browser to sign in to Raizn. Press Allow there, then come back here.");
|
|
41
|
+
s.err(`If it does not open, visit: ${url.href}`);
|
|
42
|
+
await s.ctx.openUrl(url.href);
|
|
43
|
+
try {
|
|
44
|
+
q = await lb.waitForCallback(BROWSER_LOGIN_TIMEOUT_MS);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new CliError("Timed out waiting for the browser (5 minutes). Run `raizn login` again.");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
lb.close();
|
|
52
|
+
}
|
|
53
|
+
if (q.get("state") !== state)
|
|
54
|
+
throw new CliError("The browser answered a different login request. Run `raizn login` again.");
|
|
55
|
+
if (q.get("error"))
|
|
56
|
+
throw new CliError(q.get("error") === "access_denied" ? "Sign-in was cancelled in the browser." : `Sign-in failed in the browser (${q.get("error")}).`);
|
|
57
|
+
const code = q.get("code");
|
|
58
|
+
if (!code)
|
|
59
|
+
throw new CliError("The browser returned no sign-in code. Run `raizn login` again.");
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await s.ctx.fetch(new URL("/api/cli/token", origin).href, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
65
|
+
body: JSON.stringify({ code, verifier }),
|
|
66
|
+
redirect: "error",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
throw new CliError(`Could not reach ${origin}: ${e instanceof Error ? e.message : String(e)}`);
|
|
71
|
+
}
|
|
72
|
+
const body = (await res.json().catch(() => ({})));
|
|
73
|
+
if (!res.ok || typeof body.token !== "string") {
|
|
74
|
+
throw new CliError(typeof body.message === "string" ? body.message : `Sign-in failed (HTTP ${res.status}). Run \`raizn login\` again.`);
|
|
75
|
+
}
|
|
76
|
+
return body.token;
|
|
77
|
+
}
|
|
78
|
+
export async function login(s, opts = {}) {
|
|
79
|
+
const url = s.api();
|
|
80
|
+
const origin = apiOrigin(url);
|
|
81
|
+
const env = s.ctx.env["RAIZN_TOKEN"]?.trim();
|
|
82
|
+
let token;
|
|
83
|
+
if (env) {
|
|
84
|
+
// CI: verify what the pipeline will use, but never copy an injected secret into a keychain.
|
|
85
|
+
s.err("RAIZN_TOKEN is set; verifying it. It is used instead of any stored token, so nothing is saved.");
|
|
86
|
+
token = env;
|
|
87
|
+
}
|
|
88
|
+
else if (!s.ctx.io.isTTY) {
|
|
89
|
+
token = (await s.ctx.io.readStdin()).trim();
|
|
90
|
+
}
|
|
91
|
+
else if (opts.token) {
|
|
92
|
+
s.err(`Create a token in the Raizn admin at Settings → AI assistant connection (MCP) (${origin}).`);
|
|
93
|
+
token = (await s.ctx.io.promptSecret("Paste your token (input hidden): ")).trim();
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
token = await browserLogin(s, origin);
|
|
97
|
+
}
|
|
98
|
+
if (!token)
|
|
99
|
+
throw new CliError("No token entered.");
|
|
100
|
+
s.addSecret(token);
|
|
101
|
+
let who;
|
|
102
|
+
try {
|
|
103
|
+
who = await listStores(s.clientWith(url, token));
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
if (e instanceof AuthError)
|
|
107
|
+
throw new AuthError(`Token not saved: ${e.message}`);
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
if (!env) {
|
|
111
|
+
const where = await s.ctx.credentials.set(origin, token);
|
|
112
|
+
s.err(where === "keychain" ? "Token saved to the system keychain." : "Token saved to the credentials file (no system keychain available).");
|
|
113
|
+
}
|
|
114
|
+
describe(s, who, origin);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
export async function logout(s) {
|
|
118
|
+
const origin = apiOrigin(s.api((await s.checkout())?.state));
|
|
119
|
+
const removed = await s.ctx.credentials.delete(origin);
|
|
120
|
+
s.out(removed ? `Logged out of ${origin}.` : `No stored token for ${origin}.`);
|
|
121
|
+
if (s.ctx.env["RAIZN_TOKEN"])
|
|
122
|
+
s.err("Note: RAIZN_TOKEN is still set in this environment, and commands will keep using it.");
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
export async function whoami(s) {
|
|
126
|
+
const state = (await s.checkout())?.state;
|
|
127
|
+
const c = await s.client(state);
|
|
128
|
+
describe(s, await listStores(c), apiOrigin(c.url));
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
export async function stores(s) {
|
|
132
|
+
const state = (await s.checkout())?.state;
|
|
133
|
+
const r = await listStores(await s.client(state));
|
|
134
|
+
if (r.stores.length === 0) {
|
|
135
|
+
s.err("This token cannot reach any store.");
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
for (const line of table([["SLUG", "NAME"], ...r.stores.map((st) => [st.slug, st.name])]))
|
|
139
|
+
s.out(line);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getPreviewUrl } from "../api.js";
|
|
3
|
+
import { AuthError, CliError, RateLimitError, ToolRefusal, retryAt } from "../errors.js";
|
|
4
|
+
import { plural } from "../format.js";
|
|
5
|
+
import { isLayoutPath } from "../layout.js";
|
|
6
|
+
import { localChanges } from "../state.js";
|
|
7
|
+
import { conflictAdvice, pushChanges } from "../sync.js";
|
|
8
|
+
export const DEBOUNCE_MS = 1000;
|
|
9
|
+
/** Signed preview links live 30 minutes; fetch the next one with five to spare. */
|
|
10
|
+
export const PREVIEW_REFRESH_MS = 25 * 60 * 1000;
|
|
11
|
+
const PREVIEW_MARGIN_MS = 5 * 60 * 1000;
|
|
12
|
+
const PREVIEW_RETRY_MS = 5 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Editors save through temp files and swap files; none of them are theme files, and pushing
|
|
15
|
+
* them would only earn a `refused`. Anything outside the layout is ignored for the same reason.
|
|
16
|
+
*/
|
|
17
|
+
export function watchedPath(filename) {
|
|
18
|
+
const p = filename.split(path.sep).join("/").replace(/^\.\//, "");
|
|
19
|
+
const parts = p.split("/");
|
|
20
|
+
if (parts.some((seg) => seg.startsWith(".")))
|
|
21
|
+
return null; // .raizn/, .git/, .#emacs-lock, .DS_Store
|
|
22
|
+
const base = parts[parts.length - 1] ?? "";
|
|
23
|
+
if (base.endsWith("~") || /\.sw[a-p]$/.test(base) || base === "4913" || base.startsWith("#"))
|
|
24
|
+
return null;
|
|
25
|
+
return isLayoutPath(p) ? p : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The watch → debounce → push loop. One `put_theme_files` call per quiet second, never two in
|
|
29
|
+
* flight, and after a 429 nothing is sent until the server's retry time has passed — saves made
|
|
30
|
+
* meanwhile simply join the queue.
|
|
31
|
+
*/
|
|
32
|
+
export class DevLoop {
|
|
33
|
+
s;
|
|
34
|
+
client;
|
|
35
|
+
root;
|
|
36
|
+
state;
|
|
37
|
+
debounceMs;
|
|
38
|
+
pending = new Set();
|
|
39
|
+
rescanAll = false;
|
|
40
|
+
timer = null;
|
|
41
|
+
previewTimer = null;
|
|
42
|
+
inFlight = null;
|
|
43
|
+
blockedUntil = 0;
|
|
44
|
+
stopped = false;
|
|
45
|
+
finish = null;
|
|
46
|
+
done;
|
|
47
|
+
constructor(s, client, root, state, debounceMs = DEBOUNCE_MS) {
|
|
48
|
+
this.s = s;
|
|
49
|
+
this.client = client;
|
|
50
|
+
this.root = root;
|
|
51
|
+
this.state = state;
|
|
52
|
+
this.debounceMs = debounceMs;
|
|
53
|
+
this.done = new Promise((resolve) => (this.finish = resolve));
|
|
54
|
+
}
|
|
55
|
+
get timers() {
|
|
56
|
+
return this.s.ctx.timers;
|
|
57
|
+
}
|
|
58
|
+
stamp() {
|
|
59
|
+
return `[${this.s.ctx.now().toLocaleTimeString("en-GB", { hour12: false })}]`;
|
|
60
|
+
}
|
|
61
|
+
/** A filesystem event. `null` = the OS did not say which file, so rescan everything. */
|
|
62
|
+
notify(filename) {
|
|
63
|
+
if (this.stopped)
|
|
64
|
+
return;
|
|
65
|
+
if (filename === null)
|
|
66
|
+
this.rescanAll = true;
|
|
67
|
+
else {
|
|
68
|
+
const p = watchedPath(filename);
|
|
69
|
+
if (!p)
|
|
70
|
+
return;
|
|
71
|
+
this.pending.add(p);
|
|
72
|
+
}
|
|
73
|
+
this.schedule(this.debounceMs);
|
|
74
|
+
}
|
|
75
|
+
schedule(ms) {
|
|
76
|
+
if (this.stopped)
|
|
77
|
+
return;
|
|
78
|
+
if (this.timer !== null)
|
|
79
|
+
this.timers.clearTimeout(this.timer);
|
|
80
|
+
const wait = Math.max(ms, this.blockedUntil - this.s.ctx.now().getTime());
|
|
81
|
+
this.timer = this.timers.setTimeout(() => {
|
|
82
|
+
this.timer = null;
|
|
83
|
+
void this.flush();
|
|
84
|
+
}, wait);
|
|
85
|
+
}
|
|
86
|
+
async flush() {
|
|
87
|
+
if (this.stopped)
|
|
88
|
+
return;
|
|
89
|
+
if (this.inFlight) {
|
|
90
|
+
// A push is running; the files stay queued and go in the next batch once it lands.
|
|
91
|
+
await this.inFlight;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const waitMs = this.blockedUntil - this.s.ctx.now().getTime();
|
|
95
|
+
if (waitMs > 0) {
|
|
96
|
+
this.schedule(waitMs);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const paths = [...this.pending];
|
|
100
|
+
const all = this.rescanAll;
|
|
101
|
+
this.pending.clear();
|
|
102
|
+
this.rescanAll = false;
|
|
103
|
+
this.inFlight = this.pushBatch(paths, all);
|
|
104
|
+
try {
|
|
105
|
+
await this.inFlight;
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
this.inFlight = null;
|
|
109
|
+
}
|
|
110
|
+
if (!this.stopped && (this.pending.size > 0 || this.rescanAll) && this.timer === null)
|
|
111
|
+
this.schedule(this.debounceMs);
|
|
112
|
+
}
|
|
113
|
+
requeue(paths, all) {
|
|
114
|
+
for (const p of paths)
|
|
115
|
+
this.pending.add(p);
|
|
116
|
+
if (all)
|
|
117
|
+
this.rescanAll = true;
|
|
118
|
+
}
|
|
119
|
+
async pushBatch(paths, all) {
|
|
120
|
+
const { changes } = await localChanges(this.root, this.state, all ? undefined : paths);
|
|
121
|
+
if (changes.length === 0)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
const r = await pushChanges(this.s, this.client, this.root, this.state, changes, { force: false, compact: true });
|
|
125
|
+
const failed = r.conflicts.length + r.invalid.length + r.refused.length;
|
|
126
|
+
this.s.err(`${this.stamp()} ${r.saved} saved${failed ? `, ${failed} not saved` : ""}${r.unchanged ? `, ${r.unchanged} unchanged` : ""}`);
|
|
127
|
+
conflictAdvice(this.s, r.conflicts);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
if (e instanceof RateLimitError) {
|
|
131
|
+
this.requeue(paths, all);
|
|
132
|
+
this.blockedUntil = this.s.ctx.now().getTime() + e.retryAfterSeconds * 1000;
|
|
133
|
+
this.s.err(`${this.stamp()} Rate limited: ${e.message} Waiting ${e.retryAfterSeconds}s (until ${retryAt(e.retryAfterSeconds, this.s.ctx.now())}); ${plural(this.pending.size || changes.length, "queued change")} will be sent then.`);
|
|
134
|
+
this.schedule(e.retryAfterSeconds * 1000);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (e instanceof AuthError || e instanceof ToolRefusal) {
|
|
138
|
+
// A revoked token or a theme that is no longer a draft will not fix itself; stop.
|
|
139
|
+
this.s.err(e instanceof ToolRefusal ? `The server refused the push: ${e.code}: ${e.serverMessage}` : e.message);
|
|
140
|
+
this.stop(1);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.requeue(paths, all);
|
|
144
|
+
this.s.err(`${this.stamp()} ${e instanceof Error ? e.message : String(e)} — your changes stay queued and go with your next save.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Print the preview link now and a fresh one before each expires. */
|
|
148
|
+
async startPreview() {
|
|
149
|
+
await this.fetchPreview(true);
|
|
150
|
+
}
|
|
151
|
+
async fetchPreview(first) {
|
|
152
|
+
if (this.stopped)
|
|
153
|
+
return;
|
|
154
|
+
let next = PREVIEW_RETRY_MS;
|
|
155
|
+
try {
|
|
156
|
+
const r = await getPreviewUrl(this.client, this.state.store, this.state.themeId);
|
|
157
|
+
this.s.out(first ? `Preview: ${r.url}` : `${this.stamp()} New preview link (the previous one is about to expire): ${r.url}`);
|
|
158
|
+
const expires = r.expiresAt ? Date.parse(r.expiresAt) : NaN;
|
|
159
|
+
next = Number.isFinite(expires)
|
|
160
|
+
? Math.max(60_000, Math.min(PREVIEW_REFRESH_MS, expires - this.s.ctx.now().getTime() - PREVIEW_MARGIN_MS))
|
|
161
|
+
: PREVIEW_REFRESH_MS;
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
if (e instanceof AuthError) {
|
|
165
|
+
this.s.err(e.message);
|
|
166
|
+
this.stop(1);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (e instanceof RateLimitError)
|
|
170
|
+
next = Math.max(e.retryAfterSeconds * 1000, 1000);
|
|
171
|
+
this.s.err(`${this.stamp()} Could not get a preview link: ${e instanceof Error ? e.message : String(e)}`);
|
|
172
|
+
}
|
|
173
|
+
if (this.stopped)
|
|
174
|
+
return;
|
|
175
|
+
this.previewTimer = this.timers.setTimeout(() => void this.fetchPreview(false), next);
|
|
176
|
+
}
|
|
177
|
+
/** Resolves once no push is in flight — lets tests (and a clean exit) wait for the network. */
|
|
178
|
+
async idle() {
|
|
179
|
+
while (this.inFlight)
|
|
180
|
+
await this.inFlight;
|
|
181
|
+
}
|
|
182
|
+
stop(code = 0) {
|
|
183
|
+
if (this.stopped)
|
|
184
|
+
return;
|
|
185
|
+
this.stopped = true;
|
|
186
|
+
if (this.timer !== null)
|
|
187
|
+
this.timers.clearTimeout(this.timer);
|
|
188
|
+
if (this.previewTimer !== null)
|
|
189
|
+
this.timers.clearTimeout(this.previewTimer);
|
|
190
|
+
this.timer = null;
|
|
191
|
+
this.previewTimer = null;
|
|
192
|
+
this.finish?.(code);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function themeDev(s) {
|
|
196
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
197
|
+
const client = await s.client(state);
|
|
198
|
+
const { changes } = await localChanges(root, state);
|
|
199
|
+
if (changes.length > 0) {
|
|
200
|
+
s.err(`Pushing ${plural(changes.length, "pending change")} first…`);
|
|
201
|
+
const r = await pushChanges(s, client, root, state, changes, { force: false, compact: true });
|
|
202
|
+
if (r.conflicts.length > 0) {
|
|
203
|
+
conflictAdvice(s, r.conflicts);
|
|
204
|
+
throw new CliError("Resolve the conflicts above, then run `raizn theme dev` again.");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const loop = new DevLoop(s, client, root, state);
|
|
208
|
+
await loop.startPreview();
|
|
209
|
+
let watcher;
|
|
210
|
+
try {
|
|
211
|
+
watcher = s.ctx.watch(root, (f) => loop.notify(f));
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
loop.stop(1);
|
|
215
|
+
throw new CliError(`Could not watch ${root}: ${e instanceof Error ? e.message : String(e)}`);
|
|
216
|
+
}
|
|
217
|
+
const offInterrupt = s.ctx.onInterrupt(() => {
|
|
218
|
+
s.err("\nStopped watching. Your pushed changes are on the draft; nothing went live.");
|
|
219
|
+
loop.stop(0);
|
|
220
|
+
});
|
|
221
|
+
s.err(`Watching ${root} for changes to "${state.themeName}" on ${state.store}. Ctrl-C to stop.`);
|
|
222
|
+
const code = await loop.done;
|
|
223
|
+
watcher.close();
|
|
224
|
+
// Let a push that was mid-flight at Ctrl-C finish and record its tags before we exit.
|
|
225
|
+
await loop.idle();
|
|
226
|
+
offInterrupt();
|
|
227
|
+
return code;
|
|
228
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { deleteCustomSection, getThemeFiles, listCustomSectionVersions, listSectionStarters, restoreCustomSectionVersion, } from "../api.js";
|
|
4
|
+
import { CliError, ToolRefusal } from "../errors.js";
|
|
5
|
+
import { table } from "../format.js";
|
|
6
|
+
import { SECTION_TYPE_RE, bytesOf, hashBytes, sectionPaths, sectionTypeOf, toDiskPath } from "../layout.js";
|
|
7
|
+
import { readLocal, writeState } from "../state.js";
|
|
8
|
+
function checkType(type) {
|
|
9
|
+
if (!type)
|
|
10
|
+
throw new CliError("Which section? Pass its type, e.g. `raizn section new promo-strip`.");
|
|
11
|
+
if (!SECTION_TYPE_RE.test(type)) {
|
|
12
|
+
throw new CliError(`"${type}" is not a valid section type: use 2–80 lowercase letters, digits, "-" or "_", starting with a letter or digit.`);
|
|
13
|
+
}
|
|
14
|
+
return type;
|
|
15
|
+
}
|
|
16
|
+
const schemaText = (schema) => (typeof schema === "string" ? schema : JSON.stringify(schema ?? {}, null, 2) + "\n");
|
|
17
|
+
// --- new ---------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Local only. The section reaches the server with the next push or dev, like any other edit —
|
|
20
|
+
* so it gets validated, conflict-checked and audited on the same path as everything else.
|
|
21
|
+
*/
|
|
22
|
+
export async function sectionNew(s, rawType, opts) {
|
|
23
|
+
const type = checkType(rawType);
|
|
24
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
25
|
+
const paths = sectionPaths(type);
|
|
26
|
+
for (const p of Object.values(paths)) {
|
|
27
|
+
if (state.files[p] || (await readLocal(root, p)))
|
|
28
|
+
throw new CliError(`Section "${type}" already exists (${p}). Pick another type, or edit its files.`);
|
|
29
|
+
}
|
|
30
|
+
const { starters } = await listSectionStarters(await s.client(state));
|
|
31
|
+
if (!starters || starters.length === 0)
|
|
32
|
+
throw new CliError("The server offered no section starters.");
|
|
33
|
+
let starter;
|
|
34
|
+
if (opts.starter) {
|
|
35
|
+
starter = starters.find((x) => x.key === opts.starter);
|
|
36
|
+
if (!starter)
|
|
37
|
+
throw new CliError(`No starter "${opts.starter}". Available: ${starters.map((x) => x.key).join(", ")}.`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const fallback = starters.find((x) => x.key === "blank") ?? starters[0];
|
|
41
|
+
if (s.ctx.io.isTTY) {
|
|
42
|
+
s.err("Starters:");
|
|
43
|
+
for (const line of table(starters.map((x, i) => [` ${i + 1}.`, x.key, x.name, x.group ?? ""])))
|
|
44
|
+
s.err(line);
|
|
45
|
+
const answer = (await s.ctx.io.prompt(`Start from which? [${fallback.key}] `)).trim();
|
|
46
|
+
if (!answer)
|
|
47
|
+
starter = fallback;
|
|
48
|
+
else {
|
|
49
|
+
const n = Number(answer);
|
|
50
|
+
starter = Number.isInteger(n) && n >= 1 ? starters[n - 1] : starters.find((x) => x.key === answer);
|
|
51
|
+
if (!starter)
|
|
52
|
+
throw new CliError(`No starter "${answer}".`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
starter = fallback;
|
|
57
|
+
s.err(`Using the "${starter.key}" starter (pass --starter <key> to choose).`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const writes = [
|
|
61
|
+
[paths.liquid, starter.template ?? ""],
|
|
62
|
+
[paths.schema, schemaText(starter.schema)],
|
|
63
|
+
];
|
|
64
|
+
if (typeof starter.css === "string" && starter.css.length > 0)
|
|
65
|
+
writes.push([paths.css, starter.css]);
|
|
66
|
+
for (const [p, content] of writes) {
|
|
67
|
+
const disk = toDiskPath(root, p);
|
|
68
|
+
await fs.mkdir(path.dirname(disk), { recursive: true });
|
|
69
|
+
await fs.writeFile(disk, bytesOf(content), { flag: "wx" });
|
|
70
|
+
s.out(` created ${p}`);
|
|
71
|
+
}
|
|
72
|
+
s.out(`Section "${type}" created locally from "${starter.key}". Set its "name" in ${paths.schema}, then \`raizn theme push\` (or keep \`raizn theme dev\` running) to create it on the store.`);
|
|
73
|
+
s.out("Custom sections belong to the store: they go live with the first approval that uses them, once it is published.");
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
// --- delete ------------------------------------------------------------------
|
|
77
|
+
export async function sectionDelete(s, rawType) {
|
|
78
|
+
const type = checkType(rawType);
|
|
79
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
80
|
+
try {
|
|
81
|
+
await deleteCustomSection(await s.client(state), state.store, type);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (e instanceof ToolRefusal && e.code === "section_in_use") {
|
|
85
|
+
const usedIn = e.details?.usedIn;
|
|
86
|
+
const where = Array.isArray(usedIn) ? usedIn.map(String) : [];
|
|
87
|
+
throw new CliError([`The server refused to delete "${type}": ${e.code}: ${e.serverMessage}`, ...where.map((w) => ` used in ${w}`), "Remove it from those templates (and push) first."].join("\n"));
|
|
88
|
+
}
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
for (const p of Object.values(sectionPaths(type))) {
|
|
92
|
+
await fs.rm(toDiskPath(root, p), { force: true });
|
|
93
|
+
delete state.files[p];
|
|
94
|
+
}
|
|
95
|
+
await writeState(root, state);
|
|
96
|
+
s.out(`Deleted custom section "${type}" from ${state.store} and removed its local files.`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
// --- history / restore --------------------------------------------------------
|
|
100
|
+
export async function sectionHistory(s, rawType, opts) {
|
|
101
|
+
const type = checkType(rawType);
|
|
102
|
+
const { state } = await s.requireCheckout({ editable: false });
|
|
103
|
+
const r = await listCustomSectionVersions(await s.client(state), state.store, type, opts.limit);
|
|
104
|
+
s.out(`${r.type}: draft v${r.version}, ${r.publishedVersion ? `live v${r.publishedVersion}` : "never published"}`);
|
|
105
|
+
const rows = r.versions.map((v) => [
|
|
106
|
+
`v${v.version}`,
|
|
107
|
+
v.createdAt,
|
|
108
|
+
v.author ?? "—",
|
|
109
|
+
[v.version === r.version ? "draft" : "", v.version === r.publishedVersion ? "live" : ""].filter(Boolean).join(", "),
|
|
110
|
+
]);
|
|
111
|
+
for (const line of table([["VERSION", "CREATED", "AUTHOR", ""], ...rows]))
|
|
112
|
+
s.out(line);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
export async function sectionRestore(s, rawType, rawVersion, opts) {
|
|
116
|
+
const type = checkType(rawType);
|
|
117
|
+
const version = Number(rawVersion?.replace(/^v/i, ""));
|
|
118
|
+
if (!rawVersion || !Number.isInteger(version) || version < 1)
|
|
119
|
+
throw new CliError("Which version? e.g. `raizn section restore promo-strip 3` (see `raizn section history`).");
|
|
120
|
+
const { root, state } = await s.requireCheckout({ editable: true });
|
|
121
|
+
const paths = Object.values(sectionPaths(type));
|
|
122
|
+
// Restoring rewrites the local files; do not silently discard edits made to them.
|
|
123
|
+
if (!opts.force) {
|
|
124
|
+
for (const p of paths) {
|
|
125
|
+
const bytes = await readLocal(root, p);
|
|
126
|
+
const known = state.files[p];
|
|
127
|
+
if (bytes && (!known || known.hash !== hashBytes(bytes))) {
|
|
128
|
+
throw new CliError(`${p} has local changes that restoring would overwrite. Push or discard them, or re-run with --force.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const client = await s.client(state);
|
|
133
|
+
const r = await restoreCustomSectionVersion(client, state.store, type, version);
|
|
134
|
+
s.out(`Restored "${type}" v${r.restoredFrom} as new draft version v${r.version} (history is kept).`);
|
|
135
|
+
const all = await getThemeFiles(client, state.store, state.themeId);
|
|
136
|
+
const fresh = new Map(all.files.filter((f) => sectionTypeOf(f.path) === type).map((f) => [f.path, f]));
|
|
137
|
+
for (const p of paths) {
|
|
138
|
+
const f = fresh.get(p);
|
|
139
|
+
const disk = toDiskPath(root, p);
|
|
140
|
+
if (f) {
|
|
141
|
+
await fs.mkdir(path.dirname(disk), { recursive: true });
|
|
142
|
+
await fs.writeFile(disk, bytesOf(f.content));
|
|
143
|
+
state.files[p] = { tag: f.tag, hash: hashBytes(bytesOf(f.content)) };
|
|
144
|
+
s.out(` updated ${p}`);
|
|
145
|
+
}
|
|
146
|
+
else if (state.files[p] || (await readLocal(root, p))) {
|
|
147
|
+
// e.g. the restored version has no CSS.
|
|
148
|
+
await fs.rm(disk, { force: true });
|
|
149
|
+
delete state.files[p];
|
|
150
|
+
s.out(` removed ${p}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
await writeState(root, state);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|