@blocofy/cli 0.6.0 → 0.8.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 +45 -1
- package/bin/blocofy.mjs +261 -8
- package/lib/args.mjs +5 -2
- package/lib/credentials.mjs +57 -15
- package/lib/media-uses.mjs +82 -0
- package/lib/secret-prompt.mjs +39 -0
- package/lib/theme-sync.mjs +35 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,15 +24,56 @@ blocofy theme push [dir] # Write the local theme to a DRAFT by default (create/
|
|
|
24
24
|
# delete) — preview & publish from the admin panel. --live writes
|
|
25
25
|
# to the live site immediately (asks to confirm; add --yes for CI).
|
|
26
26
|
# --name <name> names the new draft (draft mode only).
|
|
27
|
+
# --prune also removes target files deleted locally (lists them
|
|
28
|
+
# first; on the live theme asks to confirm — --yes for CI).
|
|
27
29
|
blocofy theme rename <handle> <new name>
|
|
28
30
|
# Rename a theme (label only). Works on any theme, live included.
|
|
29
31
|
# Handle from the panel theme card or `blocofy status`.
|
|
32
|
+
blocofy login --api-key [--api-url <url>]
|
|
33
|
+
# Save a v1 API key (blcf_live_…) for the `pages media-*` commands.
|
|
34
|
+
# The flag takes NO value: the key is typed into a hidden prompt, so
|
|
35
|
+
# it never lands in argv or shell history. Non-interactive shells use
|
|
36
|
+
# BLOCOFY_API_KEY + BLOCOFY_API_URL instead (no prompt, nothing
|
|
37
|
+
# written without them). --api-url defaults to https://app.blocofy.com.
|
|
38
|
+
blocofy pages media-uses <page-handle> [--json]
|
|
39
|
+
# List a page's localized-media decisions on its newest draft
|
|
40
|
+
# (v1 API, pages:read) with the draft revision id/version.
|
|
41
|
+
blocofy pages media-decide <page-handle> --decisions <file.json>
|
|
42
|
+
[--expected-revision-id <n> --expected-version <n>] [--json]
|
|
43
|
+
# Apply the file's { "decisions": [...] } (max 20) to the draft
|
|
44
|
+
# atomically (v1 API, pages:write). Without the --expected-* pair
|
|
45
|
+
# the CLI GETs the draft first and uses its current revision;
|
|
46
|
+
# items without idempotency_key get a random UUID. Exit 0 applied
|
|
47
|
+
# (or "No changes" when already recorded), 1 usage/auth/network/5xx
|
|
48
|
+
# (no retry), 2 server refusal (4xx) — {error} JSON on stderr.
|
|
30
49
|
blocofy --version
|
|
31
50
|
blocofy --help
|
|
32
51
|
```
|
|
33
52
|
|
|
34
53
|
## Changelog
|
|
35
54
|
|
|
55
|
+
- **0.8.0** — Page media decisions over the public v1 API: `pages media-uses <page-handle>` lists a
|
|
56
|
+
page's localized-media decisions on its newest draft, and `pages media-decide <page-handle>
|
|
57
|
+
--decisions <file.json>` applies a batch of decisions to that draft atomically (all or nothing;
|
|
58
|
+
a replayed batch answers "No changes"). Both use a **v1 API key** (`blcf_live_…`, scopes
|
|
59
|
+
`pages:read` / `pages:write`) — the `bcf_` dev token is not accepted. `blocofy login --api-key`
|
|
60
|
+
stores the key from a **hidden prompt**; the flag deliberately takes no value so the key never
|
|
61
|
+
enters argv or shell history, and a non-interactive shell without `BLOCOFY_API_KEY` +
|
|
62
|
+
`BLOCOFY_API_URL` exits without writing. The API pair lives next to the dev pair in
|
|
63
|
+
`~/.blocofy/credentials.json` (still 0600): logging in one way keeps the other. Exit codes for
|
|
64
|
+
the new commands: 0 success, 1 usage/auth/network/5xx (no automatic retry), 2 server refusal
|
|
65
|
+
(4xx, with the server's `{error}` JSON on stderr). Existing commands and the dev-token flow are
|
|
66
|
+
unchanged.
|
|
67
|
+
|
|
68
|
+
- **0.7.0** — `theme push --prune`: files that exist on the platform but no longer exist locally are
|
|
69
|
+
removed. Until now a push merged remote-only files back into the upload, so a file deleted locally
|
|
70
|
+
stayed on the theme forever. The list is printed before anything is written; pruning the LIVE theme
|
|
71
|
+
(`--live`, or `--instance` pointing at the live theme) asks for confirmation or `--yes`, and a
|
|
72
|
+
non-interactive shell without `--yes` exits without writing. Removal goes through the platform's atomic
|
|
73
|
+
deploy, so it is revision-tracked. A draft push also no longer creates a draft as a side effect of its
|
|
74
|
+
pre-push remote read: the CLI probes the existing CLI draft by handle and skips the probe when there is
|
|
75
|
+
none. Previously a failure during that read left an empty draft behind and printed a bare `HTTP 500`.
|
|
76
|
+
|
|
36
77
|
- **0.6.0** — `theme dev`'s local view finally shows what you are working on. It used to render your
|
|
37
78
|
DRAFT theme against the site's LIVE page content, so draft page documents were invisible; each render
|
|
38
79
|
now carries the draft theme instance and the platform resolves that instance's pages. Three failures
|
|
@@ -83,7 +124,10 @@ editor — without affecting your published theme. Save a file and **every open
|
|
|
83
124
|
the draft from the theme editor when you're ready.
|
|
84
125
|
|
|
85
126
|
Credentials come from `~/.blocofy/credentials.json` (written by `blocofy login`) or the
|
|
86
|
-
`BLOCOFY_URL` + `BLOCOFY_TOKEN` environment variables (for CI/automation).
|
|
127
|
+
`BLOCOFY_URL` + `BLOCOFY_TOKEN` environment variables (for CI/automation). The v1 API commands
|
|
128
|
+
(`pages media-uses`, `pages media-decide`) use a separate `blcf_live_…` key from the same file
|
|
129
|
+
(written by `blocofy login --api-key`) or `BLOCOFY_API_KEY` + `BLOCOFY_API_URL` — both variables
|
|
130
|
+
together; with only one set the CLI fails instead of falling back to the file.
|
|
87
131
|
|
|
88
132
|
## Theme structure
|
|
89
133
|
|
package/bin/blocofy.mjs
CHANGED
|
@@ -9,17 +9,19 @@
|
|
|
9
9
|
* livereload. No monorepo required.
|
|
10
10
|
*/
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { existsSync } from "node:fs";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
15
|
import { createInterface } from "node:readline/promises";
|
|
16
16
|
|
|
17
17
|
import { parseArgs } from "../lib/args.mjs";
|
|
18
18
|
import { pullContent, pushContent } from "../lib/content-sync.mjs";
|
|
19
|
-
import { credentialsPath, loadCredentials, saveCredentials } from "../lib/credentials.mjs";
|
|
19
|
+
import { credentialsPath, loadApiCredentials, loadCredentials, saveCredentials } from "../lib/credentials.mjs";
|
|
20
20
|
import { startDevServer } from "../lib/dev-server.mjs";
|
|
21
21
|
import { readLocalTemplates } from "../lib/local-theme.mjs";
|
|
22
|
+
import { CliRefusal, DEFAULT_API_URL, decidePageMediaUses, fetchPageMediaUses, isValidApiKey } from "../lib/media-uses.mjs";
|
|
22
23
|
import { githubNote, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
|
|
24
|
+
import { promptSecret } from "../lib/secret-prompt.mjs";
|
|
23
25
|
import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
|
|
24
26
|
import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
|
|
25
27
|
import { hyperlink, openUrl } from "../lib/term.mjs";
|
|
@@ -32,10 +34,13 @@ const args = process.argv.slice(2);
|
|
|
32
34
|
// bayrak YAZIMSIZ çıkışla reddedilir (eskiden sonraki token'ı değer olarak yutup hedef dizini sessizce
|
|
33
35
|
// kaydırıyordu). `--confirm` ve `--dry` belgelenmemiş ama gerçek bayraklardır; `--dry` (theme dev'in
|
|
34
36
|
// sunucu-başlatmama kancası) `--dry-run`'dan (sunucu-tarafı doğrulama) FARKLIDIR, alias değildir.
|
|
37
|
+
// 0.8.0: `login --api-key` DEĞERSİZ bayraktır (parser'da boolean) — sır gizli prompt'tan ya da
|
|
38
|
+
// BLOCOFY_API_KEY'den gelir, argv'ye asla girmez. `pages media-uses` / `media-decide` v1 API komutlarıdır.
|
|
35
39
|
const KNOWN = {
|
|
36
|
-
login: ["url", "token"],
|
|
40
|
+
login: ["url", "token", "api-key", "api-url"],
|
|
41
|
+
pages: ["decisions", "expected-revision-id", "expected-version", "json"],
|
|
37
42
|
themePull: ["draft", "instance"],
|
|
38
|
-
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key"],
|
|
43
|
+
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
|
|
39
44
|
themeDev: ["port", "dry", "no-sync", "name"],
|
|
40
45
|
themePublish: ["instance"],
|
|
41
46
|
themeRename: ["name"],
|
|
@@ -66,6 +71,13 @@ Usage
|
|
|
66
71
|
Save your platform URL + dev token to ~/.blocofy/credentials.json.
|
|
67
72
|
Get a token from the admin panel → Settings → Theme CLI tokens.
|
|
68
73
|
|
|
74
|
+
blocofy login --api-key [--api-url <url>]
|
|
75
|
+
Save a v1 API key (blcf_live_…) for the \`pages media-*\` commands. The key is read
|
|
76
|
+
from a HIDDEN prompt — the flag takes no value, so the key never lands in your shell
|
|
77
|
+
history. Kept alongside the dev token in the same credentials file.
|
|
78
|
+
--api-url <url> API origin (default https://app.blocofy.com)
|
|
79
|
+
Non-interactive shells: set BLOCOFY_API_KEY + BLOCOFY_API_URL instead.
|
|
80
|
+
|
|
69
81
|
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
|
|
70
82
|
Start a dev server and print 3 auto-reloading views — Local, live-domain
|
|
71
83
|
Preview, and the theme Editor. Press l / p / e to open each, q to quit.
|
|
@@ -81,7 +93,7 @@ Usage
|
|
|
81
93
|
--instance <handle> pull a specific theme by its handle (from the admin
|
|
82
94
|
panel theme card, or \`blocofy status\`)
|
|
83
95
|
|
|
84
|
-
blocofy theme push [dir] [--live] [--yes] [--instance <handle>]
|
|
96
|
+
blocofy theme push [dir] [--live] [--yes] [--instance <handle>] [--prune]
|
|
85
97
|
By DEFAULT writes to a DRAFT theme (create/update; no delete) — preview & publish
|
|
86
98
|
it from the admin panel, never touching the live site. Publish it with
|
|
87
99
|
'blocofy theme publish'.
|
|
@@ -96,6 +108,8 @@ Usage
|
|
|
96
108
|
--validate alias for --dry-run (validate only, nothing written)
|
|
97
109
|
--diff show what a push WOULD change vs the target (read-only), then stop
|
|
98
110
|
--idempotency-key <k> attach an idempotency key so a retried push is not double-applied
|
|
111
|
+
--prune also REMOVE target files that no longer exist locally (lists them first;
|
|
112
|
+
on the live theme asks to confirm — non-interactive shells must add --yes)
|
|
99
113
|
|
|
100
114
|
blocofy theme rename <handle> <new name>
|
|
101
115
|
Rename a theme (the name is just a label). Works on any of your themes,
|
|
@@ -116,6 +130,21 @@ Usage
|
|
|
116
130
|
push: write pages/*.json to the site. Updates EXISTING pages only —
|
|
117
131
|
never creates or deletes a page; unchanged pages are skipped.
|
|
118
132
|
|
|
133
|
+
blocofy pages media-uses <page-handle> [--json]
|
|
134
|
+
List a page's localized-media decisions on its newest DRAFT (v1 API, pages:read).
|
|
135
|
+
Prints the draft's revision id/version needed by media-decide. --json: raw response.
|
|
136
|
+
|
|
137
|
+
blocofy pages media-decide <page-handle> --decisions <file.json>
|
|
138
|
+
[--expected-revision-id <n> --expected-version <n>] [--json]
|
|
139
|
+
Apply one or more media decisions to the page's draft atomically (v1 API, pages:write).
|
|
140
|
+
The file is { "decisions": [ { path, facet, decision, target_asset?, alt?, caption?,
|
|
141
|
+
decorative?, idempotency_key?, witness? } ] } (max 20). Without the two --expected-*
|
|
142
|
+
flags the CLI first GETs the draft and uses its current revision id/version; items
|
|
143
|
+
without an idempotency_key get a random UUID.
|
|
144
|
+
Exit codes: 0 applied (or "No changes" when every item was already recorded);
|
|
145
|
+
1 usage/auth/network/5xx (no retry); 2 the server refused (4xx) — the {error} JSON
|
|
146
|
+
is printed to stderr.
|
|
147
|
+
|
|
119
148
|
blocofy settings pull [dir] / settings push [dir]
|
|
120
149
|
Download / upload config/settings.json (theme tokens/settings + color schemes).
|
|
121
150
|
|
|
@@ -127,8 +156,12 @@ Examples
|
|
|
127
156
|
blocofy theme pull && blocofy theme dev
|
|
128
157
|
blocofy theme push && blocofy theme publish
|
|
129
158
|
blocofy status
|
|
159
|
+
blocofy login --api-key
|
|
160
|
+
blocofy pages media-uses pg_abc123 --json
|
|
161
|
+
blocofy pages media-decide pg_abc123 --decisions decisions.json
|
|
130
162
|
|
|
131
163
|
Auth: ~/.blocofy/credentials.json (from \`login\`), or BLOCOFY_URL + BLOCOFY_TOKEN env vars.
|
|
164
|
+
v1 API (pages media-*): \`login --api-key\`, or BLOCOFY_API_KEY + BLOCOFY_API_URL env vars.
|
|
132
165
|
The CLI does not build assets — bring your own (npm/Vite/Tailwind); the platform serves
|
|
133
166
|
plain Liquid + static assets.`);
|
|
134
167
|
}
|
|
@@ -144,8 +177,60 @@ async function promptValid(rl, question, normalize, valid, hint, tries = 3) {
|
|
|
144
177
|
process.exit(1);
|
|
145
178
|
}
|
|
146
179
|
|
|
180
|
+
/**
|
|
181
|
+
* `blocofy login --api-key [--api-url <url>]` — v1 API key login (0.8.0, D3 §4.7).
|
|
182
|
+
* The key is NEVER taken from argv: a hidden prompt on a TTY, BLOCOFY_API_KEY otherwise.
|
|
183
|
+
* Non-TTY without the env var → message + exit 1, nothing written (fail-closed). No message
|
|
184
|
+
* printed by this function ever contains the key (not even a prefix).
|
|
185
|
+
*/
|
|
186
|
+
async function loginApiKey(flags, positionals) {
|
|
187
|
+
if (positionals.length > 0 || flags.url !== undefined || flags.token !== undefined) {
|
|
188
|
+
console.error("`login --api-key` takes no value and no --url/--token: the API key is read from a hidden prompt (or BLOCOFY_API_KEY). Nothing was written.");
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
let apiUrl;
|
|
192
|
+
if (typeof flags["api-url"] === "string") apiUrl = normalizeUrl(flags["api-url"]);
|
|
193
|
+
else if (process.env.BLOCOFY_API_URL) apiUrl = normalizeUrl(process.env.BLOCOFY_API_URL);
|
|
194
|
+
else apiUrl = DEFAULT_API_URL;
|
|
195
|
+
if (!isValidUrl(apiUrl)) {
|
|
196
|
+
console.error("Invalid --api-url — must be a valid http(s):// URL. Nothing was written.");
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let apiKey = null;
|
|
201
|
+
if (process.stdin.isTTY) {
|
|
202
|
+
apiKey = await promptSecret("API key (blcf_live_…, hidden): ");
|
|
203
|
+
if (apiKey === null) {
|
|
204
|
+
console.error("Cancelled. Nothing was written.");
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
apiKey = apiKey.trim();
|
|
208
|
+
} else if (process.env.BLOCOFY_API_KEY) {
|
|
209
|
+
apiKey = process.env.BLOCOFY_API_KEY.trim();
|
|
210
|
+
} else {
|
|
211
|
+
console.error("Non-interactive shell: `login --api-key` needs a terminal for the hidden prompt. Set BLOCOFY_API_KEY (+ BLOCOFY_API_URL) instead. Nothing was written.");
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
if (!isValidApiKey(apiKey)) {
|
|
215
|
+
console.error("Invalid API key — a v1 key starts with blcf_live_ (a bcf_ dev token is not accepted for the v1 API). Nothing was written.");
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
saveCredentials({ apiUrl, apiKey });
|
|
220
|
+
console.log(`✓ API key saved → ${credentialsPath()} (API: ${apiUrl})`);
|
|
221
|
+
console.log("Next: blocofy pages media-uses <page-handle>");
|
|
222
|
+
}
|
|
223
|
+
|
|
147
224
|
async function login(rest) {
|
|
148
|
-
const { flags } = parseArgsOrExit(rest, KNOWN.login);
|
|
225
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.login);
|
|
226
|
+
if (flags["api-key"] || flags["api-url"] !== undefined) {
|
|
227
|
+
if (!flags["api-key"]) {
|
|
228
|
+
console.error("--api-url is only used with --api-key. Nothing was written.");
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
await loginApiKey(flags, positionals);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
149
234
|
let url = typeof flags.url === "string" ? normalizeUrl(flags.url) : "";
|
|
150
235
|
let token = typeof flags.token === "string" ? flags.token.trim() : "";
|
|
151
236
|
|
|
@@ -261,7 +346,7 @@ async function themePush(rest) {
|
|
|
261
346
|
}
|
|
262
347
|
for (const k of d.added) console.log(` + ${k}`);
|
|
263
348
|
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)`);
|
|
349
|
+
for (const k of d.removed) console.log(` - ${k} (present on target, absent locally — ${flags.prune ? "--prune removes it" : "push does not delete"})`);
|
|
265
350
|
console.log(`\n${d.added.length} added, ${d.changed.length} changed, ${d.removed.length} remote-only.`);
|
|
266
351
|
return;
|
|
267
352
|
}
|
|
@@ -279,8 +364,10 @@ async function themePush(rest) {
|
|
|
279
364
|
// yanlış-tenant'a yazımı görünür kılar ("klarosa sandım, ksc'ye yazdım"). whoami
|
|
280
365
|
// yoksa/erişilemezse sessiz geç; etiketi confirmation mesajlarında da kullan.
|
|
281
366
|
let site = null;
|
|
367
|
+
let whoami = null;
|
|
282
368
|
try {
|
|
283
|
-
|
|
369
|
+
whoami = await fetchWhoami({ url: creds.url, token: creds.token });
|
|
370
|
+
site = whoami.site;
|
|
284
371
|
} catch {
|
|
285
372
|
/* best-effort */
|
|
286
373
|
}
|
|
@@ -322,6 +409,34 @@ async function themePush(rest) {
|
|
|
322
409
|
}
|
|
323
410
|
}
|
|
324
411
|
|
|
412
|
+
// PS-13 `--prune`: canlı temadan dosya SİLER. Hedef canlıysa (`--live` ya da canlı temanın handle'ı
|
|
413
|
+
// verilmiş `--instance`; whoami çözülemediyse canlı sayılır) canlı-push onay kuralı aynen uygulanır:
|
|
414
|
+
// `--yes`/`--confirm` → onaylı, TTY → liste basıldıktan sonra y/N, non-TTY → yazımsız çıkış.
|
|
415
|
+
const prune = Boolean(flags.prune) && !dryRun;
|
|
416
|
+
const liveTarget = mode === "live" || (mode === "instance" && (whoami == null || String(instance) === String(whoami.liveThemeId)));
|
|
417
|
+
const pruneDecision = livePushDecision({
|
|
418
|
+
draft: !prune || !liveTarget,
|
|
419
|
+
yes: Boolean(flags.yes),
|
|
420
|
+
confirm: Boolean(flags.confirm),
|
|
421
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
422
|
+
});
|
|
423
|
+
if (pruneDecision.mustAbort) {
|
|
424
|
+
console.error(`⚠ 'theme push --prune' removes files from the LIVE theme of ${target}.`);
|
|
425
|
+
console.error(` Non-interactive shell: pass --prune --yes to confirm. Nothing was written.`);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
const confirmPrune = async (keys) => {
|
|
429
|
+
console.log(`--prune will remove ${keys.length} file(s) absent locally:`);
|
|
430
|
+
for (const k of keys) console.log(` - ${k}`);
|
|
431
|
+
if (!pruneDecision.needsPrompt) return true;
|
|
432
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
433
|
+
try {
|
|
434
|
+
return isAffirmative(await rl.question(`⚠ Remove these files from the LIVE theme of ${target}? [y/N] `));
|
|
435
|
+
} finally {
|
|
436
|
+
rl.close();
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
325
440
|
// `--name` yalnızca YENİ taslak yaratırken (draft modu) anlamlı — canlıya/mevcut
|
|
326
441
|
// instance'a yazarken ad kaydedilmez, sessizce kaybolmasın diye açıkça uyar.
|
|
327
442
|
if (name && mode !== "draft") {
|
|
@@ -351,6 +466,8 @@ async function themePush(rest) {
|
|
|
351
466
|
dryRun,
|
|
352
467
|
idempotencyKey,
|
|
353
468
|
onRetry: (info) => console.error(retryNotice(info)),
|
|
469
|
+
prune,
|
|
470
|
+
confirmPrune,
|
|
354
471
|
});
|
|
355
472
|
} catch (error) {
|
|
356
473
|
if (error?.code === "cli_upgrade_required") {
|
|
@@ -367,6 +484,11 @@ async function themePush(rest) {
|
|
|
367
484
|
throw error;
|
|
368
485
|
}
|
|
369
486
|
|
|
487
|
+
if (result.aborted) {
|
|
488
|
+
console.error("Aborted. Nothing was written.");
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
|
|
370
492
|
// --dry-run / --validate: the server validated without writing. Report and stop.
|
|
371
493
|
if (result.dryRun) {
|
|
372
494
|
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
@@ -380,6 +502,9 @@ async function themePush(rest) {
|
|
|
380
502
|
if (Array.isArray(result.remoteOnlyKept) && result.remoteOnlyKept.length) {
|
|
381
503
|
console.log(` (kept ${result.remoteOnlyKept.length} remote-only file(s) — push does not delete)`);
|
|
382
504
|
}
|
|
505
|
+
if (Array.isArray(result.remoteOnlyRemoved) && result.remoteOnlyRemoved.length) {
|
|
506
|
+
console.log(` (removed ${result.remoteOnlyRemoved.length} file(s) absent locally — --prune)`);
|
|
507
|
+
}
|
|
383
508
|
console.log(`✓ Deployed atomically: deployment #${result.deploymentId}, revision #${result.sourceRevisionId}, pointer v${result.pointerVersion}.`);
|
|
384
509
|
if (mode === "draft") {
|
|
385
510
|
console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
|
|
@@ -439,6 +564,130 @@ async function contentPull(scope, rest) {
|
|
|
439
564
|
console.log(`Downloaded ${count} ${scope === "settings" ? "settings" : "page"} file(s) → ${dir}`);
|
|
440
565
|
}
|
|
441
566
|
|
|
567
|
+
/** Resolve the v1 API pair (blcf_live_ key only) or exit 1 with guidance. Never prints the key. */
|
|
568
|
+
function requireApiCreds() {
|
|
569
|
+
let creds;
|
|
570
|
+
try {
|
|
571
|
+
creds = loadApiCredentials();
|
|
572
|
+
} catch (error) {
|
|
573
|
+
console.error(error?.message ?? error);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
if (!creds) {
|
|
577
|
+
console.error("API key required: run `blocofy login --api-key` (or set BLOCOFY_API_KEY + BLOCOFY_API_URL). The dev token (bcf_) is not accepted for the v1 API.");
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
if (!isValidApiKey(creds.apiKey)) {
|
|
581
|
+
console.error(`The stored API key (${creds.source}) is not a v1 key — it must start with blcf_live_. Run \`blocofy login --api-key\`.`);
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
return creds;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** v1 refusal (4xx) → {error} JSON on stderr, exit 2; anything else → message, exit 1. */
|
|
588
|
+
function exitForApiError(error) {
|
|
589
|
+
if (error instanceof CliRefusal) {
|
|
590
|
+
console.error(JSON.stringify({ error: error.error }));
|
|
591
|
+
process.exit(2);
|
|
592
|
+
}
|
|
593
|
+
console.error(error?.message ?? error);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function printMediaUsesView(view) {
|
|
598
|
+
if (view.applicable === false) {
|
|
599
|
+
console.log(`Page ${view.page?.id}: media decisions not applicable (${view.reason}).`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const uses = Array.isArray(view.uses) ? view.uses : [];
|
|
603
|
+
console.log(`Page ${view.page?.id} — draft revision ${view.revision?.id} v${view.revision?.version} — ${view.locale} ← ${view.source_locale}`);
|
|
604
|
+
console.log(`${view.counts?.total ?? uses.length} use(s), ${view.counts?.blocked ?? 0} blocked`);
|
|
605
|
+
for (const u of uses) {
|
|
606
|
+
const marks = [u.stale ? "stale" : null, u.blocks ? "BLOCKS" : null, u.editable ? null : "read-only"].filter(Boolean);
|
|
607
|
+
const extra = marks.length ? ` [${marks.join(", ")}]` : "";
|
|
608
|
+
const reasons = Array.isArray(u.reasons) && u.reasons.length ? ` — ${u.reasons.join(", ")}` : "";
|
|
609
|
+
console.log(` ${u.decision.padEnd(9)} ${u.path} (${u.facet}, ${u.kind})${extra}${reasons}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function pagesMediaUses(rest) {
|
|
614
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
|
|
615
|
+
const page = positionals[0];
|
|
616
|
+
if (!page) {
|
|
617
|
+
console.error("Usage: blocofy pages media-uses <page-handle> [--json]");
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
const { apiUrl, apiKey } = requireApiCreds();
|
|
621
|
+
const view = await fetchPageMediaUses({ apiUrl, apiKey, page });
|
|
622
|
+
if (flags.json) console.log(JSON.stringify(view, null, 2));
|
|
623
|
+
else printMediaUsesView(view);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function parseExpected(flags, name) {
|
|
627
|
+
const raw = flags[name];
|
|
628
|
+
if (raw === undefined) return null;
|
|
629
|
+
const n = Number(raw);
|
|
630
|
+
if (typeof raw !== "string" || !Number.isSafeInteger(n) || n < 0) {
|
|
631
|
+
console.error(`--${name} must be a non-negative integer.`);
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
return n;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function pagesMediaDecide(rest) {
|
|
638
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
|
|
639
|
+
const page = positionals[0];
|
|
640
|
+
const file = typeof flags.decisions === "string" ? resolve(flags.decisions) : null;
|
|
641
|
+
if (!page || !file) {
|
|
642
|
+
console.error("Usage: blocofy pages media-decide <page-handle> --decisions <file.json> [--expected-revision-id <n> --expected-version <n>] [--json]");
|
|
643
|
+
process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
let parsed;
|
|
646
|
+
try {
|
|
647
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
648
|
+
} catch (error) {
|
|
649
|
+
console.error(`Cannot read decisions file ${file}: ${error?.message ?? error}`);
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.decisions) || parsed.decisions.length === 0) {
|
|
653
|
+
console.error(`Decisions file must be { "decisions": [ ... ] } with at least one item: ${file}`);
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
let expectedRevisionId = parseExpected(flags, "expected-revision-id");
|
|
657
|
+
let expectedVersion = parseExpected(flags, "expected-version");
|
|
658
|
+
if ((expectedRevisionId === null) !== (expectedVersion === null)) {
|
|
659
|
+
console.error("--expected-revision-id and --expected-version must be given together (or both omitted to use the current draft).");
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const { apiUrl, apiKey } = requireApiCreds();
|
|
664
|
+
if (expectedRevisionId === null) {
|
|
665
|
+
const view = await fetchPageMediaUses({ apiUrl, apiKey, page });
|
|
666
|
+
if (view.applicable === false) {
|
|
667
|
+
console.error(`Page ${page}: media decisions not applicable (${view.reason}). Nothing was written.`);
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
670
|
+
expectedRevisionId = view.revision.id;
|
|
671
|
+
expectedVersion = view.revision.version;
|
|
672
|
+
}
|
|
673
|
+
const decisions = parsed.decisions.map((item) =>
|
|
674
|
+
item && typeof item === "object" && typeof item.idempotency_key !== "string" ? { ...item, idempotency_key: randomUUID() } : item,
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
const out = await decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions });
|
|
678
|
+
if (flags.json) {
|
|
679
|
+
console.log(JSON.stringify(out, null, 2));
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (out.written === false) {
|
|
683
|
+
console.log("No changes — the decisions were already recorded (replay).");
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const applied = Array.isArray(out.applied) ? out.applied : [];
|
|
687
|
+
console.log(`✓ Applied ${applied.length} decision(s) → draft revision ${out.revision?.id} v${out.revision?.version}`);
|
|
688
|
+
for (const a of applied) console.log(` ${a.decision.padEnd(9)} ${a.path} (${a.facet})${a.replayed ? " [replayed]" : ""}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
442
691
|
async function themeDev(rest) {
|
|
443
692
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeDev);
|
|
444
693
|
const themeDir = resolve(positionals[0] ?? process.cwd());
|
|
@@ -713,6 +962,10 @@ if (first === "--version" || first === "-v") {
|
|
|
713
962
|
console.error(error?.message ?? error);
|
|
714
963
|
process.exit(1);
|
|
715
964
|
});
|
|
965
|
+
} else if (first === "pages" && rest[0] === "media-uses") {
|
|
966
|
+
pagesMediaUses(rest.slice(1)).catch(exitForApiError);
|
|
967
|
+
} else if (first === "pages" && rest[0] === "media-decide") {
|
|
968
|
+
pagesMediaDecide(rest.slice(1)).catch(exitForApiError);
|
|
716
969
|
} else if (first === "pages" && rest[0] === "pull") {
|
|
717
970
|
contentPull("pages", rest.slice(1)).catch((error) => {
|
|
718
971
|
console.error(error?.message ?? error);
|
package/lib/args.mjs
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* (tema dizini) sanılmaz. Boolean flag'ler sonraki token'ı YUTMAZ.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
|
|
9
|
+
* gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
|
|
10
|
+
*/
|
|
11
|
+
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json"]);
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
|
package/lib/credentials.mjs
CHANGED
|
@@ -8,20 +8,43 @@ import { join } from "node:path";
|
|
|
8
8
|
* variables (BLOCOFY_URL/BLOCOFY_TOKEN) take precedence over the file (CI/agents).
|
|
9
9
|
* The token is stored in plaintext on disk (the usual dev-CLI pattern), so the file
|
|
10
10
|
* is locked down to 0600.
|
|
11
|
+
*
|
|
12
|
+
* 0.8.0: the same file also holds the v1 API pair `{ apiUrl, apiKey }` written by
|
|
13
|
+
* `blocofy login --api-key`. The two pairs coexist: `saveCredentials(patch)` MERGES the
|
|
14
|
+
* patch into the existing JSON (an unreadable/corrupt file counts as `{}`), so an API login
|
|
15
|
+
* keeps the dev pair and a later dev login keeps the API pair. `loadCredentials()` (dev
|
|
16
|
+
* pair) is unchanged; `loadApiCredentials()` resolves the v1 pair (BLOCOFY_API_KEY +
|
|
17
|
+
* BLOCOFY_API_URL first, then the file).
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
|
-
const
|
|
14
|
-
const
|
|
20
|
+
const DIR_NAME = ".blocofy";
|
|
21
|
+
const FILE_NAME = "credentials.json";
|
|
22
|
+
|
|
23
|
+
// Resolved per call (not at import): tests redirect HOME to a temp dir, and os.homedir()
|
|
24
|
+
// honours $HOME on POSIX.
|
|
25
|
+
function dir() {
|
|
26
|
+
return join(homedir(), DIR_NAME);
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
export function credentialsPath() {
|
|
17
|
-
return
|
|
30
|
+
return join(dir(), FILE_NAME);
|
|
18
31
|
}
|
|
19
32
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
function readExisting() {
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(readFileSync(credentialsPath(), "utf8"));
|
|
36
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
37
|
+
} catch {
|
|
38
|
+
return {}; // missing/corrupt → start clean
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Merge `patch` into the credentials file and write it with 0600 permissions. */
|
|
43
|
+
export function saveCredentials(patch) {
|
|
44
|
+
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
45
|
+
const merged = { ...readExisting(), ...patch };
|
|
46
|
+
writeFileSync(credentialsPath(), JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
|
|
47
|
+
chmodSync(credentialsPath(), 0o600); // create-mode is ignored if the file already exists → enforce it
|
|
25
48
|
}
|
|
26
49
|
|
|
27
50
|
/**
|
|
@@ -34,13 +57,32 @@ export function loadCredentials() {
|
|
|
34
57
|
if (envUrl && envToken) {
|
|
35
58
|
return { url: stripSlash(envUrl), token: envToken, source: "env" };
|
|
36
59
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
const data = readExisting();
|
|
61
|
+
if (typeof data.url === "string" && typeof data.token === "string") {
|
|
62
|
+
return { url: stripSlash(data.url), token: data.token, source: "file" };
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the v1 API pair: BLOCOFY_API_KEY + BLOCOFY_API_URL (BOTH required — with only one
|
|
69
|
+
* set this throws and does NOT fall through to the file, so a half-configured CI job fails
|
|
70
|
+
* loudly instead of silently using a developer's file), then the file's `{ apiUrl, apiKey }`.
|
|
71
|
+
* Returns `{ apiUrl, apiKey, source }` or null. The thrown message never contains the key.
|
|
72
|
+
*/
|
|
73
|
+
export function loadApiCredentials() {
|
|
74
|
+
const envKey = process.env.BLOCOFY_API_KEY;
|
|
75
|
+
const envUrl = process.env.BLOCOFY_API_URL;
|
|
76
|
+
if (envKey && envUrl) {
|
|
77
|
+
return { apiUrl: stripSlash(envUrl), apiKey: envKey, source: "env" };
|
|
78
|
+
}
|
|
79
|
+
if (envKey || envUrl) {
|
|
80
|
+
const missing = envKey ? "BLOCOFY_API_URL" : "BLOCOFY_API_KEY";
|
|
81
|
+
throw new Error(`${missing} is not set — BLOCOFY_API_KEY and BLOCOFY_API_URL must be set together (the credentials file is not consulted while either is set).`);
|
|
82
|
+
}
|
|
83
|
+
const data = readExisting();
|
|
84
|
+
if (typeof data.apiUrl === "string" && typeof data.apiKey === "string") {
|
|
85
|
+
return { apiUrl: stripSlash(data.apiUrl), apiKey: data.apiKey, source: "file" };
|
|
44
86
|
}
|
|
45
87
|
return null;
|
|
46
88
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1 client for a page's localized-media decisions (D3, CLI 0.8.0):
|
|
3
|
+
* GET /api/v1/pages/{id}/media-uses (pages:read) → PageMediaUsesView | PageMediaUsesNotApplicable
|
|
4
|
+
* POST /api/v1/pages/{id}/media-uses (pages:write) → DecidePageMediaUsesOutput
|
|
5
|
+
*
|
|
6
|
+
* Plan: multisite-cms docs/architecture/plans/2026-09-16-d3-page-media-use-public-surfaces.md §4.5–4.7.
|
|
7
|
+
*
|
|
8
|
+
* Authentication is a `blcf_live_…` API key (Bearer). The dev `bcf_…` token is NOT accepted for v1.
|
|
9
|
+
*
|
|
10
|
+
* NO RETRIES. A decision batch is applied atomically on the server but a lost response is only
|
|
11
|
+
* safe to replay with the SAME idempotency keys, which is the caller's decision, not this
|
|
12
|
+
* client's — so a 5xx/network failure surfaces once and the command exits 1. A 4xx is a server
|
|
13
|
+
* REFUSAL (`CliRefusal`): the `{ error }` envelope is carried verbatim for the command to print
|
|
14
|
+
* as JSON on stderr with exit code 2.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const API_KEY_PREFIX = "blcf_live_";
|
|
18
|
+
export const DEFAULT_API_URL = "https://app.blocofy.com";
|
|
19
|
+
|
|
20
|
+
/** v1 accepts ONLY a live API key. Never echo the key back in a message. */
|
|
21
|
+
export function isValidApiKey(key) {
|
|
22
|
+
return typeof key === "string" && key.startsWith(API_KEY_PREFIX) && key.length > API_KEY_PREFIX.length;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A 4xx response from v1: `status` + the server's `error` object ({ code, message, details? }). */
|
|
26
|
+
export class CliRefusal extends Error {
|
|
27
|
+
constructor(status, error) {
|
|
28
|
+
super(`HTTP ${status} ${error?.code ?? ""}: ${error?.message ?? ""}`.trim());
|
|
29
|
+
this.name = "CliRefusal";
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.error = error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mediaUsesUrl(apiUrl, page) {
|
|
36
|
+
return `${String(apiUrl).replace(/\/+$/, "")}/api/v1/pages/${encodeURIComponent(page)}/media-uses`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function request(method, { apiUrl, apiKey, page }, body) {
|
|
40
|
+
const headers = { authorization: `Bearer ${apiKey}`, accept: "application/json" };
|
|
41
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
42
|
+
const res = await fetch(mediaUsesUrl(apiUrl, page), {
|
|
43
|
+
method,
|
|
44
|
+
headers,
|
|
45
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
const text = await res.text();
|
|
48
|
+
let json;
|
|
49
|
+
try {
|
|
50
|
+
json = text ? JSON.parse(text) : null;
|
|
51
|
+
} catch {
|
|
52
|
+
json = null;
|
|
53
|
+
}
|
|
54
|
+
if (res.ok) {
|
|
55
|
+
if (json === null) throw new Error(`HTTP ${res.status}: empty or non-JSON response`);
|
|
56
|
+
return json;
|
|
57
|
+
}
|
|
58
|
+
if (res.status >= 400 && res.status < 500) {
|
|
59
|
+
const error = json?.error && typeof json.error === "object" ? json.error : { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` };
|
|
60
|
+
throw new CliRefusal(res.status, error);
|
|
61
|
+
}
|
|
62
|
+
// 5xx (no retry) — the message never includes the request headers.
|
|
63
|
+
const detail = json?.error?.message ?? (text ? text.slice(0, 200) : "");
|
|
64
|
+
throw new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** GET the page's media-use view (the newest draft's `revision{id,version}` is inside). */
|
|
68
|
+
export function fetchPageMediaUses({ apiUrl, apiKey, page }) {
|
|
69
|
+
return request("GET", { apiUrl, apiKey, page });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* POST a decision batch. The body is exactly `{ expected_revision_id, expected_version, decisions }`
|
|
74
|
+
* — items are sent as given (idempotency keys are the caller's responsibility).
|
|
75
|
+
*/
|
|
76
|
+
export function decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions }) {
|
|
77
|
+
return request(
|
|
78
|
+
"POST",
|
|
79
|
+
{ apiUrl, apiKey, page },
|
|
80
|
+
{ expected_revision_id: expectedRevisionId, expected_version: expectedVersion, decisions },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hidden (non-echoing) secret prompt for `blocofy login --api-key` (0.8.0, D3 §4.7).
|
|
3
|
+
*
|
|
4
|
+
* Reads one line from a TTY in raw mode so the key never echoes to the terminal (and so it is
|
|
5
|
+
* never in argv or shell history — the flag takes no value). On a non-TTY stdin it resolves
|
|
6
|
+
* `null` WITHOUT reading anything: the caller must then use BLOCOFY_API_KEY or fail closed.
|
|
7
|
+
* Backspace edits, Enter finishes, Ctrl-C / Ctrl-D (empty) cancel with `null`. Raw mode is
|
|
8
|
+
* always restored.
|
|
9
|
+
*/
|
|
10
|
+
export function promptSecret(question, { input = process.stdin, output = process.stdout } = {}) {
|
|
11
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") return Promise.resolve(null);
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let buffer = "";
|
|
14
|
+
const finish = (value) => {
|
|
15
|
+
input.removeListener("data", onData);
|
|
16
|
+
input.setRawMode(false);
|
|
17
|
+
input.pause();
|
|
18
|
+
output.write("\n");
|
|
19
|
+
resolve(value);
|
|
20
|
+
};
|
|
21
|
+
const onData = (chunk) => {
|
|
22
|
+
for (const ch of String(chunk)) {
|
|
23
|
+
if (ch === "\r" || ch === "\n") return finish(buffer);
|
|
24
|
+
if (ch === "") return finish(null); // Ctrl-C
|
|
25
|
+
if (ch === "") return finish(buffer.length ? buffer : null); // Ctrl-D
|
|
26
|
+
if (ch === "" || ch === "\b") {
|
|
27
|
+
buffer = buffer.slice(0, -1);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
buffer += ch;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
output.write(question);
|
|
34
|
+
input.setRawMode(true);
|
|
35
|
+
input.setEncoding("utf8");
|
|
36
|
+
input.resume();
|
|
37
|
+
input.on("data", onData);
|
|
38
|
+
});
|
|
39
|
+
}
|
package/lib/theme-sync.mjs
CHANGED
|
@@ -183,11 +183,12 @@ export async function fetchDevSession({ url, token, name = null }) {
|
|
|
183
183
|
* writes to a draft theme instance instead of the live theme — preview & publish
|
|
184
184
|
* it from the admin panel without affecting the live site.
|
|
185
185
|
*/
|
|
186
|
-
export async function pushTheme({ dir, url, token, draft = false, instance = null, name = null, dryRun = false, idempotencyKey = null, onRetry = null }) {
|
|
186
|
+
export async function pushTheme({ dir, url, token, draft = false, instance = null, name = null, dryRun = false, idempotencyKey = null, onRetry = null, prune = false, confirmPrune = null }) {
|
|
187
187
|
const base = url.replace(/\/+$/, "");
|
|
188
188
|
const files = readLocalTemplates(dir);
|
|
189
189
|
const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
|
|
190
190
|
const remoteOnlyKept = [];
|
|
191
|
+
const remoteOnlyRemoved = [];
|
|
191
192
|
if (idempotencyKey && !dryRun) {
|
|
192
193
|
// 0.5.0 kanonik push üç aşamadır (denetim düzeltmeleri):
|
|
193
194
|
// 1) PREFLIGHT dry-run POST (yalnız yerel küme): 422/426 sınıfı hatalar SUNUCUDA HİÇBİR ŞEY
|
|
@@ -195,8 +196,12 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
195
196
|
// arkada instance + sayfa klonu bırakıyordu.
|
|
196
197
|
// 2) MERGE PROBE GET: "push silmez" garantisi — uzakta olup yerelde olmayan, kapı-kabul-eden
|
|
197
198
|
// dosyalar payload'a aynen eklenir; kapı-dışı satırları (README.md vb.) sunucunun
|
|
198
|
-
// retained-rows kuralı korur.
|
|
199
|
-
//
|
|
199
|
+
// retained-rows kuralı korur. `--prune` bu dosyaları EKLEMEZ (kanonik deploy klasörleri
|
|
200
|
+
// atomik değiştirir → silinirler) ve yazımdan ÖNCE `confirmPrune(liste)`'ye bildirir.
|
|
201
|
+
// PS-09: taslak hedefte taslak-bayraklı GET KULLANILMAZ — sunucuda taslak provizyonlar (yan
|
|
202
|
+
// etki; yarıda kalırsa boş taslak bırakırdı). Mevcut CLI taslağı `/api/dev/site`'tan
|
|
203
|
+
// (`source === "import"`, sunucunun ensureDraftInstance seçimiyle aynı: id sıralı ilk) bulunup
|
|
204
|
+
// `?instance=` ile yoklanır; yoksa yoklanacak uzak dosya da yoktur → probe atlanır.
|
|
200
205
|
// 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
|
|
201
206
|
const pre = await fetchWithRetry(
|
|
202
207
|
`${base}/api/dev/theme`,
|
|
@@ -205,25 +210,37 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
205
210
|
);
|
|
206
211
|
if (!pre.ok) throw await httpError(pre);
|
|
207
212
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (!
|
|
215
|
-
|
|
213
|
+
let probeInstance = instance;
|
|
214
|
+
if (!instance && draft) {
|
|
215
|
+
const status = await fetchSiteStatus({ url, token });
|
|
216
|
+
probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
|
|
217
|
+
}
|
|
218
|
+
let remote = {};
|
|
219
|
+
if (probeInstance || !draft) {
|
|
220
|
+
const query = probeInstance ? `?instance=${encodeURIComponent(probeInstance)}` : "";
|
|
221
|
+
const probe = await fetch(`${base}/api/dev/theme${query}`, { headers: headersFor() });
|
|
222
|
+
if (!probe.ok) throw await httpError(probe);
|
|
223
|
+
remote = (await probe.json()).files ?? {};
|
|
224
|
+
}
|
|
216
225
|
for (const [key, content] of Object.entries(remote)) {
|
|
217
226
|
if (key in files) continue;
|
|
218
227
|
const top = key.split("/")[0] ?? "";
|
|
219
228
|
// Sunucu kapı-kabul kümesinin birebir aynası: THEME_DIRS altı ÇOK-SEGMENTLİ path'ler + tek
|
|
220
229
|
// config istisnası (çıplak `layout` gibi slash'sız bir uzak anahtar sunucuda 422 olurdu).
|
|
221
230
|
if ((key.includes("/") && THEME_DIRS.has(top)) || key === "config/settings_schema.json") {
|
|
222
|
-
|
|
223
|
-
|
|
231
|
+
if (prune) {
|
|
232
|
+
remoteOnlyRemoved.push(key);
|
|
233
|
+
} else {
|
|
234
|
+
files[key] = typeof content === "string" ? content : "";
|
|
235
|
+
remoteOnlyKept.push(key);
|
|
236
|
+
}
|
|
224
237
|
}
|
|
225
238
|
}
|
|
226
239
|
remoteOnlyKept.sort();
|
|
240
|
+
remoteOnlyRemoved.sort();
|
|
241
|
+
if (remoteOnlyRemoved.length && confirmPrune && !(await confirmPrune(remoteOnlyRemoved))) {
|
|
242
|
+
return { aborted: true, remoteOnlyRemoved };
|
|
243
|
+
}
|
|
227
244
|
}
|
|
228
245
|
const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
|
|
229
246
|
// dryRun asks the server to validate (auth + snapshot + hostile-Liquid worker) WITHOUT writing.
|
|
@@ -237,7 +254,11 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
237
254
|
);
|
|
238
255
|
if (!res.ok) throw await httpError(res);
|
|
239
256
|
const json = await res.json();
|
|
240
|
-
return
|
|
257
|
+
return {
|
|
258
|
+
...json,
|
|
259
|
+
...(remoteOnlyKept.length ? { remoteOnlyKept } : {}),
|
|
260
|
+
...(remoteOnlyRemoved.length ? { remoteOnlyRemoved } : {}),
|
|
261
|
+
};
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
/**
|