@blocofy/cli 0.2.4 → 0.5.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 +34 -2
- package/bin/blocofy.mjs +226 -38
- package/lib/args.mjs +10 -2
- package/lib/confirm.mjs +16 -0
- package/lib/dev-server.mjs +3 -0
- package/lib/local-theme.mjs +8 -2
- package/lib/theme-sync.mjs +143 -12
- package/lib/validate.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,13 +17,45 @@ blocofy login [--url <url>] [--token <bcf_…>]
|
|
|
17
17
|
# Save your platform URL + dev token (~/.blocofy/credentials.json, 0600).
|
|
18
18
|
blocofy theme dev [dir] # Local dev server. Prints 3 views — local preview, a live-domain
|
|
19
19
|
# preview link, and the theme editor — all auto-reloading on save.
|
|
20
|
-
# --port <n> (default 3030), --no-sync (local preview only)
|
|
20
|
+
# --port <n> (default 3030), --no-sync (local preview only),
|
|
21
|
+
# --name <name> (name the draft when first created)
|
|
21
22
|
blocofy theme pull [dir] # Download the live theme to disk.
|
|
22
|
-
blocofy theme push [dir] # Write the local theme to
|
|
23
|
+
blocofy theme push [dir] # Write the local theme to a DRAFT by default (create/update; no
|
|
24
|
+
# delete) — preview & publish from the admin panel. --live writes
|
|
25
|
+
# to the live site immediately (asks to confirm; add --yes for CI).
|
|
26
|
+
# --name <name> names the new draft (draft mode only).
|
|
27
|
+
blocofy theme rename <handle> <new name>
|
|
28
|
+
# Rename a theme (label only). Works on any theme, live included.
|
|
29
|
+
# Handle from the panel theme card or `blocofy status`.
|
|
23
30
|
blocofy --version
|
|
24
31
|
blocofy --help
|
|
25
32
|
```
|
|
26
33
|
|
|
34
|
+
## Changelog
|
|
35
|
+
|
|
36
|
+
- **0.5.0** — M4 canonical deploy protocol. `theme push` now deploys through the platform's atomic
|
|
37
|
+
source pipeline: the CLI declares the protocol handshake and auto-generates a per-push idempotency
|
|
38
|
+
key (transport retries converge; `--idempotency-key <k>` overrides it for scripting). A `--live`
|
|
39
|
+
push is finally visible on the pinned production render again. "Push does not delete" still holds —
|
|
40
|
+
remote-only files are carried along, and server-retained rows survive. New flags: `--dry-run` /
|
|
41
|
+
`--validate` (server-side validation, nothing written — refused against a server that predates the
|
|
42
|
+
protocol, which would otherwise silently write), `--diff` (read-only compare vs the LIVE theme).
|
|
43
|
+
`--help` on ANY subcommand now prints help and never runs the command (0.4.0 executed a real push);
|
|
44
|
+
unknown flags exit write-free instead of silently swallowing the next argument. `layer/` joined the
|
|
45
|
+
synced theme directories. Server errors `cli_upgrade_required` (426) and `idempotency_conflict`
|
|
46
|
+
(409) get human messages. Token format check aligned to the server minimum (total length ≥24 incl. the
|
|
47
|
+
`bcf_` prefix; real tokens are 47 chars). Two honest costs of the canonical pipeline: re-pushing
|
|
48
|
+
identical bytes still records a NEW deployment (the revision itself is deduplicated server-side; on
|
|
49
|
+
a repo-linked live target it also produces a reconcile bot commit), and the no-delete guarantee is
|
|
50
|
+
bought by carrying remote-only files in the payload — a very large theme (local+remote > 256 files
|
|
51
|
+
or > 4 MiB) can now hit the server's payload limits where 0.4.0's upsert did not.
|
|
52
|
+
- **0.4.0** — `theme push` / `theme dev` accept `--name <name>` to name a new draft (applied
|
|
53
|
+
only when the draft is first created; reuse ignores it). New `theme rename <handle> <new name>`
|
|
54
|
+
renames a theme (the name is a label; works on any theme, including the live one).
|
|
55
|
+
- **0.3.0** — `theme push` now writes to a **draft** by default; use `--live` for the old
|
|
56
|
+
immediate-live behavior (with a confirmation prompt; `--yes` to skip it in CI). `blocofy
|
|
57
|
+
status` now names the exact pages at 404 risk when a theme's pages are split off the live theme.
|
|
58
|
+
|
|
27
59
|
## How it works
|
|
28
60
|
|
|
29
61
|
`theme dev` starts a local HTTP server. For each page request it reads your local theme
|
package/bin/blocofy.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* `/api/dev/render` endpoint (local theme files + live data) with file-watch
|
|
9
9
|
* livereload. No monorepo required.
|
|
10
10
|
*/
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
11
12
|
import { existsSync } from "node:fs";
|
|
12
13
|
import { createRequire } from "node:module";
|
|
13
14
|
import { resolve } from "node:path";
|
|
@@ -19,14 +20,36 @@ import { credentialsPath, loadCredentials, saveCredentials } from "../lib/creden
|
|
|
19
20
|
import { startDevServer } from "../lib/dev-server.mjs";
|
|
20
21
|
import { readLocalTemplates } from "../lib/local-theme.mjs";
|
|
21
22
|
import { githubNote, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
|
|
22
|
-
import { fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme } from "../lib/theme-sync.mjs";
|
|
23
|
-
import { isAffirmative, livePushDecision } from "../lib/confirm.mjs";
|
|
23
|
+
import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
|
|
24
|
+
import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
|
|
24
25
|
import { hyperlink, openUrl } from "../lib/term.mjs";
|
|
25
26
|
import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
|
|
26
27
|
|
|
27
28
|
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
28
29
|
const args = process.argv.slice(2);
|
|
29
30
|
|
|
31
|
+
// 0.5.0 güvenlik sıkılaştırması: her komutun kabul ettiği bayraklar açık bir listedir. Bilinmeyen bir
|
|
32
|
+
// bayrak YAZIMSIZ çıkışla reddedilir (eskiden sonraki token'ı değer olarak yutup hedef dizini sessizce
|
|
33
|
+
// kaydırıyordu). `--confirm` ve `--dry` belgelenmemiş ama gerçek bayraklardır; `--dry` (theme dev'in
|
|
34
|
+
// sunucu-başlatmama kancası) `--dry-run`'dan (sunucu-tarafı doğrulama) FARKLIDIR, alias değildir.
|
|
35
|
+
const KNOWN = {
|
|
36
|
+
login: ["url", "token"],
|
|
37
|
+
themePull: ["draft", "instance"],
|
|
38
|
+
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key"],
|
|
39
|
+
themeDev: ["port", "dry", "no-sync", "name"],
|
|
40
|
+
themePublish: ["instance"],
|
|
41
|
+
themeRename: ["name"],
|
|
42
|
+
content: [],
|
|
43
|
+
};
|
|
44
|
+
function parseArgsOrExit(rest, known) {
|
|
45
|
+
const parsed = parseArgs(rest, new Set([...known, "help", "version"]));
|
|
46
|
+
if (parsed.unknownFlag) {
|
|
47
|
+
console.error(`Unknown flag ${parsed.unknownFlag}. Nothing was written. See \`blocofy --help\`.`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
/** Human label for a resolved site: "Name (slug)" or just the slug. */
|
|
31
54
|
function siteLabel(site) {
|
|
32
55
|
if (!site) return "";
|
|
@@ -43,13 +66,14 @@ Usage
|
|
|
43
66
|
Save your platform URL + dev token to ~/.blocofy/credentials.json.
|
|
44
67
|
Get a token from the admin panel → Settings → Theme CLI tokens.
|
|
45
68
|
|
|
46
|
-
blocofy theme dev [dir] [--port <n>] [--no-sync]
|
|
69
|
+
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
|
|
47
70
|
Start a dev server and print 3 auto-reloading views — Local, live-domain
|
|
48
71
|
Preview, and the theme Editor. Press l / p / e to open each, q to quit.
|
|
49
72
|
Edit a file and save → every open view reloads. Saves sync to a DRAFT theme
|
|
50
73
|
only (never the live site). (dir defaults to cwd)
|
|
51
74
|
--port <n> local port (default 3030)
|
|
52
75
|
--no-sync local preview only (skip draft sync + remote views)
|
|
76
|
+
--name <name> name the draft when it is first created (ignored if it already exists)
|
|
53
77
|
|
|
54
78
|
blocofy theme pull [dir] [--draft] [--instance <handle>]
|
|
55
79
|
Download the live theme to disk. (dir defaults to cwd)
|
|
@@ -57,14 +81,25 @@ Usage
|
|
|
57
81
|
--instance <handle> pull a specific theme by its handle (from the admin
|
|
58
82
|
panel theme card, or \`blocofy status\`)
|
|
59
83
|
|
|
60
|
-
blocofy theme push [dir] [--
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
blocofy theme push [dir] [--live] [--yes] [--instance <handle>]
|
|
85
|
+
By DEFAULT writes to a DRAFT theme (create/update; no delete) — preview & publish
|
|
86
|
+
it from the admin panel, never touching the live site. Publish it with
|
|
87
|
+
'blocofy theme publish'.
|
|
88
|
+
--live write to the LIVE site IMMEDIATELY (no preview). Asks for
|
|
89
|
+
confirmation first; non-interactive shells must add --yes.
|
|
90
|
+
--draft explicit draft (same as the default; safe)
|
|
91
|
+
--yes confirm a --live push without prompting (for CI / agents)
|
|
66
92
|
--instance <handle> push to a specific theme by its handle (safe targeted
|
|
67
93
|
write — no live-confirmation prompt)
|
|
94
|
+
--name <name> name the NEW draft (draft mode only; ignored on --live/--instance)
|
|
95
|
+
--dry-run validate on the server WITHOUT writing (auth + snapshot + Liquid check)
|
|
96
|
+
--validate alias for --dry-run (validate only, nothing written)
|
|
97
|
+
--diff show what a push WOULD change vs the target (read-only), then stop
|
|
98
|
+
--idempotency-key <k> attach an idempotency key so a retried push is not double-applied
|
|
99
|
+
|
|
100
|
+
blocofy theme rename <handle> <new name>
|
|
101
|
+
Rename a theme (the name is just a label). Works on any of your themes,
|
|
102
|
+
including the live one. Handle comes from the panel theme card or 'blocofy status'.
|
|
68
103
|
|
|
69
104
|
blocofy theme publish [--instance <handle>]
|
|
70
105
|
Publish a draft theme to the LIVE site. With no flag, publishes the draft that
|
|
@@ -90,7 +125,7 @@ Usage
|
|
|
90
125
|
Examples
|
|
91
126
|
blocofy login --url https://store.myblocofy.com --token bcf_xxxxxxxx
|
|
92
127
|
blocofy theme pull && blocofy theme dev
|
|
93
|
-
blocofy theme push
|
|
128
|
+
blocofy theme push && blocofy theme publish
|
|
94
129
|
blocofy status
|
|
95
130
|
|
|
96
131
|
Auth: ~/.blocofy/credentials.json (from \`login\`), or BLOCOFY_URL + BLOCOFY_TOKEN env vars.
|
|
@@ -110,7 +145,7 @@ async function promptValid(rl, question, normalize, valid, hint, tries = 3) {
|
|
|
110
145
|
}
|
|
111
146
|
|
|
112
147
|
async function login(rest) {
|
|
113
|
-
const { flags } =
|
|
148
|
+
const { flags } = parseArgsOrExit(rest, KNOWN.login);
|
|
114
149
|
let url = typeof flags.url === "string" ? normalizeUrl(flags.url) : "";
|
|
115
150
|
let token = typeof flags.token === "string" ? flags.token.trim() : "";
|
|
116
151
|
|
|
@@ -178,7 +213,7 @@ function requireCreds() {
|
|
|
178
213
|
}
|
|
179
214
|
|
|
180
215
|
async function themePull(rest) {
|
|
181
|
-
const { flags, positionals } =
|
|
216
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePull);
|
|
182
217
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
183
218
|
const creds = requireCreds();
|
|
184
219
|
const draft = Boolean(flags.draft);
|
|
@@ -189,15 +224,56 @@ async function themePull(rest) {
|
|
|
189
224
|
}
|
|
190
225
|
|
|
191
226
|
async function themePush(rest) {
|
|
192
|
-
const { flags, positionals } =
|
|
227
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePush);
|
|
193
228
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
194
229
|
if (!existsSync(dir)) {
|
|
195
230
|
console.error(`Theme directory not found: ${dir}`);
|
|
196
231
|
process.exit(1);
|
|
197
232
|
}
|
|
198
233
|
const creds = requireCreds();
|
|
199
|
-
const
|
|
200
|
-
const
|
|
234
|
+
const instanceFlag = typeof flags.instance === "string" ? flags.instance : null;
|
|
235
|
+
const name = typeof flags.name === "string" ? flags.name : null;
|
|
236
|
+
const dryRun = Boolean(flags["dry-run"] || flags.validate);
|
|
237
|
+
// 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
|
|
238
|
+
// OLMADAN header'lar tek başına legacy writer'a düşer ve --live push pinned render'a YANSIMAZ (M4
|
|
239
|
+
// read cutover'ının ana CLI şikâyeti). Push-OPERASYONU-başına üretilir: fetchWithRetry'nin 5xx/429
|
|
240
|
+
// denemeleri aynı key'le yakınsar, ardışık push'lar farklı key alır (sabit key, settings/hedef
|
|
241
|
+
// değişiminde kalıcı 409 idempotency_conflict üretirdi). Ham id gönderilir — sunucu `idem:` ile
|
|
242
|
+
// ad-alanlar, önek EKLENMEZ.
|
|
243
|
+
const idempotencyKey = typeof flags["idempotency-key"] === "string" ? flags["idempotency-key"] : `cli-${randomUUID()}`;
|
|
244
|
+
|
|
245
|
+
// `--diff`: read-only preview vs the LIVE theme (or --instance). No write; the draft target is not
|
|
246
|
+
// diffable (a draft GET would PROVISION the draft server-side — a read-only command must not mutate).
|
|
247
|
+
if (flags.diff) {
|
|
248
|
+
let diffSite = null;
|
|
249
|
+
try {
|
|
250
|
+
diffSite = (await fetchWhoami({ url: creds.url, token: creds.token })).site;
|
|
251
|
+
} catch {
|
|
252
|
+
/* best-effort — etiket kozmetik, diff yine koşar */
|
|
253
|
+
}
|
|
254
|
+
const diffTarget = diffSite ? ` of ${siteLabel(diffSite)}` : "";
|
|
255
|
+
const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: instanceFlag });
|
|
256
|
+
console.log(instanceFlag ? `Diff vs theme ${instanceFlag}${diffTarget}:` : `Diff vs the LIVE theme${diffTarget} (push default writes to a DRAFT):`);
|
|
257
|
+
const total = d.added.length + d.changed.length + d.removed.length;
|
|
258
|
+
if (total === 0) {
|
|
259
|
+
console.log("No differences — local theme matches the target.");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
for (const k of d.added) console.log(` + ${k}`);
|
|
263
|
+
for (const k of d.changed) console.log(` ~ ${k}`);
|
|
264
|
+
for (const k of d.removed) console.log(` - ${k} (present on target, absent locally — push does not delete)`);
|
|
265
|
+
console.log(`\n${d.added.length} added, ${d.changed.length} changed, ${d.removed.length} remote-only.`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Yeni varsayılan hedef: DRAFT (güvenli). `--live` eski anında-canlı davranışını
|
|
270
|
+
// açıkça geri getirir; `--instance` belirli bir temayı adresler. Sadece "live"
|
|
271
|
+
// modu canlıya yazar ve onay gerektirir.
|
|
272
|
+
const { mode, instance } = resolvePushMode({
|
|
273
|
+
live: Boolean(flags.live),
|
|
274
|
+
draft: Boolean(flags.draft),
|
|
275
|
+
instance: instanceFlag,
|
|
276
|
+
});
|
|
201
277
|
|
|
202
278
|
// Hedef tenant'ı çöz ve GÖSTER — site sunucuda TOKEN'dan çözülür (URL kozmetik),
|
|
203
279
|
// yanlış-tenant'a yazımı görünür kılar ("klarosa sandım, ksc'ye yazdım"). whoami
|
|
@@ -209,21 +285,27 @@ async function themePush(rest) {
|
|
|
209
285
|
/* best-effort */
|
|
210
286
|
}
|
|
211
287
|
const target = site ? siteLabel(site) : creds.url;
|
|
212
|
-
if (
|
|
213
|
-
console.log(`→
|
|
288
|
+
if (mode === "instance") {
|
|
289
|
+
console.log(`→ Pushing to theme ${instance}${site ? ` of ${target}` : ""}`);
|
|
290
|
+
} else if (mode === "live") {
|
|
291
|
+
console.log(dryRun ? `→ Validating against the LIVE theme of ${target} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${target}`);
|
|
292
|
+
} else {
|
|
293
|
+
console.log(`→ Pushing to a draft${site ? ` of ${target}` : ""}`);
|
|
214
294
|
}
|
|
215
295
|
|
|
216
|
-
// Canlı push (
|
|
217
|
-
// kazara canlıya basmasın diye açık onay şart (#431 L2).
|
|
296
|
+
// Canlı push (`--live`) ANINDA canlı temayı değiştirir (önizleme yok). Agent/CI
|
|
297
|
+
// kazara canlıya basmasın diye açık onay şart (#431 L2). Draft/instance modu
|
|
298
|
+
// güvenli → otomatik onay (prompt yok).
|
|
299
|
+
// 0.5.0: `--dry-run` hiçbir şey yazmaz → canlı onayı gereksiz (draft:true gibi davranır).
|
|
218
300
|
const decision = livePushDecision({
|
|
219
|
-
draft:
|
|
301
|
+
draft: mode !== "live" || dryRun,
|
|
220
302
|
yes: Boolean(flags.yes),
|
|
221
303
|
confirm: Boolean(flags.confirm),
|
|
222
304
|
isTTY: Boolean(process.stdin.isTTY),
|
|
223
305
|
});
|
|
224
306
|
if (decision.mustAbort) {
|
|
225
|
-
console.error(`⚠ 'theme push' writes to the LIVE theme of ${target} immediately (no preview).`);
|
|
226
|
-
console.error(` Non-interactive shell: pass --yes to confirm, or
|
|
307
|
+
console.error(`⚠ 'theme push --live' writes to the LIVE theme of ${target} immediately (no preview).`);
|
|
308
|
+
console.error(` Non-interactive shell: pass --live --yes to confirm, or omit --live to push to a safe draft.`);
|
|
227
309
|
process.exit(1);
|
|
228
310
|
}
|
|
229
311
|
if (decision.needsPrompt) {
|
|
@@ -235,22 +317,90 @@ async function themePush(rest) {
|
|
|
235
317
|
rl.close();
|
|
236
318
|
}
|
|
237
319
|
if (!isAffirmative(answer)) {
|
|
238
|
-
console.error("Aborted.
|
|
320
|
+
console.error("Aborted. Omit `--live` to push to a safe draft, or pass `--live --yes` to confirm.");
|
|
239
321
|
process.exit(1);
|
|
240
322
|
}
|
|
241
323
|
}
|
|
242
324
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
325
|
+
// `--name` yalnızca YENİ taslak yaratırken (draft modu) anlamlı — canlıya/mevcut
|
|
326
|
+
// instance'a yazarken ad kaydedilmez, sessizce kaybolmasın diye açıkça uyar.
|
|
327
|
+
if (name && mode !== "draft") {
|
|
328
|
+
console.error("Note: --name yalnız yeni taslak yaratırken (varsayılan push) geçerli, yok sayıldı.");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 0.5.0: eski (protokolsüz) bir sunucu `dryRun` alanını bilmez ve SESSİZCE GERÇEK YAZIM yapardı —
|
|
332
|
+
// dry-run yalnız sunucu kanonik protokolü beyan ediyorsa koşar (sorgusuz, mutasyonsuz GET ön kontrolü).
|
|
333
|
+
if (dryRun) {
|
|
334
|
+
const { supported } = await fetchCanonicalSupport({ url: creds.url, token: creds.token });
|
|
335
|
+
if (!supported) {
|
|
336
|
+
console.error("✗ --dry-run needs a server that speaks the canonical protocol; this one does not.");
|
|
337
|
+
console.error(" An old server would IGNORE dryRun and write for real. Nothing was sent.");
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let result;
|
|
343
|
+
try {
|
|
344
|
+
result = await pushTheme({
|
|
345
|
+
dir,
|
|
346
|
+
url: creds.url,
|
|
347
|
+
token: creds.token,
|
|
348
|
+
draft: mode === "draft",
|
|
349
|
+
instance: mode === "instance" ? instance : null,
|
|
350
|
+
name: mode === "draft" ? name : null,
|
|
351
|
+
dryRun,
|
|
352
|
+
idempotencyKey,
|
|
353
|
+
onRetry: (info) => console.error(retryNotice(info)),
|
|
354
|
+
});
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (error?.code === "cli_upgrade_required") {
|
|
357
|
+
const missing = Array.isArray(error?.body?.fence?.missing) ? error.body.fence.missing.join(", ") : "";
|
|
358
|
+
console.error("✗ Sunucu bu CLI sürümünü reddetti (cli_upgrade_required).");
|
|
359
|
+
console.error(` Güncelle: npm i -g @blocofy/cli@latest${missing ? `\n Sunucunun istediği eksik yetenekler: ${missing}` : ""}`);
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
if (error?.code === "idempotency_conflict") {
|
|
363
|
+
console.error("✗ Idempotency çakışması: aynı anahtar daha önce FARKLI içerikle kullanılmış (409).");
|
|
364
|
+
console.error(" `--idempotency-key` verdiysen yeni bir anahtarla dene; vermediysen tekrar `blocofy theme push` yeterli (her koşu taze anahtar üretir).");
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --dry-run / --validate: the server validated without writing. Report and stop.
|
|
371
|
+
if (result.dryRun) {
|
|
372
|
+
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
373
|
+
console.log(`✓ Validation passed (dry run — nothing written).${warnings.length ? ` ${warnings.length} warning(s).` : ""}`);
|
|
374
|
+
for (const w of warnings) console.log(` ⚠ ${w}`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 0.5.0: kanonik boru hattı yanıtı (CP üzerinden atomik deploy) — legacy created/updated alanları yok.
|
|
379
|
+
if (result.committed === true) {
|
|
380
|
+
if (Array.isArray(result.remoteOnlyKept) && result.remoteOnlyKept.length) {
|
|
381
|
+
console.log(` (kept ${result.remoteOnlyKept.length} remote-only file(s) — push does not delete)`);
|
|
382
|
+
}
|
|
383
|
+
console.log(`✓ Deployed atomically: deployment #${result.deploymentId}, revision #${result.sourceRevisionId}, pointer v${result.pointerVersion}.`);
|
|
384
|
+
if (mode === "draft") {
|
|
385
|
+
console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
|
|
386
|
+
console.log("Publish it live with: blocofy theme publish");
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Sunucu canlı-yazımı bildirdiyse (yeni alan; eski sunucuda yok) belirgin uyar.
|
|
392
|
+
if (result.warning === "live_write" && result.message) {
|
|
393
|
+
console.error(`\n⚠ ${result.message}\n`);
|
|
394
|
+
}
|
|
395
|
+
|
|
251
396
|
if (result.instanceId) {
|
|
252
397
|
console.log(`Pushed to theme ${result.instanceId} (${result.created} created, ${result.updated} updated).`);
|
|
253
|
-
|
|
398
|
+
if (mode === "draft") {
|
|
399
|
+
console.log(`Preview & publish it in the admin panel: Theme → Theme library → "Open in editor".`);
|
|
400
|
+
console.log(`Publish it live with: blocofy theme publish`);
|
|
401
|
+
} else {
|
|
402
|
+
console.log(`Preview & publish it in the admin panel: Theme → Theme library → "Open in editor".`);
|
|
403
|
+
}
|
|
254
404
|
} else {
|
|
255
405
|
const extra = result.skippedDeletes
|
|
256
406
|
? `, ${result.skippedDeletes} remote file(s) absent locally (not deleted)`
|
|
@@ -260,7 +410,7 @@ async function themePush(rest) {
|
|
|
260
410
|
}
|
|
261
411
|
|
|
262
412
|
async function contentPush(scope, rest) {
|
|
263
|
-
const { positionals } =
|
|
413
|
+
const { positionals } = parseArgsOrExit(rest, KNOWN.content);
|
|
264
414
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
265
415
|
if (!existsSync(dir)) {
|
|
266
416
|
console.error(`Directory not found: ${dir}`);
|
|
@@ -282,7 +432,7 @@ async function contentPush(scope, rest) {
|
|
|
282
432
|
}
|
|
283
433
|
|
|
284
434
|
async function contentPull(scope, rest) {
|
|
285
|
-
const { positionals } =
|
|
435
|
+
const { positionals } = parseArgsOrExit(rest, KNOWN.content);
|
|
286
436
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
287
437
|
const creds = requireCreds();
|
|
288
438
|
const { count } = await pullContent({ dir, url: creds.url, token: creds.token, scope });
|
|
@@ -290,7 +440,7 @@ async function contentPull(scope, rest) {
|
|
|
290
440
|
}
|
|
291
441
|
|
|
292
442
|
async function themeDev(rest) {
|
|
293
|
-
const { flags, positionals } =
|
|
443
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeDev);
|
|
294
444
|
const themeDir = resolve(positionals[0] ?? process.cwd());
|
|
295
445
|
|
|
296
446
|
if (!existsSync(themeDir)) {
|
|
@@ -306,7 +456,8 @@ async function themeDev(rest) {
|
|
|
306
456
|
let session = null;
|
|
307
457
|
if (!flags["no-sync"]) {
|
|
308
458
|
try {
|
|
309
|
-
|
|
459
|
+
const name = typeof flags.name === "string" ? flags.name : null;
|
|
460
|
+
session = await fetchDevSession({ url: creds.url, token: creds.token, name });
|
|
310
461
|
} catch (error) {
|
|
311
462
|
console.warn(
|
|
312
463
|
`Warning: dev session unavailable (${error?.message ?? error}). ` +
|
|
@@ -430,7 +581,7 @@ async function themeDev(rest) {
|
|
|
430
581
|
}
|
|
431
582
|
|
|
432
583
|
async function themePublish(rest) {
|
|
433
|
-
const { flags } =
|
|
584
|
+
const { flags } = parseArgsOrExit(rest, KNOWN.themePublish);
|
|
434
585
|
const creds = requireCreds();
|
|
435
586
|
let instance = typeof flags.instance === "string" ? flags.instance : null;
|
|
436
587
|
if (!instance) {
|
|
@@ -444,6 +595,20 @@ async function themePublish(rest) {
|
|
|
444
595
|
);
|
|
445
596
|
}
|
|
446
597
|
|
|
598
|
+
async function themeRename(rest) {
|
|
599
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeRename);
|
|
600
|
+
const handle = positionals[0];
|
|
601
|
+
const name = positionals.slice(1).join(" ") || (typeof flags.name === "string" ? flags.name : null);
|
|
602
|
+
if (!handle || !name) {
|
|
603
|
+
console.error("Usage: blocofy theme rename <handle> <new name>");
|
|
604
|
+
console.error(" Rename a theme (handle from the panel theme card or `blocofy status`).");
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
const creds = requireCreds();
|
|
608
|
+
const result = await renameInstance({ url: creds.url, token: creds.token, instance: handle, name });
|
|
609
|
+
console.log(`✓ Renamed to "${result.name}" (${result.id}).`);
|
|
610
|
+
}
|
|
611
|
+
|
|
447
612
|
async function status() {
|
|
448
613
|
const creds = requireCreds();
|
|
449
614
|
const s = await fetchSiteStatus({ url: creds.url, token: creds.token });
|
|
@@ -455,12 +620,23 @@ async function status() {
|
|
|
455
620
|
: `Live theme: none`,
|
|
456
621
|
);
|
|
457
622
|
console.log(`Health: ${s.health}`);
|
|
623
|
+
// orphan_missing_slugs yeni sunucu alanı (PR #661). Eski/deploy-edilmemiş sunucuda
|
|
624
|
+
// yok → null; o durumda eski (yumuşatılmış) sayaç satırına düş.
|
|
625
|
+
const missing = Array.isArray(s.orphan_missing_slugs) ? s.orphan_missing_slugs : null;
|
|
458
626
|
if (s.health === "live_instance_empty") {
|
|
459
627
|
console.error(
|
|
460
628
|
` ⚠ The live theme has no pages — the site will 404. Publish a theme with content: blocofy theme publish`,
|
|
461
629
|
);
|
|
462
630
|
} else if (s.health === "pages_split") {
|
|
463
|
-
|
|
631
|
+
if (missing && missing.length) {
|
|
632
|
+
console.warn(
|
|
633
|
+
` ⚠ These pages are published only on a non-live theme, so visitors can't reach them (404 risk): ` +
|
|
634
|
+
`${missing.join(", ")}\n` +
|
|
635
|
+
` Fix: publish the theme that has them — blocofy theme publish`,
|
|
636
|
+
);
|
|
637
|
+
} else {
|
|
638
|
+
console.warn(` ⚠ ${s.orphaned_pages} page(s) published on a non-live theme.`);
|
|
639
|
+
}
|
|
464
640
|
}
|
|
465
641
|
if (Array.isArray(s.drafts) && s.drafts.length > 0) {
|
|
466
642
|
console.log(`Drafts: ${s.drafts.map((d) => `${d.id}${d.name ? ` ${d.name}` : ""}`).join(", ")}`);
|
|
@@ -474,6 +650,13 @@ if (first === "--version" || first === "-v") {
|
|
|
474
650
|
console.log(VERSION);
|
|
475
651
|
} else if (!first || first === "--help" || first === "-h" || first === "help") {
|
|
476
652
|
printHelp();
|
|
653
|
+
} else if (args.includes("--help") || args.includes("-h")) {
|
|
654
|
+
// 0.5.0: `--help` HERHANGİ bir konumda YARDIMDIR — alt-komut handler'ı asla koşmaz.
|
|
655
|
+
// (0.4.0'da `blocofy theme push --help` GERÇEK bir push koşuyordu.)
|
|
656
|
+
printHelp();
|
|
657
|
+
} else if (args.includes("--version") || args.includes("-v")) {
|
|
658
|
+
// Aynı simetri: `theme push <dir> --version` da yalnız sürüm basar, asla yazmaz.
|
|
659
|
+
console.log(VERSION);
|
|
477
660
|
} else if (first === "login") {
|
|
478
661
|
login(rest).catch((error) => {
|
|
479
662
|
console.error(error?.message ?? error);
|
|
@@ -499,6 +682,11 @@ if (first === "--version" || first === "-v") {
|
|
|
499
682
|
console.error(error?.message ?? error);
|
|
500
683
|
process.exit(1);
|
|
501
684
|
});
|
|
685
|
+
} else if (first === "theme" && rest[0] === "rename") {
|
|
686
|
+
themeRename(rest.slice(1)).catch((error) => {
|
|
687
|
+
console.error(error?.message ?? error);
|
|
688
|
+
process.exit(1);
|
|
689
|
+
});
|
|
502
690
|
} else if (first === "status") {
|
|
503
691
|
status().catch((error) => {
|
|
504
692
|
console.error(error?.message ?? error);
|
package/lib/args.mjs
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/** Sonraki token'ı değer olarak almayan flag'ler. */
|
|
8
|
-
const BOOLEAN_FLAGS = new Set(["draft", "yes", "confirm", "no-sync", "dry", "help", "version"]);
|
|
8
|
+
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version"]);
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
|
|
12
|
+
* `unknownFlag` olarak döner ve DEĞER YUTMAZ — eskiden `--halp mydir` `mydir`'i değer sanıp
|
|
13
|
+
* hedef dizini sessizce cwd'ye kaydırıyordu (0.5.0: bilinmeyen bayrak = yazımsız çıkış).
|
|
14
|
+
*/
|
|
15
|
+
export function parseArgs(rest, known = null) {
|
|
11
16
|
const flags = {};
|
|
12
17
|
const positionals = [];
|
|
13
18
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -17,6 +22,9 @@ export function parseArgs(rest) {
|
|
|
17
22
|
continue;
|
|
18
23
|
}
|
|
19
24
|
const key = arg.slice(2);
|
|
25
|
+
if (known && !known.has(key)) {
|
|
26
|
+
return { flags, positionals, unknownFlag: `--${key}` };
|
|
27
|
+
}
|
|
20
28
|
if (BOOLEAN_FLAGS.has(key)) {
|
|
21
29
|
flags[key] = true;
|
|
22
30
|
continue;
|
package/lib/confirm.mjs
CHANGED
|
@@ -15,6 +15,22 @@ export function livePushDecision({ draft, yes, confirm, isTTY }) {
|
|
|
15
15
|
return { autoApproved: false, needsPrompt: true, mustAbort: false };
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* `theme push` hedefini seç (saf, yan etkisiz). Yeni varsayılan: DRAFT (güvenli).
|
|
20
|
+
* `--live` eski "anında canlı" davranışını açıkça geri getirir.
|
|
21
|
+
* - `instance` verilmişse → { mode: "instance", instance } (açık-hedef; `--live`
|
|
22
|
+
* olsa bile instance KAZANIR — kullanıcı belirli bir temayı adresledi).
|
|
23
|
+
* - `--live` (instance yok) → { mode: "live" } (onay gerektiren tek yol).
|
|
24
|
+
* - aksi halde → { mode: "draft" } (varsayılan; açık `--draft` dahil).
|
|
25
|
+
* - `--live` + `--draft` birlikte → draft (güvenli olan kazanır).
|
|
26
|
+
*/
|
|
27
|
+
export function resolvePushMode({ live, draft, instance } = {}) {
|
|
28
|
+
if (instance) return { mode: "instance", instance };
|
|
29
|
+
if (draft) return { mode: "draft" };
|
|
30
|
+
if (live) return { mode: "live" };
|
|
31
|
+
return { mode: "draft" };
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
/** y/N yanıtını yorumla (yes/y → onay). */
|
|
19
35
|
export function isAffirmative(answer) {
|
|
20
36
|
const a = String(answer ?? "").trim().toLowerCase();
|
package/lib/dev-server.mjs
CHANGED
|
@@ -97,6 +97,9 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, onErr
|
|
|
97
97
|
let error = null;
|
|
98
98
|
if (syncDraft) {
|
|
99
99
|
try {
|
|
100
|
+
// BİLİNÇLE key'siz (0.5.0): dev sync her kayıtta koşar — kanonik mühür (key'li dal) kayıt
|
|
101
|
+
// başına revizyon+deployment üretirdi (retention yok) ve taslak önizlemeye katkısı sıfır.
|
|
102
|
+
// Key'siz istek sunucuda legacy draft yazımıdır; kanonik mühür bilinçli `theme push` işidir.
|
|
100
103
|
await pushTheme({ dir, url, token, draft: true, onRetry });
|
|
101
104
|
} catch (err) {
|
|
102
105
|
error = err?.message ?? String(err); // senkron hatası reload'u engellemesin
|
package/lib/local-theme.mjs
CHANGED
|
@@ -7,7 +7,9 @@ import { extname, join, relative, sep } from "node:path";
|
|
|
7
7
|
* THEME_DIRS, keyed by relative path with the `.liquid` extension stripped.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Mirror of the platform's theme-paths.ts THEME_DIRS (incl. `layer`, added M5 B1 — 0.5.0 closes the
|
|
11
|
+
// drift where a pulled layer/ file was silently dropped from the next push).
|
|
12
|
+
export const THEME_DIRS = new Set(["layout", "section", "block", "layer", "partial", "asset", "template"]);
|
|
11
13
|
const LIQUID_KINDS = new Set(["layout", "section", "block", "partial", "template"]);
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -16,7 +18,11 @@ const LIQUID_KINDS = new Set(["layout", "section", "block", "partial", "template
|
|
|
16
18
|
*/
|
|
17
19
|
export function localPathFor(key) {
|
|
18
20
|
const top = key.split("/")[0] ?? "";
|
|
19
|
-
|
|
21
|
+
// Platform repoPathFor aynası (0.5.0): `.liquid` yalnız UZANTISIZ basename'lere — `template/index.json`
|
|
22
|
+
// diske AYNEN yazılır; eski kural onu `.json.liquid` yapıp git-checkout'lu bir çalışma dizininde
|
|
23
|
+
// repo'daki gerçek dosyanın yanına churn-ikizi düşürüyordu.
|
|
24
|
+
const base = key.split("/").pop() ?? "";
|
|
25
|
+
return LIQUID_KINDS.has(top) && !base.includes(".") ? `${key}.liquid` : key;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** Turn a theme directory into a `{ path: content }` map (sent to dev-render). */
|
package/lib/theme-sync.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
|
-
import { localPathFor, readLocalTemplates } from "./local-theme.mjs";
|
|
4
|
+
import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* `blocofy theme pull/push`. Talks to the platform's `/api/dev/theme` endpoint
|
|
@@ -9,6 +9,19 @@ import { localPathFor, readLocalTemplates } from "./local-theme.mjs";
|
|
|
9
9
|
* readLocalTemplates → POST (create/update; no delete).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* M4 canonical source-write handshake. We declare the protocol version + the full canonical-write
|
|
14
|
+
* capability set; the server is authoritative and fences an under-declaring client (a NEW CLI against an
|
|
15
|
+
* OLD server just sees the headers ignored — forward compatible). Capabilities must match the server's
|
|
16
|
+
* CANONICAL_WRITE_CAPABILITIES exactly, in order.
|
|
17
|
+
*/
|
|
18
|
+
const CANONICAL_PROTOCOL = "1";
|
|
19
|
+
const CANONICAL_CAPABILITIES = "validate,dry-run,diff,idempotency-key,target-instance";
|
|
20
|
+
|
|
21
|
+
function canonicalHeaders(extra = {}) {
|
|
22
|
+
return { "x-blocofy-protocol": CANONICAL_PROTOCOL, "x-blocofy-capabilities": CANONICAL_CAPABILITIES, ...extra };
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
async function errorText(res) {
|
|
13
26
|
const text = await res.text();
|
|
14
27
|
try {
|
|
@@ -18,6 +31,42 @@ async function errorText(res) {
|
|
|
18
31
|
}
|
|
19
32
|
}
|
|
20
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Yapılandırılmış HTTP hatası (0.5.0): mesajın yanında `code` (sunucunun `error` alanı), `status` ve tam
|
|
36
|
+
* `body` taşınır — 426 `cli_upgrade_required` (fence.missing/requiredVersion) ve 409 `idempotency_conflict`
|
|
37
|
+
* için insan-dili mesajı ÇAĞIRAN kurar; buradaki throw yalnız veriyi kaybetmeden taşır.
|
|
38
|
+
*/
|
|
39
|
+
async function httpError(res) {
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let body = null;
|
|
42
|
+
try {
|
|
43
|
+
body = JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
/* düz metin gövde */
|
|
46
|
+
}
|
|
47
|
+
const err = new Error(typeof body?.error === "string" ? body.error : text || `HTTP ${res.status}`);
|
|
48
|
+
err.status = res.status;
|
|
49
|
+
err.code = typeof body?.error === "string" ? body.error : null;
|
|
50
|
+
err.body = body;
|
|
51
|
+
return err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sunucu kanonik protokolü konuşuyor mu? (0.5.0 `--dry-run` ön kontrolü.) SORGUSUZ canlı GET — mutasyonsuz
|
|
56
|
+
* (`?draft=1` GET'i sunucuda taslak provizyonlar, o yüzden burada KULLANILMAZ). Eski bir sunucu `protocol`
|
|
57
|
+
* alanını hiç dönmez → false → `--dry-run` reddedilir; aksi hâlde eski sunucu `dryRun`'ı yok sayıp GERÇEK
|
|
58
|
+
* yazım yapardı (0.5.0 öncesi sessiz-yazım regresyonu).
|
|
59
|
+
*/
|
|
60
|
+
export async function fetchCanonicalSupport({ url, token }) {
|
|
61
|
+
const base = url.replace(/\/+$/, "");
|
|
62
|
+
const res = await fetch(`${base}/api/dev/theme`, {
|
|
63
|
+
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) throw await httpError(res);
|
|
66
|
+
const body = await res.json();
|
|
67
|
+
return { supported: body?.protocol === 1 };
|
|
68
|
+
}
|
|
69
|
+
|
|
21
70
|
/** Backoff delays (ms) between attempts — index 0 = after the first failure. */
|
|
22
71
|
const RETRY_BACKOFF_MS = [300, 900];
|
|
23
72
|
|
|
@@ -70,7 +119,7 @@ export async function pullTheme({ dir, url, token, draft = false, instance = nul
|
|
|
70
119
|
const base = url.replace(/\/+$/, "");
|
|
71
120
|
const query = instance ? `?instance=${encodeURIComponent(instance)}` : draft ? "?draft=1" : "";
|
|
72
121
|
const res = await fetch(`${base}/api/dev/theme${query}`, {
|
|
73
|
-
headers: { authorization: `Bearer ${token}` },
|
|
122
|
+
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
74
123
|
});
|
|
75
124
|
if (!res.ok) throw new Error(await errorText(res));
|
|
76
125
|
const { files } = await res.json();
|
|
@@ -103,9 +152,10 @@ export async function fetchWhoami({ url, token }) {
|
|
|
103
152
|
* Dev session bilgisi (#119 `theme dev`): platform draft instance'ı hazırlar ve
|
|
104
153
|
* 3 görünümün URL'lerini döner — `{ draftInstanceId, previewUrl, editorUrl, site }`.
|
|
105
154
|
*/
|
|
106
|
-
export async function fetchDevSession({ url, token }) {
|
|
155
|
+
export async function fetchDevSession({ url, token, name = null }) {
|
|
107
156
|
const base = url.replace(/\/+$/, "");
|
|
108
|
-
const
|
|
157
|
+
const query = name ? `?name=${encodeURIComponent(name)}` : "";
|
|
158
|
+
const res = await fetch(`${base}/api/dev/session${query}`, {
|
|
109
159
|
headers: { authorization: `Bearer ${token}` },
|
|
110
160
|
});
|
|
111
161
|
if (!res.ok) throw new Error(await errorText(res));
|
|
@@ -117,21 +167,86 @@ export async function fetchDevSession({ url, token }) {
|
|
|
117
167
|
* writes to a draft theme instance instead of the live theme — preview & publish
|
|
118
168
|
* it from the admin panel without affecting the live site.
|
|
119
169
|
*/
|
|
120
|
-
export async function pushTheme({ dir, url, token, draft = false, instance = null, onRetry = null }) {
|
|
170
|
+
export async function pushTheme({ dir, url, token, draft = false, instance = null, name = null, dryRun = false, idempotencyKey = null, onRetry = null }) {
|
|
121
171
|
const base = url.replace(/\/+$/, "");
|
|
122
172
|
const files = readLocalTemplates(dir);
|
|
123
|
-
const
|
|
173
|
+
const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
|
|
174
|
+
const remoteOnlyKept = [];
|
|
175
|
+
if (idempotencyKey && !dryRun) {
|
|
176
|
+
// 0.5.0 kanonik push üç aşamadır (denetim düzeltmeleri):
|
|
177
|
+
// 1) PREFLIGHT dry-run POST (yalnız yerel küme): 422/426 sınıfı hatalar SUNUCUDA HİÇBİR ŞEY
|
|
178
|
+
// provizyonlanmadan yakalanır — probe GET'i taslak yaratabildiği için başarısız bir push
|
|
179
|
+
// arkada instance + sayfa klonu bırakıyordu.
|
|
180
|
+
// 2) MERGE PROBE GET: "push silmez" garantisi — uzakta olup yerelde olmayan, kapı-kabul-eden
|
|
181
|
+
// dosyalar payload'a aynen eklenir; kapı-dışı satırları (README.md vb.) sunucunun
|
|
182
|
+
// retained-rows kuralı korur. Taslak hedefte `?name=` taşınır: adsız probe taslağı
|
|
183
|
+
// `CLI Draft — <tarih>` olarak yaratır ve POST'un --name'i reuse yolunda yok sayılırdı.
|
|
184
|
+
// 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
|
|
185
|
+
const pre = await fetchWithRetry(
|
|
186
|
+
`${base}/api/dev/theme`,
|
|
187
|
+
{ method: "POST", headers: headersFor({ "content-type": "application/json", "x-idempotency-key": idempotencyKey }), body: JSON.stringify({ ...(instance ? { files, instance } : { files, draft, ...(name ? { name } : {}) }), dryRun: true }) },
|
|
188
|
+
{ onRetry },
|
|
189
|
+
);
|
|
190
|
+
if (!pre.ok) throw await httpError(pre);
|
|
191
|
+
|
|
192
|
+
const query = instance
|
|
193
|
+
? `?instance=${encodeURIComponent(instance)}`
|
|
194
|
+
: draft
|
|
195
|
+
? `?draft=1${name ? `&name=${encodeURIComponent(name)}` : ""}`
|
|
196
|
+
: "";
|
|
197
|
+
const probe = await fetch(`${base}/api/dev/theme${query}`, { headers: headersFor() });
|
|
198
|
+
if (!probe.ok) throw await httpError(probe);
|
|
199
|
+
const { files: remote = {} } = await probe.json();
|
|
200
|
+
for (const [key, content] of Object.entries(remote)) {
|
|
201
|
+
if (key in files) continue;
|
|
202
|
+
const top = key.split("/")[0] ?? "";
|
|
203
|
+
// Sunucu kapı-kabul kümesinin birebir aynası: THEME_DIRS altı ÇOK-SEGMENTLİ path'ler + tek
|
|
204
|
+
// config istisnası (çıplak `layout` gibi slash'sız bir uzak anahtar sunucuda 422 olurdu).
|
|
205
|
+
if ((key.includes("/") && THEME_DIRS.has(top)) || key === "config/settings_schema.json") {
|
|
206
|
+
files[key] = typeof content === "string" ? content : "";
|
|
207
|
+
remoteOnlyKept.push(key);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
remoteOnlyKept.sort();
|
|
211
|
+
}
|
|
212
|
+
const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
|
|
213
|
+
// dryRun asks the server to validate (auth + snapshot + hostile-Liquid worker) WITHOUT writing.
|
|
214
|
+
const payload = dryRun ? { ...target, dryRun: true } : target;
|
|
215
|
+
const headers = canonicalHeaders({ "content-type": "application/json", authorization: `Bearer ${token}` });
|
|
216
|
+
if (idempotencyKey) headers["x-idempotency-key"] = idempotencyKey;
|
|
124
217
|
const res = await fetchWithRetry(
|
|
125
218
|
`${base}/api/dev/theme`,
|
|
126
|
-
{
|
|
127
|
-
method: "POST",
|
|
128
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
129
|
-
body: JSON.stringify(payload),
|
|
130
|
-
},
|
|
219
|
+
{ method: "POST", headers, body: JSON.stringify(payload) },
|
|
131
220
|
{ onRetry },
|
|
132
221
|
);
|
|
222
|
+
if (!res.ok) throw await httpError(res);
|
|
223
|
+
const json = await res.json();
|
|
224
|
+
return remoteOnlyKept.length ? { ...json, remoteOnlyKept } : json;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `blocofy theme push --diff` — compare the LOCAL theme with the LIVE theme (or an explicit `--instance`).
|
|
229
|
+
* Read-only: the draft target is deliberately NOT diffable — `?draft=1` GET'i sunucuda taslak PROVİZYONLAR
|
|
230
|
+
* (sayfa klonları dahil), read-only bir komut mutasyon tetikleyemez (0.5.0 denetim düzeltmesi). Çağıran
|
|
231
|
+
* çıktıyı "canlıya göre fark" diye etiketler. Returns `{ added, changed, removed }` (stripped keys).
|
|
232
|
+
*/
|
|
233
|
+
export async function diffTheme({ dir, url, token, instance = null }) {
|
|
234
|
+
const base = url.replace(/\/+$/, "");
|
|
235
|
+
const query = instance ? `?instance=${encodeURIComponent(instance)}` : "";
|
|
236
|
+
const res = await fetch(`${base}/api/dev/theme${query}`, {
|
|
237
|
+
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
238
|
+
});
|
|
133
239
|
if (!res.ok) throw new Error(await errorText(res));
|
|
134
|
-
|
|
240
|
+
const { files: remote = {} } = await res.json();
|
|
241
|
+
const local = readLocalTemplates(dir);
|
|
242
|
+
const added = [];
|
|
243
|
+
const changed = [];
|
|
244
|
+
for (const [key, content] of Object.entries(local)) {
|
|
245
|
+
if (!(key in remote)) added.push(key);
|
|
246
|
+
else if (remote[key] !== content) changed.push(key);
|
|
247
|
+
}
|
|
248
|
+
const removed = Object.keys(remote).filter((k) => !(k in local));
|
|
249
|
+
return { added: added.sort(), changed: changed.sort(), removed: removed.sort() };
|
|
135
250
|
}
|
|
136
251
|
|
|
137
252
|
/**
|
|
@@ -150,6 +265,22 @@ export async function publishInstance({ url, token, instanceId }) {
|
|
|
150
265
|
return res.json();
|
|
151
266
|
}
|
|
152
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Bir tema instance'ının adını değiştir (`POST /api/dev/theme/rename`). Ad yalnızca
|
|
270
|
+
* bir etiket — canlı instance dahil sahip olunan her instance yeniden adlandırılabilir.
|
|
271
|
+
* `{ ok, id, name }` döner.
|
|
272
|
+
*/
|
|
273
|
+
export async function renameInstance({ url, token, instance, name }) {
|
|
274
|
+
const base = url.replace(/\/+$/, "");
|
|
275
|
+
const res = await fetch(`${base}/api/dev/theme/rename`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
278
|
+
body: JSON.stringify({ instance, name }),
|
|
279
|
+
});
|
|
280
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
281
|
+
return res.json();
|
|
282
|
+
}
|
|
283
|
+
|
|
153
284
|
/**
|
|
154
285
|
* Site sağlık/durum özeti (`GET /api/dev/site`) — `blocofy status`. Canlı tema instance'ı,
|
|
155
286
|
* instance-başına sayfa dağılımı, taslaklar ve health döner.
|
package/lib/validate.mjs
CHANGED