@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/layout.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
export const SECTION_TYPE_RE = /^[a-z0-9][a-z0-9_-]{1,79}$/;
|
|
8
|
+
const TEMPLATE_RE = /^templates\/[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)?\.json$/;
|
|
9
|
+
const LAYOUT_RE = /^layout\/[a-z0-9][a-z0-9_-]*\.json$/;
|
|
10
|
+
const SECTION_RE = /^sections\/([a-z0-9][a-z0-9_-]{1,79})\.(liquid|schema\.json|css)$/;
|
|
11
|
+
const FIXED = new Set(["config/settings.json", "assets/custom.css"]);
|
|
12
|
+
/** Top-level folders the layout uses; the walker never descends anywhere else. */
|
|
13
|
+
export const LAYOUT_DIRS = ["templates", "layout", "config", "assets", "sections"];
|
|
14
|
+
export function isLayoutPath(p) {
|
|
15
|
+
return FIXED.has(p) || TEMPLATE_RE.test(p) || LAYOUT_RE.test(p) || SECTION_RE.test(p);
|
|
16
|
+
}
|
|
17
|
+
/** `sections/promo.schema.json` → `promo`; null for anything that is not a section file. */
|
|
18
|
+
export function sectionTypeOf(p) {
|
|
19
|
+
return SECTION_RE.exec(p)?.[1] ?? null;
|
|
20
|
+
}
|
|
21
|
+
export const sectionPaths = (type) => ({
|
|
22
|
+
liquid: `sections/${type}.liquid`,
|
|
23
|
+
schema: `sections/${type}.schema.json`,
|
|
24
|
+
css: `sections/${type}.css`,
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* Server-side write order is sections → settings/CSS → templates; chunking follows it so a
|
|
28
|
+
* template never arrives in an earlier request than the new section it places.
|
|
29
|
+
*/
|
|
30
|
+
export function writeRank(p) {
|
|
31
|
+
if (p.startsWith("sections/"))
|
|
32
|
+
return 0;
|
|
33
|
+
if (p.startsWith("config/") || p.startsWith("assets/"))
|
|
34
|
+
return 1;
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Layout path → absolute OS path under `root`. Paths come from the server too, so this is also
|
|
39
|
+
* the guard against `../` or an absolute path escaping the checkout: only exact layout shapes pass.
|
|
40
|
+
*/
|
|
41
|
+
export function toDiskPath(root, layoutPath) {
|
|
42
|
+
if (!isLayoutPath(layoutPath))
|
|
43
|
+
throw new Error(`Not a theme file path: ${layoutPath}`);
|
|
44
|
+
return path.join(root, ...layoutPath.split("/"));
|
|
45
|
+
}
|
|
46
|
+
/** Absolute or cwd-relative OS path → layout path, or null when it is outside the checkout. */
|
|
47
|
+
export function toLayoutPath(root, diskPath) {
|
|
48
|
+
const rel = path.relative(root, path.resolve(diskPath));
|
|
49
|
+
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
|
|
50
|
+
return null;
|
|
51
|
+
return rel.split(path.sep).join(path.posix.sep);
|
|
52
|
+
}
|
|
53
|
+
// --- content ---------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* A git checkout with `core.autocrlf` rewrites LF to CRLF on disk. Hashing and sending the
|
|
56
|
+
* LF form means such a checkout neither looks modified nor pushes carriage returns.
|
|
57
|
+
*/
|
|
58
|
+
export function normaliseEol(buf) {
|
|
59
|
+
if (!buf.includes(0x0d))
|
|
60
|
+
return buf;
|
|
61
|
+
const out = Buffer.allocUnsafe(buf.length);
|
|
62
|
+
let j = 0;
|
|
63
|
+
for (let i = 0; i < buf.length; i++) {
|
|
64
|
+
const b = buf[i];
|
|
65
|
+
if (b === 0x0d && buf[i + 1] === 0x0a)
|
|
66
|
+
continue;
|
|
67
|
+
out[j++] = b;
|
|
68
|
+
}
|
|
69
|
+
return out.subarray(0, j);
|
|
70
|
+
}
|
|
71
|
+
export const hashBytes = (buf) => createHash("sha256").update(normaliseEol(buf)).digest("hex");
|
|
72
|
+
/**
|
|
73
|
+
* Bytes → the string we send. `Buffer#toString` keeps a BOM as U+FEFF rather than eating it
|
|
74
|
+
* (TextDecoder would drop it), so what the person saved is exactly what the server receives.
|
|
75
|
+
*/
|
|
76
|
+
export const textOf = (buf) => normaliseEol(buf).toString("utf8");
|
|
77
|
+
/** The server's content → the exact bytes we write. No BOM added, no newline appended. */
|
|
78
|
+
export const bytesOf = (content) => Buffer.from(content, "utf8");
|
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,93 @@
|
|
|
1
|
+
import { DEFAULT_API_URL, apiOrigin, parseApiUrl } from "./api-url.js";
|
|
2
|
+
import { McpClient } from "./client.js";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { makeRedactor } from "./redact.js";
|
|
5
|
+
import { findRoot, readState } from "./state.js";
|
|
6
|
+
/**
|
|
7
|
+
* Per-invocation plumbing shared by every command: which API, which token, redacted output,
|
|
8
|
+
* and the checkout (if any) the command runs in.
|
|
9
|
+
*/
|
|
10
|
+
export class Session {
|
|
11
|
+
ctx;
|
|
12
|
+
apiFlag;
|
|
13
|
+
secrets = [];
|
|
14
|
+
redact;
|
|
15
|
+
clientPromise = null;
|
|
16
|
+
apiUrl = null;
|
|
17
|
+
constructor(ctx, apiFlag) {
|
|
18
|
+
this.ctx = ctx;
|
|
19
|
+
this.apiFlag = apiFlag;
|
|
20
|
+
this.redact = makeRedactor(() => this.secrets);
|
|
21
|
+
const envToken = ctx.env["RAIZN_TOKEN"];
|
|
22
|
+
if (envToken)
|
|
23
|
+
this.secrets.push(envToken.trim());
|
|
24
|
+
}
|
|
25
|
+
/** Every printed line goes through the redactor — not only errors — so no path can leak it. */
|
|
26
|
+
out(line = "") {
|
|
27
|
+
this.ctx.io.out(this.redact(line));
|
|
28
|
+
}
|
|
29
|
+
err(line = "") {
|
|
30
|
+
this.ctx.io.err(this.redact(line));
|
|
31
|
+
}
|
|
32
|
+
addSecret(s) {
|
|
33
|
+
if (s)
|
|
34
|
+
this.secrets.push(s);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `--api`, then the checkout's recorded API, then RAIZN_API_URL, then the default. The
|
|
38
|
+
* checkout outranks the environment because its theme id only means something on the server
|
|
39
|
+
* it was pulled from; an explicit flag that disagrees with it is refused rather than obeyed.
|
|
40
|
+
*/
|
|
41
|
+
api(state) {
|
|
42
|
+
if (this.apiUrl)
|
|
43
|
+
return this.apiUrl;
|
|
44
|
+
const flag = this.apiFlag ? parseApiUrl(this.apiFlag) : null;
|
|
45
|
+
const recorded = state?.api ? parseApiUrl(state.api) : null;
|
|
46
|
+
if (flag && recorded && flag.href !== recorded.href) {
|
|
47
|
+
throw new CliError(`This folder was pulled from ${recorded.href}, not ${flag.href}. Drop --api, or pull into a different folder.`);
|
|
48
|
+
}
|
|
49
|
+
const env = this.ctx.env["RAIZN_API_URL"];
|
|
50
|
+
this.apiUrl = flag ?? recorded ?? parseApiUrl(env && env.trim() ? env : DEFAULT_API_URL);
|
|
51
|
+
return this.apiUrl;
|
|
52
|
+
}
|
|
53
|
+
/** RAIZN_TOKEN (CI) wins over a stored login, so a pipeline never picks up someone's laptop token. */
|
|
54
|
+
async token(url) {
|
|
55
|
+
const env = this.ctx.env["RAIZN_TOKEN"]?.trim();
|
|
56
|
+
if (env)
|
|
57
|
+
return { token: env, source: "env" };
|
|
58
|
+
const stored = await this.ctx.credentials.get(apiOrigin(url));
|
|
59
|
+
if (!stored)
|
|
60
|
+
throw new CliError(`Not logged in to ${apiOrigin(url)}. Run \`raizn login\` (or set RAIZN_TOKEN in CI).`);
|
|
61
|
+
this.addSecret(stored);
|
|
62
|
+
return { token: stored, source: "stored" };
|
|
63
|
+
}
|
|
64
|
+
clientWith(url, token) {
|
|
65
|
+
this.addSecret(token);
|
|
66
|
+
return new McpClient({ url, token, fetch: this.ctx.fetch, userAgent: `raizn-cli/${this.ctx.version}` });
|
|
67
|
+
}
|
|
68
|
+
client(state) {
|
|
69
|
+
this.clientPromise ??= (async () => {
|
|
70
|
+
const url = this.api(state);
|
|
71
|
+
const { token } = await this.token(url);
|
|
72
|
+
return this.clientWith(url, token);
|
|
73
|
+
})();
|
|
74
|
+
return this.clientPromise;
|
|
75
|
+
}
|
|
76
|
+
/** The checkout this command runs in, found by walking up from the working directory. */
|
|
77
|
+
async checkout() {
|
|
78
|
+
const root = await findRoot(this.ctx.cwd);
|
|
79
|
+
if (!root)
|
|
80
|
+
return null;
|
|
81
|
+
const state = await readState(root);
|
|
82
|
+
return state ? { root, state } : null;
|
|
83
|
+
}
|
|
84
|
+
async requireCheckout(opts) {
|
|
85
|
+
const co = await this.checkout();
|
|
86
|
+
if (!co)
|
|
87
|
+
throw new CliError("This folder is not a theme checkout. Run `raizn theme pull --store <slug>` first.");
|
|
88
|
+
if (opts.editable && co.state.readOnly) {
|
|
89
|
+
throw new CliError(`${co.root} is a read-only copy of the LIVE theme. Nothing can be written from here. Run \`raizn theme pull --store ${co.state.store}\` in another folder to edit a draft.`);
|
|
90
|
+
}
|
|
91
|
+
return co;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { LAYOUT_DIRS, hashBytes, isLayoutPath, toDiskPath } from "./layout.js";
|
|
5
|
+
/**
|
|
6
|
+
* `.raizn/state.json` — what this folder was pulled from and the server tag + local hash of every
|
|
7
|
+
* file at that moment. It never holds the token: a checkout gets committed, zipped and shared.
|
|
8
|
+
*/
|
|
9
|
+
export const STATE_DIR = ".raizn";
|
|
10
|
+
export const STATE_FILE = "state.json";
|
|
11
|
+
export const statePath = (root) => path.join(root, STATE_DIR, STATE_FILE);
|
|
12
|
+
export async function readState(root) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = await fs.readFile(statePath(root), "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
if (e.code === "ENOENT")
|
|
19
|
+
return null;
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
let s;
|
|
23
|
+
try {
|
|
24
|
+
s = JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new CliError(`${statePath(root)} is not valid JSON. Pull again with \`raizn theme pull --force\`.`);
|
|
28
|
+
}
|
|
29
|
+
const o = s;
|
|
30
|
+
if (o.version !== 1 || typeof o.store !== "string" || typeof o.themeId !== "string" || typeof o.files !== "object" || o.files === null) {
|
|
31
|
+
throw new CliError(`${statePath(root)} is not a state file this version of raizn understands. Pull again with \`raizn theme pull --force\`.`);
|
|
32
|
+
}
|
|
33
|
+
return { ...o, readOnly: o.readOnly === true };
|
|
34
|
+
}
|
|
35
|
+
/** Written to a temp file and renamed, so a Ctrl-C mid-write never leaves half a state file. */
|
|
36
|
+
export async function writeState(root, state) {
|
|
37
|
+
const dir = path.join(root, STATE_DIR);
|
|
38
|
+
await fs.mkdir(dir, { recursive: true });
|
|
39
|
+
// `*` keeps the whole folder — state, tags, anything added later — out of the theme's git repo.
|
|
40
|
+
await fs.writeFile(path.join(dir, ".gitignore"), "*\n");
|
|
41
|
+
const file = statePath(root);
|
|
42
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
43
|
+
const sorted = {};
|
|
44
|
+
for (const k of Object.keys(state.files).sort())
|
|
45
|
+
sorted[k] = state.files[k];
|
|
46
|
+
await fs.writeFile(tmp, JSON.stringify({ ...state, files: sorted }, null, 2) + "\n");
|
|
47
|
+
await fs.rename(tmp, file);
|
|
48
|
+
}
|
|
49
|
+
/** Walk up from `start` to the nearest folder holding `.raizn/state.json`, like git finds `.git`. */
|
|
50
|
+
export async function findRoot(start) {
|
|
51
|
+
let dir = path.resolve(start);
|
|
52
|
+
for (;;) {
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(statePath(dir));
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* keep walking */
|
|
59
|
+
}
|
|
60
|
+
const up = path.dirname(dir);
|
|
61
|
+
if (up === dir)
|
|
62
|
+
return null;
|
|
63
|
+
dir = up;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Every layout file under `root`, as POSIX layout paths. Anything else in the folder is ignored. */
|
|
67
|
+
export async function listLocalFiles(root) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const top of LAYOUT_DIRS) {
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await fs.readdir(path.join(root, top), { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const e of entries) {
|
|
78
|
+
if (!e.isFile())
|
|
79
|
+
continue;
|
|
80
|
+
const p = `${top}/${e.name}`;
|
|
81
|
+
if (isLayoutPath(p))
|
|
82
|
+
out.push(p);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out.sort();
|
|
86
|
+
}
|
|
87
|
+
export async function readLocal(root, layoutPath) {
|
|
88
|
+
try {
|
|
89
|
+
return await fs.readFile(toDiskPath(root, layoutPath));
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
if (e.code === "ENOENT" || e.code === "EISDIR")
|
|
93
|
+
return null;
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Files whose bytes differ from what the state recorded, plus layout files the state never saw. */
|
|
98
|
+
export async function localChanges(root, state, only) {
|
|
99
|
+
const files = state?.files ?? {};
|
|
100
|
+
const onDisk = only ? [...only] : await listLocalFiles(root);
|
|
101
|
+
const changes = [];
|
|
102
|
+
for (const p of onDisk) {
|
|
103
|
+
const bytes = await readLocal(root, p);
|
|
104
|
+
if (!bytes)
|
|
105
|
+
continue;
|
|
106
|
+
const hash = hashBytes(bytes);
|
|
107
|
+
const known = files[p];
|
|
108
|
+
if (!known)
|
|
109
|
+
changes.push({ path: p, kind: "new", bytes, hash });
|
|
110
|
+
else if (known.hash !== hash)
|
|
111
|
+
changes.push({ path: p, kind: "modified", bytes, hash });
|
|
112
|
+
}
|
|
113
|
+
const deleted = [];
|
|
114
|
+
if (!only) {
|
|
115
|
+
const present = new Set(onDisk);
|
|
116
|
+
for (const p of Object.keys(files))
|
|
117
|
+
if (!present.has(p))
|
|
118
|
+
deleted.push(p);
|
|
119
|
+
}
|
|
120
|
+
return { changes, deleted: deleted.sort() };
|
|
121
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { putThemeFiles } from "./api.js";
|
|
3
|
+
import { chunkFiles } from "./chunk.js";
|
|
4
|
+
import { unifiedDiff } from "./diff.js";
|
|
5
|
+
import { formatIssue } from "./format.js";
|
|
6
|
+
import { writeState } from "./state.js";
|
|
7
|
+
import { textOf } from "./layout.js";
|
|
8
|
+
/** Contract: tag = first 16 hex of SHA-256 over the UTF-8 content. Only a fallback; the server's tag wins. */
|
|
9
|
+
const tagOf = (content) => createHash("sha256").update(content, "utf8").digest("hex").slice(0, 16);
|
|
10
|
+
/**
|
|
11
|
+
* Send `changes` with the tag each was based on, then record what the server accepted. State is
|
|
12
|
+
* written after every chunk, so a 429 or a dropped connection halfway through never forgets the
|
|
13
|
+
* files that did land, and never marks as pushed a file that did not.
|
|
14
|
+
*/
|
|
15
|
+
export async function pushChanges(s, client, root, state, changes, opts) {
|
|
16
|
+
const outcome = { saved: 0, unchanged: 0, conflicts: [], invalid: [], refused: [] };
|
|
17
|
+
const byPath = new Map(changes.map((c) => [c.path, c]));
|
|
18
|
+
const payload = changes.map((c) => ({ path: c.path, content: textOf(c.bytes), baseTag: state.files[c.path]?.tag ?? null }));
|
|
19
|
+
for (const chunk of chunkFiles(payload)) {
|
|
20
|
+
const r = await putThemeFiles(client, state.store, state.themeId, chunk, opts.force);
|
|
21
|
+
for (const res of r.results ?? []) {
|
|
22
|
+
const local = byPath.get(res.path);
|
|
23
|
+
if (!local)
|
|
24
|
+
continue;
|
|
25
|
+
const sent = chunk.find((f) => f.path === res.path);
|
|
26
|
+
report(s, res, sent?.content ?? "", opts.compact === true);
|
|
27
|
+
if (res.status === "saved" || res.status === "unchanged") {
|
|
28
|
+
// Keep OUR bytes' hash with the server's tag: the server may store a normalised form,
|
|
29
|
+
// but what is on disk is what we sent, so that is what "unmodified" must mean.
|
|
30
|
+
state.files[res.path] = { tag: res.tag ?? tagOf(sent?.content ?? ""), hash: local.hash };
|
|
31
|
+
if (res.status === "saved")
|
|
32
|
+
outcome.saved++;
|
|
33
|
+
else
|
|
34
|
+
outcome.unchanged++;
|
|
35
|
+
}
|
|
36
|
+
else if (res.status === "conflict")
|
|
37
|
+
outcome.conflicts.push(res.path);
|
|
38
|
+
else if (res.status === "invalid")
|
|
39
|
+
outcome.invalid.push(res.path);
|
|
40
|
+
else
|
|
41
|
+
outcome.refused.push(res.path);
|
|
42
|
+
}
|
|
43
|
+
await writeState(root, state);
|
|
44
|
+
}
|
|
45
|
+
return outcome;
|
|
46
|
+
}
|
|
47
|
+
function report(s, res, localContent, compact) {
|
|
48
|
+
const pad = (status) => ` ${status.padEnd(9)} ${res.path}`;
|
|
49
|
+
switch (res.status) {
|
|
50
|
+
case "saved":
|
|
51
|
+
s.out(pad("saved"));
|
|
52
|
+
return;
|
|
53
|
+
case "unchanged":
|
|
54
|
+
if (!compact)
|
|
55
|
+
s.out(pad("unchanged"));
|
|
56
|
+
return;
|
|
57
|
+
case "conflict": {
|
|
58
|
+
s.out(`${pad("conflict")} — changed on the server since you last pulled or pushed it`);
|
|
59
|
+
if (res.current) {
|
|
60
|
+
const diff = unifiedDiff(res.current.content, localContent, `server/${res.path}`, `local/${res.path}`);
|
|
61
|
+
for (const line of diff.trimEnd().split("\n"))
|
|
62
|
+
s.out(line);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
case "invalid":
|
|
67
|
+
s.out(pad("invalid"));
|
|
68
|
+
for (const issue of res.errors ?? [])
|
|
69
|
+
s.out(` ${formatIssue(res.path, issue, !compact)}`);
|
|
70
|
+
return;
|
|
71
|
+
default:
|
|
72
|
+
s.out(`${pad(res.status)}${res.message ? ` — ${res.message}` : ""}`);
|
|
73
|
+
for (const issue of res.errors ?? [])
|
|
74
|
+
s.out(` ${formatIssue(res.path, issue, !compact)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function conflictAdvice(s, conflicts) {
|
|
78
|
+
if (conflicts.length === 0)
|
|
79
|
+
return;
|
|
80
|
+
s.err(`${conflicts.length} file(s) changed on the server since you last synced them (someone else, or the admin editor, saved a newer version). ` +
|
|
81
|
+
"Merge the server's changes into your file and push again, run `raizn theme pull --force` to take the server's version, or `raizn theme push --force` to overwrite it.");
|
|
82
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@raizn/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Edit a Raizn Commerce store's theme as local files, preview it on the real store domain and send it for approval.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Raizn-Global/raizn-commerce.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"raizn": "dist/bin.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.20.3",
|
|
24
|
+
"typescript": "~5.9.3",
|
|
25
|
+
"vitest": "^4.1.8"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@napi-rs/keyring": "^2.1.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org/"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
37
|
+
"test": "vitest run"
|
|
38
|
+
}
|
|
39
|
+
}
|