@blocofy/cli 0.10.0 → 0.12.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 +208 -20
- package/bin/blocofy.mjs +424 -43
- package/lib/args.mjs +2 -1
- package/lib/http.mjs +4 -1
- package/lib/output.mjs +23 -4
- package/lib/site-state.mjs +33 -10
- package/lib/target.mjs +71 -13
- package/lib/theme-sync.mjs +163 -63
- package/lib/translations.mjs +1059 -0
- package/package.json +1 -1
package/bin/blocofy.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* livereload. No monorepo required.
|
|
10
10
|
*/
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { join, relative, resolve } from "node:path";
|
|
15
15
|
import { createInterface } from "node:readline/promises";
|
|
@@ -34,6 +34,7 @@ import { printError, printTarget, printWarning, redact, registerSecret, targetDa
|
|
|
34
34
|
import {
|
|
35
35
|
CONTEXT_NAME_RE,
|
|
36
36
|
TargetError,
|
|
37
|
+
assertCredentialTypes,
|
|
37
38
|
claimNewBinding,
|
|
38
39
|
compareOrigin,
|
|
39
40
|
enforceBindingPolicy,
|
|
@@ -48,6 +49,22 @@ import {
|
|
|
48
49
|
import { startDevServer } from "../lib/dev-server.mjs";
|
|
49
50
|
import { readLocalTemplates } from "../lib/local-theme.mjs";
|
|
50
51
|
import { CliRefusal, DEFAULT_API_URL, decidePageMediaUses, fetchPageMediaUses, isValidApiKey } from "../lib/media-uses.mjs";
|
|
52
|
+
import {
|
|
53
|
+
ImportNotApplied,
|
|
54
|
+
ImportOutcomeUnknown,
|
|
55
|
+
TRANSLATION_ONLY,
|
|
56
|
+
XLIFF_MAX_CHARS,
|
|
57
|
+
addImportReport,
|
|
58
|
+
chunkPackage,
|
|
59
|
+
emptyImportTally,
|
|
60
|
+
exportAllTranslations,
|
|
61
|
+
exportSkipLines,
|
|
62
|
+
fitChunksToBodyCap,
|
|
63
|
+
importSummaryLines,
|
|
64
|
+
importTranslationChunk,
|
|
65
|
+
readPackageFile,
|
|
66
|
+
toXliff,
|
|
67
|
+
} from "../lib/translations.mjs";
|
|
51
68
|
import { githubNote, healthAdvice, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
|
|
52
69
|
import { promptSecret } from "../lib/secret-prompt.mjs";
|
|
53
70
|
import { MANIFEST_PATH, buildManifest, validateSiteStateTree, verifyManifest } from "../lib/site-state.mjs";
|
|
@@ -70,12 +87,12 @@ const args = process.argv.slice(2);
|
|
|
70
87
|
// BLOCOFY_API_KEY'den gelir, argv'ye asla girmez. `pages media-uses` / `media-decide` v1 API komutlarıdır.
|
|
71
88
|
const KNOWN = {
|
|
72
89
|
login: ["url", "token", "api-key", "api-url", "keychain"],
|
|
73
|
-
pages: ["decisions", "expected-revision-id", "expected-version", "json"],
|
|
90
|
+
pages: ["decisions", "expected-revision-id", "expected-version", "json", "dir"],
|
|
74
91
|
themePull: ["draft", "instance"],
|
|
75
92
|
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
|
|
76
93
|
themeDev: ["port", "dry", "no-sync", "name"],
|
|
77
94
|
themePublish: ["instance"],
|
|
78
|
-
themeRename: ["name"],
|
|
95
|
+
themeRename: ["name", "dir"],
|
|
79
96
|
content: [],
|
|
80
97
|
settingsPush: ["instance", "live", "yes", "confirm"],
|
|
81
98
|
pagesPull: ["strict"],
|
|
@@ -88,6 +105,8 @@ const KNOWN = {
|
|
|
88
105
|
sitePlan: ["target", "mode", "accept-live-effects"],
|
|
89
106
|
siteApply: ["target", "mode", "accept-live-effects"],
|
|
90
107
|
sitePublish: ["target", "mode", "yes"],
|
|
108
|
+
translationsExport: ["locale", "out", "format", "only", "force"],
|
|
109
|
+
translationsImport: ["dry-run", "publish", "on-source-change"],
|
|
91
110
|
};
|
|
92
111
|
// CF-T1/T2: every command accepts the global `--context <name>` and `--json` (machine-readable refusals).
|
|
93
112
|
function parseArgsOrExit(rest, known) {
|
|
@@ -103,6 +122,24 @@ function parseArgsOrExit(rest, known) {
|
|
|
103
122
|
return parsed;
|
|
104
123
|
}
|
|
105
124
|
|
|
125
|
+
/**
|
|
126
|
+
* 1.8: the project directory a command resolves its binding from — the explicit argument (`[dir]` or `--dir <dir>`)
|
|
127
|
+
* when given, else cwd. A missing directory is a usage error before any request.
|
|
128
|
+
*/
|
|
129
|
+
function commandDir(explicit) {
|
|
130
|
+
if (explicit === undefined) return process.cwd();
|
|
131
|
+
if (typeof explicit !== "string" || explicit === "") throw new TargetError("USAGE", "--dir needs a directory. Nothing was read or written.", {}, 1);
|
|
132
|
+
const dir = resolve(explicit);
|
|
133
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new TargetError("USAGE", `Directory not found: ${dir}. Nothing was read or written.`, { dir }, 1);
|
|
134
|
+
return dir;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Commands whose only positional is the optional `[dir]`. */
|
|
138
|
+
function singleDirArg(positionals, usage) {
|
|
139
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${usage}`, {}, 1);
|
|
140
|
+
return commandDir(positionals[0]);
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
/** Human label for a resolved site: "Name (slug)" or just the slug. */
|
|
107
144
|
function siteLabel(site) {
|
|
108
145
|
if (!site) return "";
|
|
@@ -130,7 +167,8 @@ Usage
|
|
|
130
167
|
Non-interactive shells: set BLOCOFY_API_KEY + BLOCOFY_API_URL instead.
|
|
131
168
|
|
|
132
169
|
blocofy contexts [--json] list saved contexts (never prints secrets)
|
|
133
|
-
blocofy use <name> default context for
|
|
170
|
+
blocofy use <name> default context for status / target / pages check outside a
|
|
171
|
+
project (never used by any other command)
|
|
134
172
|
blocofy logout --context <name> remove a context and its secrets
|
|
135
173
|
blocofy link [dir] --context <name> [--adopt]
|
|
136
174
|
Bind a project directory to the context's (verified) site: writes .blocofy/project.json
|
|
@@ -176,17 +214,20 @@ Usage
|
|
|
176
214
|
(lists them first; on the live theme asks to confirm — non-interactive
|
|
177
215
|
shells must add --yes)
|
|
178
216
|
|
|
179
|
-
blocofy theme rename <handle> <new name>
|
|
217
|
+
blocofy theme rename <handle> <new name> [--dir <dir>]
|
|
180
218
|
Rename a theme (the name is just a label). Works on any of your themes,
|
|
181
219
|
including the live one. Handle comes from the panel theme card or 'blocofy status'.
|
|
220
|
+
--dir <dir> the bound project whose site this is (default: cwd)
|
|
182
221
|
|
|
183
|
-
blocofy theme publish [--instance <handle>]
|
|
222
|
+
blocofy theme publish [dir] [--instance <handle>]
|
|
184
223
|
Publish a draft theme to the LIVE site: it REPLACES the live theme for every visitor.
|
|
224
|
+
The site is the one [dir]'s project is bound to (dir defaults to cwd), so
|
|
225
|
+
'theme push ./shop && theme publish ./shop' always publishes ./shop's site.
|
|
185
226
|
With no flag, publishes the draft that 'theme dev' / 'theme push --draft' writes into.
|
|
186
227
|
The server refuses to publish a theme that has no pages (it would 404); preview first.
|
|
187
228
|
--instance <handle> publish a specific theme (handle from the panel / status)
|
|
188
229
|
|
|
189
|
-
blocofy status
|
|
230
|
+
blocofy status [dir]
|
|
190
231
|
Show the live theme, page distribution per instance, drafts, and a health flag
|
|
191
232
|
(ok / live_instance_empty / pages_split). For a problem it names the theme holding the
|
|
192
233
|
pages, the missing pages, why, and safe preview-first next steps — never a one-line fix.
|
|
@@ -229,12 +270,13 @@ Usage
|
|
|
229
270
|
is moved, exit 1. Files without "locale" use the site's default language (login needed;
|
|
230
271
|
with --write outside a bound project only an explicit --context/env credentials are used).
|
|
231
272
|
|
|
232
|
-
blocofy pages media-uses <page-handle> [--json]
|
|
273
|
+
blocofy pages media-uses <page-handle> [--dir <dir>] [--json]
|
|
233
274
|
List a page's localized-media decisions on its newest DRAFT (v1 API, pages:read).
|
|
234
275
|
Prints the draft's revision id/version needed by media-decide. --json: raw response.
|
|
276
|
+
--dir <dir> (both media commands): the bound project whose site the page is on (default: cwd).
|
|
235
277
|
|
|
236
278
|
blocofy pages media-decide <page-handle> --decisions <file.json>
|
|
237
|
-
[--expected-revision-id <n> --expected-version <n>] [--json]
|
|
279
|
+
[--expected-revision-id <n> --expected-version <n>] [--dir <dir>] [--json]
|
|
238
280
|
Apply one or more media decisions to the page's draft atomically (v1 API, pages:write).
|
|
239
281
|
The file is { "decisions": [ { path, facet, decision, target_asset?, alt?, caption?,
|
|
240
282
|
decorative?, idempotency_key?, witness? } ] } (max 20). Without the two --expected-*
|
|
@@ -246,6 +288,36 @@ Usage
|
|
|
246
288
|
1 usage/auth/network/5xx; 2 the server refused (4xx) — the {error} JSON
|
|
247
289
|
is printed to stderr.
|
|
248
290
|
|
|
291
|
+
blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff]
|
|
292
|
+
[--only all|missing|stale|pending] [--json]
|
|
293
|
+
Write every text of one language that needs translating (pages, image texts, records,
|
|
294
|
+
menus, site settings, theme texts) to ONE file (v1 API). The export asks for every kind,
|
|
295
|
+
so the API key needs the read scopes of all of them: pages:read, content:read,
|
|
296
|
+
navigation:write (menus have no read scope), settings:read, themes:read and models:read.
|
|
297
|
+
The platform answers in windows; the command follows every window and merges them, then
|
|
298
|
+
lists what was left out and why. --format xliff writes XLIFF 1.2 for translation tools.
|
|
299
|
+
--only pending only texts that are missing or need an update
|
|
300
|
+
--force replace --out if it already exists (refused otherwise, before any request)
|
|
301
|
+
|
|
302
|
+
blocofy translations import <file.json|file.xlf> [--dry-run] [--publish]
|
|
303
|
+
[--on-source-change skip|apply] [--json]
|
|
304
|
+
Import a translated package (v1 API; the write scopes of the kinds, and with --publish
|
|
305
|
+
also pages:write and content:write). Empty translations are skipped; the rest is sent in
|
|
306
|
+
chunks of at most 500 units (a page or record is never split, up to 5,000 units). Prints
|
|
307
|
+
the count per state. New pages and records are created as drafts and page content goes to
|
|
308
|
+
the draft; menus, site settings, theme texts, a live record's text and a live page's title
|
|
309
|
+
and SEO texts change at once.
|
|
310
|
+
--dry-run write nothing; report what would change
|
|
311
|
+
--publish also publish the pages and records this import wrote. A publishing
|
|
312
|
+
chunk is never resent automatically: if it gets no definite answer (or a
|
|
313
|
+
429) the command stops and says so; running it again is safe.
|
|
314
|
+
--on-source-change apply also write texts whose source changed since the export
|
|
315
|
+
(default: skip)
|
|
316
|
+
If a chunk fails, the report of the chunks done before it is printed first (--json: one
|
|
317
|
+
object with "stopped": true on stdout).
|
|
318
|
+
Exit codes: 0 done; 1 usage/file/network/5xx (or a publishing chunk that was stopped);
|
|
319
|
+
2 the server refused (4xx) — the {error} JSON is printed to stderr.
|
|
320
|
+
|
|
249
321
|
blocofy settings pull [dir]
|
|
250
322
|
blocofy settings push [dir] (--instance <handle> | --live [--yes])
|
|
251
323
|
Download / upload config/settings.json (theme tokens/settings + color schemes).
|
|
@@ -317,19 +389,31 @@ Examples
|
|
|
317
389
|
blocofy theme pull store-theme --context store && cd store-theme && blocofy theme dev
|
|
318
390
|
blocofy link ~/code/store-theme --context store (an existing checkout)
|
|
319
391
|
blocofy theme push && blocofy theme publish (inside the bound project)
|
|
392
|
+
blocofy theme push ./shop && blocofy theme publish ./shop (from anywhere: ./shop's site)
|
|
320
393
|
blocofy target && blocofy status
|
|
321
394
|
blocofy login --api-key --context store
|
|
322
395
|
blocofy pages media-uses pg_abc123 --json
|
|
323
396
|
blocofy pages media-decide pg_abc123 --decisions decisions.json
|
|
324
397
|
|
|
325
398
|
Targets (which site a command talks to)
|
|
326
|
-
Every remote command verifies its site first and prints a Target block on stderr
|
|
399
|
+
Every remote command verifies its site first and prints a Target block on stderr: the site,
|
|
400
|
+
the Platform it was verified on, the Context and where that choice came from (--context,
|
|
401
|
+
BLOCOFY_CONTEXT, env credentials, .blocofy/local.json, the project's site, the \`use\`
|
|
402
|
+
default), the Binding and the Operation (command · mode). --json: the same as {"target":…}.
|
|
327
403
|
The context is chosen in this order: --context → BLOCOFY_CONTEXT → env credentials
|
|
328
404
|
(BLOCOFY_URL+BLOCOFY_TOKEN and/or BLOCOFY_API_URL+BLOCOFY_API_KEY) → .blocofy/local.json
|
|
329
405
|
→ the one saved context matching the project's site → (terminal) pick from the matches.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
406
|
+
BLOCOFY_CONTEXT, the env credentials and .blocofy/local.json are each a choice: if two of them
|
|
407
|
+
name different contexts the command is refused before anything is read or written
|
|
408
|
+
(TARGET_CONTEXT_CONFLICT) — pass --context <name> to settle it (the Target block then lists
|
|
409
|
+
what it overrode). Inside a bound project \`use\` is ignored; outside one it is used only by
|
|
410
|
+
status, target and pages check — every other command needs --context/BLOCOFY_CONTEXT/env.
|
|
411
|
+
Commands that change a site (theme push/publish/rename/dev sync, pages push, settings push,
|
|
412
|
+
pages media-decide) need a bound project; pulls into a new empty directory bind it. A wrong project/site pairing changes nothing.
|
|
413
|
+
A context holds two SEPARATE credentials — the theme dev token (bcf_…, BLOCOFY_TOKEN) and the
|
|
414
|
+
v1 API key (blcf_live_…, BLOCOFY_API_KEY); one in the other's place is refused before any
|
|
415
|
+
request (TARGET_CREDENTIAL_WRONG_TYPE, exit 1). Each is verified with its own endpoint
|
|
416
|
+
(/api/dev/whoami, /api/v1/ping) and both must resolve to the same site.
|
|
333
417
|
A binding made against an older server has no platform origin: it still matches the same site
|
|
334
418
|
(one warning; run \`blocofy link --adopt\` to record it). A server that reports no origin cannot
|
|
335
419
|
serve a binding that records one (TARGET_UNVERIFIED).
|
|
@@ -554,7 +638,11 @@ async function login(rest) {
|
|
|
554
638
|
process.exit(1);
|
|
555
639
|
}
|
|
556
640
|
if (token && !isValidToken(token)) {
|
|
557
|
-
console.error(
|
|
641
|
+
console.error(
|
|
642
|
+
token.startsWith("blcf_")
|
|
643
|
+
? "Invalid --token — that is a v1 API key (blcf_…), not a theme dev token (bcf_…). The two are separate credentials: add the API key with `blocofy login --api-key`. Nothing was written."
|
|
644
|
+
: "Invalid --token — must start with bcf_.",
|
|
645
|
+
);
|
|
558
646
|
process.exit(1);
|
|
559
647
|
}
|
|
560
648
|
|
|
@@ -637,7 +725,7 @@ async function useCommand(rest) {
|
|
|
637
725
|
store.current_context = name;
|
|
638
726
|
saveStore(store);
|
|
639
727
|
});
|
|
640
|
-
console.log(`✓ Default context for
|
|
728
|
+
console.log(`✓ Default context for status / target / pages check outside a project: ${name}`);
|
|
641
729
|
console.log(" (Inside a bound project the project's site decides; `use` never retargets it.)");
|
|
642
730
|
}
|
|
643
731
|
|
|
@@ -666,10 +754,11 @@ async function linkCommand(rest) {
|
|
|
666
754
|
if (!flagContext && !process.env.BLOCOFY_CONTEXT && !envCtx) {
|
|
667
755
|
throw new TargetError("TARGET_CONTEXT_REQUIRED", "`blocofy link` needs the context to bind: pass --context <name> (see `blocofy contexts`).", {});
|
|
668
756
|
}
|
|
669
|
-
const resolved = await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null
|
|
757
|
+
const resolved = await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null });
|
|
670
758
|
const secrets = resolved.env ? resolved.env.secrets : readSecrets(resolved.name, resolved.context);
|
|
671
759
|
registerSecret(secrets.devToken);
|
|
672
760
|
registerSecret(secrets.apiKey);
|
|
761
|
+
assertCredentialTypes({ resolved, secrets });
|
|
673
762
|
const identity = await verifyTarget({ resolved, secrets, binding: null, retry });
|
|
674
763
|
for (const w of identity.warnings ?? []) printWarning(w, { json: JSON_MODE });
|
|
675
764
|
|
|
@@ -694,17 +783,23 @@ async function linkCommand(rest) {
|
|
|
694
783
|
const projectPath = writeBinding(dir, { site: identity.site, platformOrigin: identity.platformOrigin, contextName: resolved.name, staleLocal: true });
|
|
695
784
|
recordVerifiedSite(resolved, identity);
|
|
696
785
|
console.log(`✓ Bound ${dir} to ${siteLabel(identity.site) || identity.site.id} (${identity.site.id}) via context "${resolved.name}".`);
|
|
786
|
+
if (resolved.ignored.length) console.log(` ${resolved.source} ${resolved.name} overrode ${overridesLabel(resolved.ignored)}.`);
|
|
697
787
|
console.log(` ${relative(process.cwd(), projectPath) || projectPath} — commit it; .blocofy/local.json stays private (git-ignored).`);
|
|
698
788
|
}
|
|
699
789
|
|
|
700
790
|
async function targetCommand(rest) {
|
|
701
791
|
const { flags, positionals } = parseArgsOrExit(rest, []);
|
|
702
|
-
const dir =
|
|
703
|
-
const t = await prepareTarget({ command: "target", commandClass: "read", dir, flags, needs: "any", mode: "read", record: false, quiet: true });
|
|
792
|
+
const dir = singleDirArg(positionals, "blocofy target [dir]");
|
|
793
|
+
const t = await prepareTarget({ command: "target", commandClass: "read", dir, flags, needs: "any", mode: "read", record: false, quiet: true, allowCurrentContext: true });
|
|
704
794
|
if (JSON_MODE) console.log(JSON.stringify({ target: t.display }, null, 2));
|
|
705
795
|
else printTarget(t.display, { stream: process.stdout });
|
|
706
796
|
}
|
|
707
797
|
|
|
798
|
+
/** "BLOCOFY_CONTEXT=beta, env credentials" — the stated context choices an explicit --context overrode. */
|
|
799
|
+
function overridesLabel(ignored) {
|
|
800
|
+
return ignored.map((a) => (a.source === "env" ? "env credentials" : `${a.source}=${a.name}`)).join(", ");
|
|
801
|
+
}
|
|
802
|
+
|
|
708
803
|
/** An unverified (migrated) named context gets the verified site recorded once. */
|
|
709
804
|
function recordVerifiedSite(resolved, identity) {
|
|
710
805
|
if (resolved.env || resolved.context.site) return;
|
|
@@ -735,9 +830,10 @@ async function promptContext(candidates) {
|
|
|
735
830
|
* resolution → offline precheck → required pair → remote identity (both pairs when present) → binding match →
|
|
736
831
|
* target block. Throws TargetError / CredentialsError; never returns an unverified target.
|
|
737
832
|
*
|
|
738
|
-
* `needs`: "dev" | "api" | "any".
|
|
833
|
+
* `needs`: "dev" | "api" | "any". `allowCurrentContext`: only `status`, `target` and `pages check` may fall back to
|
|
834
|
+
* the `blocofy use` default outside a project (contract C2). Returns `{ name, dev, api, identity, binding, newBinding, display }`.
|
|
739
835
|
*/
|
|
740
|
-
async function prepareTarget({ command, commandClass, dir, flags, needs, mode, record = true, quiet = false,
|
|
836
|
+
async function prepareTarget({ command, commandClass, dir, flags, needs, mode, record = true, quiet = false, allowCurrentContext = false }) {
|
|
741
837
|
const binding = findBinding(dir);
|
|
742
838
|
const { newBinding } = enforceBindingPolicy({ commandClass, binding, dir, command });
|
|
743
839
|
const envCtx = envContext();
|
|
@@ -748,7 +844,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
748
844
|
envCtx,
|
|
749
845
|
getStore: () => (cachedStore ??= loadStore()),
|
|
750
846
|
binding,
|
|
751
|
-
|
|
847
|
+
allowCurrentContext,
|
|
752
848
|
isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
753
849
|
prompt: promptContext,
|
|
754
850
|
});
|
|
@@ -756,6 +852,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
756
852
|
const secrets = resolved.env ? resolved.env.secrets : readSecrets(resolved.name, resolved.context);
|
|
757
853
|
registerSecret(secrets.devToken);
|
|
758
854
|
registerSecret(secrets.apiKey);
|
|
855
|
+
assertCredentialTypes({ resolved, secrets });
|
|
759
856
|
|
|
760
857
|
const hasDev = Boolean(resolved.context.dev && secrets.devToken);
|
|
761
858
|
const hasApi = Boolean(resolved.context.api && secrets.apiKey);
|
|
@@ -766,9 +863,6 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
766
863
|
if (!hasApi) {
|
|
767
864
|
throw new TargetError("LOGIN_REQUIRED", `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.`, { context: resolved.name }, 1);
|
|
768
865
|
}
|
|
769
|
-
if (!isValidApiKey(secrets.apiKey)) {
|
|
770
|
-
throw new TargetError("LOGIN_REQUIRED", `The API key of context "${resolved.name}" is not a v1 key — it must start with blcf_live_. Run \`blocofy login --api-key\`.`, { context: resolved.name }, 1);
|
|
771
|
-
}
|
|
772
866
|
}
|
|
773
867
|
// CF-T4 — site plan/apply need BOTH pairs: the v1 API for the site-state endpoints, the dev token for the
|
|
774
868
|
// canonical theme deploy `awaiting_theme_source` asks for. `verifyTarget` below already asserts they
|
|
@@ -788,7 +882,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
788
882
|
|
|
789
883
|
const url = needs === "api" ? resolved.context.api?.url : resolved.context.dev?.url ?? resolved.context.api?.url;
|
|
790
884
|
const bindingLabel = binding ? relative(process.cwd(), binding.projectPath) || binding.projectPath : newBinding ? "none (new pull)" : "none";
|
|
791
|
-
const display = targetData({ site: identity.site, url, contextName: resolved.name,
|
|
885
|
+
const display = targetData({ site: identity.site, url, platformOrigin: identity.platformOrigin, contextName: resolved.name, contextSource: resolved.source, contextOverrides: resolved.ignored, bindingLabel, command, mode });
|
|
792
886
|
if (!quiet) printTarget(display, { json: JSON_MODE });
|
|
793
887
|
return {
|
|
794
888
|
name: resolved.name,
|
|
@@ -930,10 +1024,11 @@ async function themePush(rest) {
|
|
|
930
1024
|
// PS-13 `--prune`: canlı temadan dosya SİLER. Hedef canlıysa (`--live` ya da canlı temanın handle'ı
|
|
931
1025
|
// verilmiş `--instance`; whoami çözülemediyse canlı sayılır) canlı-push onay kuralı aynen uygulanır:
|
|
932
1026
|
// `--yes`/`--confirm` → onaylı, TTY → liste basıldıktan sonra y/N, non-TTY → yazımsız çıkış.
|
|
933
|
-
|
|
1027
|
+
// TPUSH-5: `--dry-run --prune` plans the pruned payload (it writes nothing, so it asks nothing).
|
|
1028
|
+
const prune = Boolean(flags.prune);
|
|
934
1029
|
const liveTarget = mode === "live" || (mode === "instance" && (whoami.liveThemeId == null || String(instance) === String(whoami.liveThemeId)));
|
|
935
1030
|
const pruneDecision = livePushDecision({
|
|
936
|
-
draft: !prune || !liveTarget,
|
|
1031
|
+
draft: !prune || dryRun || !liveTarget,
|
|
937
1032
|
yes: Boolean(flags.yes),
|
|
938
1033
|
confirm: Boolean(flags.confirm),
|
|
939
1034
|
isTTY: Boolean(process.stdin.isTTY),
|
|
@@ -1003,6 +1098,8 @@ async function themePush(rest) {
|
|
|
1003
1098
|
}
|
|
1004
1099
|
failAndExit({ code: "idempotency_conflict", status: error.status, message: "The idempotency key was already used with different content. Retry with a new key (or omit --idempotency-key).", details: {} });
|
|
1005
1100
|
}
|
|
1101
|
+
const refusal = themePushRefusal(error, { draft: mode === "draft", idempotencyKey });
|
|
1102
|
+
if (refusal) failAndExit({ code: error.code, status: error.status, ...refusal });
|
|
1006
1103
|
throw error;
|
|
1007
1104
|
}
|
|
1008
1105
|
|
|
@@ -1016,6 +1113,12 @@ async function themePush(rest) {
|
|
|
1016
1113
|
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
1017
1114
|
console.log(`✓ Validation passed (dry run — nothing written).${warnings.length ? ` ${warnings.length} warning(s).` : ""}`);
|
|
1018
1115
|
for (const w of warnings) console.log(` ⚠ ${w}`);
|
|
1116
|
+
// TPUSH-5: a 6.5 server plans the push on the control plane; an older one only validated.
|
|
1117
|
+
if (result.newDraft === true) console.log("Plan against a new draft (created by the push):");
|
|
1118
|
+
else if (result.target === "live" || result.target === "draft") {
|
|
1119
|
+
console.log(`Plan against the ${result.target} theme, pointer ${result.pointerVersion == null ? "none yet" : `v${result.pointerVersion}`}:`);
|
|
1120
|
+
}
|
|
1121
|
+
printThemeOutcomes(result.files);
|
|
1019
1122
|
return;
|
|
1020
1123
|
}
|
|
1021
1124
|
|
|
@@ -1027,7 +1130,15 @@ async function themePush(rest) {
|
|
|
1027
1130
|
if (Array.isArray(result.remoteOnlyRemoved) && result.remoteOnlyRemoved.length) {
|
|
1028
1131
|
console.log(` (removed ${result.remoteOnlyRemoved.length} file(s) absent locally — --prune)`);
|
|
1029
1132
|
}
|
|
1030
|
-
|
|
1133
|
+
printThemeOutcomes(result.files);
|
|
1134
|
+
const ids = `deployment #${result.deploymentId}, revision #${result.sourceRevisionId}, pointer v${result.pointerVersion}`;
|
|
1135
|
+
// TPUSH-5: "atomically" only when the server read the written files back and they match what was sent.
|
|
1136
|
+
if (result.readback?.verified === true) {
|
|
1137
|
+
console.log(`✓ Deployed atomically and read back (${result.readback.files} file${result.readback.files === 1 ? "" : "s"} verified): ${ids}.`);
|
|
1138
|
+
} else {
|
|
1139
|
+
console.log(`✓ Deployed: ${ids}. The written files were not verified (this server does not read them back).`);
|
|
1140
|
+
}
|
|
1141
|
+
if (result.convergence === "converged") console.log(" (already applied by an earlier push with the same idempotency key)");
|
|
1031
1142
|
if (mode === "draft") {
|
|
1032
1143
|
console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
|
|
1033
1144
|
console.log("Publish it live with: blocofy theme publish");
|
|
@@ -1056,6 +1167,105 @@ async function themePush(rest) {
|
|
|
1056
1167
|
}
|
|
1057
1168
|
}
|
|
1058
1169
|
|
|
1170
|
+
/**
|
|
1171
|
+
* TPUSH-5 — per-file outcomes of a theme push or its plan (6.5 server; an older one sends none). Changed files are
|
|
1172
|
+
* listed, unchanged ones only counted.
|
|
1173
|
+
*/
|
|
1174
|
+
function printThemeOutcomes(files) {
|
|
1175
|
+
if (!Array.isArray(files)) return;
|
|
1176
|
+
const count = { created: 0, updated: 0, removed: 0, unchanged: 0 };
|
|
1177
|
+
const mark = { created: "+", updated: "~", removed: "-" };
|
|
1178
|
+
for (const f of files) {
|
|
1179
|
+
if (!(f?.outcome in count)) continue;
|
|
1180
|
+
count[f.outcome] += 1;
|
|
1181
|
+
if (f.outcome !== "unchanged") console.log(` ${mark[f.outcome]} ${f.path}`);
|
|
1182
|
+
}
|
|
1183
|
+
console.log(`Files: ${count.created} created, ${count.updated} updated, ${count.removed} removed, ${count.unchanged} unchanged.`);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* TPUSH-5 — the 6.5 theme push refusals, in words. `null` = not one of them. A refusal writes nothing, but when the
|
|
1188
|
+
* apply was resent after an attempt without a certain answer (`error.earlierAttempt`), that earlier attempt may have
|
|
1189
|
+
* written: then the message says so instead of "Nothing was written", and `details.earlierAttempt` names it.
|
|
1190
|
+
*/
|
|
1191
|
+
function themePushRefusal(error, { draft = false, idempotencyKey = null } = {}) {
|
|
1192
|
+
const body = error?.body ?? {};
|
|
1193
|
+
const earlier = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
|
|
1194
|
+
// Where an earlier attempt of this push could have written: the theme its dry run bound it to (every attempt is the
|
|
1195
|
+
// same request), never the current target a refusal may name. A new-draft binding (null) is found via the drafts.
|
|
1196
|
+
const bound = error?.expectedTargetInstance;
|
|
1197
|
+
const check =
|
|
1198
|
+
typeof bound === "string"
|
|
1199
|
+
? `\`blocofy theme push --diff --instance ${bound}\``
|
|
1200
|
+
: bound === null
|
|
1201
|
+
? "`blocofy status` (it lists the drafts; one this push created would be there) and `blocofy theme push --diff --instance <handle>`"
|
|
1202
|
+
: "`blocofy status` and `blocofy theme push --diff --instance <handle>`";
|
|
1203
|
+
const target = typeof bound === "string" ? `theme ${bound}` : "the target";
|
|
1204
|
+
// `wrote`/`none`: what this refusal certainly did not do ("deployed" for target_changed, see below).
|
|
1205
|
+
const nothing = (next, { wrote = "wrote", none = "Nothing was written." } = {}) =>
|
|
1206
|
+
earlier === "committed"
|
|
1207
|
+
? `This attempt ${wrote} nothing, but an earlier attempt of this push was committed (its answer said so). Check ${target} with ${check} before running the push again.`
|
|
1208
|
+
: earlier === "unknown"
|
|
1209
|
+
? `This attempt ${wrote} nothing, but an earlier attempt of this push got no answer, so whether it wrote is unknown. Check ${target} with ${check} before running the push again.`
|
|
1210
|
+
: `${none} ${next}`;
|
|
1211
|
+
const withEarlier = (details) => (earlier ? { ...details, earlierAttempt: earlier } : details);
|
|
1212
|
+
// tpush round 5: a write whose last answer is transient (a passed-through 5xx, a 503 fail-closed, 502
|
|
1213
|
+
// readback_unverified with outcomeUnknown, or no answer) has an unknown outcome. It is retryable under the SAME key:
|
|
1214
|
+
// the platform then reports the committed deploy instead of writing it again. Keys are per run, so name this one.
|
|
1215
|
+
const unknownOutcome = (what) => ({
|
|
1216
|
+
...(error?.code ? {} : { code: error?.status ? `HTTP_${error.status}` : "NETWORK_ERROR" }),
|
|
1217
|
+
message:
|
|
1218
|
+
`${what}, so whether this push was deployed${typeof bound === "string" ? ` to theme ${bound}` : ""} is unknown` +
|
|
1219
|
+
`${earlier === "committed" ? " (an earlier attempt's answer said it was committed)" : ""}. ` +
|
|
1220
|
+
`Run the same command again with \`--idempotency-key ${idempotencyKey ?? "<the same key>"}\`: if this push was deployed, the server reports that deploy instead of writing it again.`,
|
|
1221
|
+
details: withEarlier({ phase: "apply", outcome: "unknown", idempotencyKey, ...(error?.status ? { status: error.status } : {}), ...(bound !== undefined ? { expectedTargetInstance: bound } : {}) }),
|
|
1222
|
+
});
|
|
1223
|
+
switch (error?.code) {
|
|
1224
|
+
case "pointer_version_conflict":
|
|
1225
|
+
return {
|
|
1226
|
+
message: `The target theme was deployed again after this push checked it (now at pointer ${body.currentVersion == null ? "none" : `v${body.currentVersion}`}). ${nothing("Run the push again.")}`,
|
|
1227
|
+
details: withEarlier({ currentVersion: body.currentVersion ?? null }),
|
|
1228
|
+
};
|
|
1229
|
+
case "site_state_version_conflict":
|
|
1230
|
+
return { message: `The theme's settings were saved on the site while this push was running. ${nothing("Run the push again.")}`, details: withEarlier({}) };
|
|
1231
|
+
case "manifest_mismatch":
|
|
1232
|
+
return { message: `The files sent for writing differ from the files the dry run checked. ${nothing("Run the push again.")}`, details: withEarlier({}) };
|
|
1233
|
+
case "target_changed": {
|
|
1234
|
+
const was = error.expectedTargetInstance === undefined ? "the theme its dry run planned against" : error.expectedTargetInstance === null ? "a new draft (its dry run planned one)" : `theme ${error.expectedTargetInstance}`;
|
|
1235
|
+
const now = typeof body.targetInstance === "string" ? `theme ${body.targetInstance}` : "a new draft (there is no draft to reuse any more)";
|
|
1236
|
+
// Definitive, but "nothing written" is not true of it: a draft push that lost the read/provision race may have
|
|
1237
|
+
// provisioned a new empty draft first (kept; the next draft push reuses it). No theme file was deployed.
|
|
1238
|
+
const none = `Nothing was deployed${draft ? " (at most a new, empty draft was created, which the next push reuses)" : ""}.`;
|
|
1239
|
+
return {
|
|
1240
|
+
message: `The push's target changed after its dry run: it planned against ${was}, but it would now write to ${now} (the live theme was switched, or the draft to reuse changed). ${nothing("Run the push again to plan against the current target.", { wrote: "deployed", none })}`,
|
|
1241
|
+
details: withEarlier({ expectedTargetInstance: error.expectedTargetInstance ?? null, targetInstance: typeof body.targetInstance === "string" ? body.targetInstance : null }),
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
case "readback_unverified":
|
|
1245
|
+
// The dry run's plan is rolled back on the server: nothing was written, whatever `committed` says.
|
|
1246
|
+
if (error.phase === "plan") {
|
|
1247
|
+
return {
|
|
1248
|
+
message: "The server could not verify the plan of this push's dry run (a plan is rolled back). Nothing was written. Run the push again.",
|
|
1249
|
+
details: { phase: "plan", committed: false },
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
if (body.outcomeUnknown === true) return unknownOutcome("The server could not read the control plane's answer to the write");
|
|
1253
|
+
// The write went to the theme the dry run bound it to; a plain --diff compares with the live theme only.
|
|
1254
|
+
return {
|
|
1255
|
+
message: `The server could not read back the files it wrote${body.committed === true ? " (the deploy was committed)" : body.committed === false ? " (nothing was committed)" : ""}. Check ${target} with ${check}.`,
|
|
1256
|
+
details: { phase: "apply", committed: typeof body.committed === "boolean" ? body.committed : null, ...(bound !== undefined ? { expectedTargetInstance: bound } : {}) },
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
if (error?.phase === "apply" && (error.status == null || error.status >= 500)) {
|
|
1260
|
+
return unknownOutcome(error.status == null ? "The write got no answer" : `The write got no definite answer (HTTP ${error.status}${error.code ? ` ${error.code}` : ""})`);
|
|
1261
|
+
}
|
|
1262
|
+
// The 6.5 preflight names the file it refused (reserved_path, path_too_long, invalid_json…).
|
|
1263
|
+
if (error?.status === 422 && typeof body.path === "string") {
|
|
1264
|
+
return { message: `${body.path}: ${typeof body.message === "string" ? body.message : error.code}. ${nothing("").trim()}`, details: withEarlier({ path: body.path }) };
|
|
1265
|
+
}
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1059
1269
|
/** PS-19 — print platform/CLI findings; returns the exit code they imply. */
|
|
1060
1270
|
function reportPageDiagnostics(diagnostics, { strict = false } = {}) {
|
|
1061
1271
|
for (const d of diagnostics) (d.level === "error" ? console.error : console.warn)(formatDiagnostic(d));
|
|
@@ -1190,7 +1400,7 @@ async function pagesCheck(rest) {
|
|
|
1190
1400
|
console.error(`Directory not found: ${dir}`);
|
|
1191
1401
|
process.exit(1);
|
|
1192
1402
|
}
|
|
1193
|
-
const creds = (await optionalTarget({ command: "pages check", dir, flags }))?.dev;
|
|
1403
|
+
const creds = (await optionalTarget({ command: "pages check", dir, flags, allowCurrentContext: true }))?.dev;
|
|
1194
1404
|
const online = Boolean(creds);
|
|
1195
1405
|
const r = await checkPages({ dir, onRetry, ...(online ? { url: creds.url, token: creds.token } : {}) });
|
|
1196
1406
|
const code = reportPageDiagnostics(r.diagnostics, { strict: Boolean(flags.strict) });
|
|
@@ -1641,11 +1851,11 @@ async function contentPull(scope, rest) {
|
|
|
1641
1851
|
* Optional online mode for offline-capable reads (`pages check`, `pages migrate-layout`): no credentials at all, or
|
|
1642
1852
|
* no context choosable outside a project → offline. Inside a bound project every other refusal still applies.
|
|
1643
1853
|
*/
|
|
1644
|
-
async function optionalTarget({ command, dir, flags, localWrite = false }) {
|
|
1854
|
+
async function optionalTarget({ command, dir, flags, localWrite = false, allowCurrentContext = false }) {
|
|
1645
1855
|
try {
|
|
1646
|
-
// Review I3
|
|
1647
|
-
//
|
|
1648
|
-
return await prepareTarget({ command, commandClass: "read",
|
|
1856
|
+
// Review I3 / contract C2: only `pages check` may borrow the global default context outside a binding;
|
|
1857
|
+
// migrate-layout (dry run or --write) needs an explicit --context/env there, else it runs offline.
|
|
1858
|
+
return await prepareTarget({ command, commandClass: "read", allowCurrentContext, dir, flags, needs: "dev", mode: localWrite ? "read · local write" : "read" });
|
|
1649
1859
|
} catch (error) {
|
|
1650
1860
|
if (error instanceof TargetError && (error.code === "LOGIN_REQUIRED" || (error.code === "TARGET_CONTEXT_REQUIRED" && !findBinding(dir)))) return null;
|
|
1651
1861
|
throw error;
|
|
@@ -1671,11 +1881,14 @@ function printMediaUsesView(view) {
|
|
|
1671
1881
|
async function pagesMediaUses(rest) {
|
|
1672
1882
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
|
|
1673
1883
|
const page = positionals[0];
|
|
1884
|
+
const usage = "blocofy pages media-uses <page-handle> [--dir <dir>] [--json]";
|
|
1674
1885
|
if (!page) {
|
|
1675
|
-
console.error(
|
|
1886
|
+
console.error(`Usage: ${usage}`);
|
|
1676
1887
|
process.exit(1);
|
|
1677
1888
|
}
|
|
1678
|
-
|
|
1889
|
+
// 1.8: the project directory is `--dir`; a directory given as a positional is refused, never dropped for cwd's.
|
|
1890
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${usage}`, {}, 1);
|
|
1891
|
+
const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-uses", commandClass: "read", dir: commandDir(flags.dir), flags, needs: "api", mode: `read · page ${page}` })).api;
|
|
1679
1892
|
const view = await fetchPageMediaUses({ apiUrl, apiKey, page, onRetry });
|
|
1680
1893
|
if (flags.json) console.log(JSON.stringify(view, null, 2));
|
|
1681
1894
|
else printMediaUsesView(view);
|
|
@@ -1696,10 +1909,12 @@ async function pagesMediaDecide(rest) {
|
|
|
1696
1909
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
|
|
1697
1910
|
const page = positionals[0];
|
|
1698
1911
|
const file = typeof flags.decisions === "string" ? resolve(flags.decisions) : null;
|
|
1912
|
+
const usage = "blocofy pages media-decide <page-handle> --decisions <file.json> [--expected-revision-id <n> --expected-version <n>] [--dir <dir>] [--json]";
|
|
1699
1913
|
if (!page || !file) {
|
|
1700
|
-
console.error(
|
|
1914
|
+
console.error(`Usage: ${usage}`);
|
|
1701
1915
|
process.exit(1);
|
|
1702
1916
|
}
|
|
1917
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${usage}`, {}, 1);
|
|
1703
1918
|
let parsed;
|
|
1704
1919
|
try {
|
|
1705
1920
|
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -1718,7 +1933,7 @@ async function pagesMediaDecide(rest) {
|
|
|
1718
1933
|
process.exit(1);
|
|
1719
1934
|
}
|
|
1720
1935
|
|
|
1721
|
-
const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-decide", commandClass: "remote-mutation", dir:
|
|
1936
|
+
const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-decide", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "api", mode: `draft · page ${page}` })).api;
|
|
1722
1937
|
if (expectedRevisionId === null) {
|
|
1723
1938
|
const view = await fetchPageMediaUses({ apiUrl, apiKey, page, onRetry });
|
|
1724
1939
|
if (view.applicable === false) {
|
|
@@ -1895,8 +2110,9 @@ async function themeDev(rest) {
|
|
|
1895
2110
|
}
|
|
1896
2111
|
|
|
1897
2112
|
async function themePublish(rest) {
|
|
1898
|
-
const { flags } = parseArgsOrExit(rest, KNOWN.themePublish);
|
|
1899
|
-
const
|
|
2113
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePublish);
|
|
2114
|
+
const dir = singleDirArg(positionals, "blocofy theme publish [dir] [--instance <handle>]");
|
|
2115
|
+
const target = await prepareTarget({ command: "theme publish", commandClass: "remote-mutation", dir, flags, needs: "dev", mode: `live${typeof flags.instance === "string" ? ` · instance ${flags.instance}` : " · CLI draft"}` });
|
|
1900
2116
|
const creds = target.dev;
|
|
1901
2117
|
let instance = typeof flags.instance === "string" ? flags.instance : null;
|
|
1902
2118
|
if (!instance) {
|
|
@@ -1932,18 +2148,18 @@ async function themeRename(rest) {
|
|
|
1932
2148
|
const handle = positionals[0];
|
|
1933
2149
|
const name = positionals.slice(1).join(" ") || (typeof flags.name === "string" ? flags.name : null);
|
|
1934
2150
|
if (!handle || !name) {
|
|
1935
|
-
console.error("Usage: blocofy theme rename <handle> <new name>");
|
|
2151
|
+
console.error("Usage: blocofy theme rename <handle> <new name> [--dir <dir>]");
|
|
1936
2152
|
console.error(" Rename a theme (handle from the panel theme card or `blocofy status`).");
|
|
1937
2153
|
process.exit(1);
|
|
1938
2154
|
}
|
|
1939
|
-
const creds = (await prepareTarget({ command: "theme rename", commandClass: "remote-mutation", dir:
|
|
2155
|
+
const creds = (await prepareTarget({ command: "theme rename", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "dev", mode: `instance ${handle}` })).dev;
|
|
1940
2156
|
const result = await renameInstance({ url: creds.url, token: creds.token, instance: handle, name, onRetry });
|
|
1941
2157
|
console.log(`✓ Renamed to "${result.name}" (${result.id}).`);
|
|
1942
2158
|
}
|
|
1943
2159
|
|
|
1944
2160
|
async function status(rest) {
|
|
1945
|
-
const { flags } = parseArgsOrExit(rest, []);
|
|
1946
|
-
const creds = (await prepareTarget({ command: "status", commandClass: "read", dir:
|
|
2161
|
+
const { flags, positionals } = parseArgsOrExit(rest, []);
|
|
2162
|
+
const creds = (await prepareTarget({ command: "status", commandClass: "read", dir: singleDirArg(positionals, "blocofy status [dir]"), flags, needs: "dev", mode: "read", allowCurrentContext: true })).dev;
|
|
1947
2163
|
const s = await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry });
|
|
1948
2164
|
const live = s.live_theme_instance;
|
|
1949
2165
|
console.log(`\nSite: ${s.site.slug}${s.url ? ` · ${s.url}` : ""}`);
|
|
@@ -1961,6 +2177,169 @@ async function status(rest) {
|
|
|
1961
2177
|
console.log("");
|
|
1962
2178
|
}
|
|
1963
2179
|
|
|
2180
|
+
// ── translations (#925): one language's texts as a package file, and back ──────────────────────────────────
|
|
2181
|
+
|
|
2182
|
+
const TRANSLATIONS_EXPORT_USAGE = "blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff] [--only all|missing|stale|pending] [--json]";
|
|
2183
|
+
const TRANSLATIONS_IMPORT_USAGE = "blocofy translations import <file.json|file.xlf> [--dry-run] [--publish] [--on-source-change skip|apply] [--json]";
|
|
2184
|
+
|
|
2185
|
+
function usageExit(usage) {
|
|
2186
|
+
console.error(`Usage: ${usage}`);
|
|
2187
|
+
process.exit(1);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
const outExists = (out) =>
|
|
2191
|
+
Object.assign(new Error(`${out} already exists. Nothing was requested or written. Choose another --out file, or add --force to replace it.`), { code: "TRANSLATIONS_OUT_EXISTS", details: { file: out } });
|
|
2192
|
+
|
|
2193
|
+
/** Write `text` to `out` through a temp file + rename, so `out` is never left half-written. */
|
|
2194
|
+
function writeOutFile(out, text, force) {
|
|
2195
|
+
const temp = `${out}.${process.pid}.blocofy-tmp`;
|
|
2196
|
+
try {
|
|
2197
|
+
writeFileSync(temp, text, { flag: "wx" });
|
|
2198
|
+
if (!force && existsSync(out)) throw outExists(out);
|
|
2199
|
+
renameSync(temp, out);
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
rmSync(temp, { force: true });
|
|
2202
|
+
throw error;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
async function translationsExport(rest) {
|
|
2207
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.translationsExport);
|
|
2208
|
+
const locale = typeof flags.locale === "string" ? flags.locale : null;
|
|
2209
|
+
const out = typeof flags.out === "string" ? resolve(flags.out) : null;
|
|
2210
|
+
const format = flags.format === undefined ? "json" : flags.format;
|
|
2211
|
+
const only = flags.only === undefined ? "all" : flags.only;
|
|
2212
|
+
const force = flags.force === true;
|
|
2213
|
+
if (!locale || !out || positionals.length > 0 || (format !== "json" && format !== "xliff") || !TRANSLATION_ONLY.includes(only)) usageExit(TRANSLATIONS_EXPORT_USAGE);
|
|
2214
|
+
// A file the customer may have filled in is never replaced silently.
|
|
2215
|
+
if (!force && existsSync(out)) throw outExists(out);
|
|
2216
|
+
// A read of the site; the package goes to --out, never into the project tree (so no binding is needed or written).
|
|
2217
|
+
const { apiUrl, apiKey } = (await prepareTarget({ command: "translations export", commandClass: "read", dir: process.cwd(), flags, needs: "api", mode: `read · ${locale}` })).api;
|
|
2218
|
+
const { pkg, skipped } = await exportAllTranslations({ apiUrl, apiKey, locale, only, onRetry, onPage: ({ page, units }) => console.error(`Export: request ${page}, ${units} unit(s) so far`) });
|
|
2219
|
+
// toXliff refuses a text XML cannot carry BEFORE anything is written.
|
|
2220
|
+
const text = format === "xliff" ? toXliff(pkg) : `${JSON.stringify(pkg, null, 2)}\n`;
|
|
2221
|
+
writeOutFile(out, text, force);
|
|
2222
|
+
if (format === "xliff" && text.length > XLIFF_MAX_CHARS) {
|
|
2223
|
+
console.error(`Warning: this XLIFF file is over ${XLIFF_MAX_CHARS.toLocaleString("en-US")} characters and cannot be imported back. Export with --format json, or a smaller scope such as --only pending.`);
|
|
2224
|
+
}
|
|
2225
|
+
if (flags.json) {
|
|
2226
|
+
console.log(JSON.stringify({ file: out, format, target_locale: pkg.target_locale, units: pkg.units.length, skipped }));
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
console.log(`Exported ${pkg.units.length} unit(s) for ${pkg.target_locale} to ${out} (${format === "xliff" ? "XLIFF" : "JSON"}).`);
|
|
2230
|
+
const skipLines = exportSkipLines(skipped);
|
|
2231
|
+
if (skipLines.length > 0) {
|
|
2232
|
+
console.log("Left out:");
|
|
2233
|
+
for (const line of skipLines) console.log(` - ${line}`);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/** The one `--json` shape of an import run: complete, stopped part-way, or with nothing to send. */
|
|
2238
|
+
function importJson(tally, { dryRun, publish, locale, chunks, chunksDone, caches, splitGroups, stopped }) {
|
|
2239
|
+
return {
|
|
2240
|
+
dry_run: dryRun,
|
|
2241
|
+
target_locale: locale,
|
|
2242
|
+
chunks,
|
|
2243
|
+
chunks_done: chunksDone,
|
|
2244
|
+
stopped,
|
|
2245
|
+
counts: tally.counts,
|
|
2246
|
+
created: tally.created,
|
|
2247
|
+
stamped: tally.stamped,
|
|
2248
|
+
publication: !dryRun && publish ? { published: tally.published, blocked: tally.publishBlocked } : null,
|
|
2249
|
+
publish_skipped: tally.publishSkipped,
|
|
2250
|
+
notes: tally.notes,
|
|
2251
|
+
hints: tally.hints,
|
|
2252
|
+
cache: caches,
|
|
2253
|
+
split_groups: splitGroups,
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
async function translationsImport(rest) {
|
|
2258
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.translationsImport);
|
|
2259
|
+
const onSourceChange = flags["on-source-change"] === undefined ? "skip" : flags["on-source-change"];
|
|
2260
|
+
if (positionals.length !== 1 || (onSourceChange !== "skip" && onSourceChange !== "apply")) usageExit(TRANSLATIONS_IMPORT_USAGE);
|
|
2261
|
+
const file = resolve(positionals[0]);
|
|
2262
|
+
const dryRun = flags["dry-run"] === true;
|
|
2263
|
+
const publish = flags.publish === true;
|
|
2264
|
+
// Everything local is checked before the first request: the file, its chunks, every chunk's body size.
|
|
2265
|
+
let pkg;
|
|
2266
|
+
let chunked;
|
|
2267
|
+
try {
|
|
2268
|
+
pkg = readPackageFile(readFileSync(file, "utf8"), file);
|
|
2269
|
+
chunked = chunkPackage(pkg);
|
|
2270
|
+
chunked.chunks = fitChunksToBodyCap(chunked.chunks);
|
|
2271
|
+
} catch (error) {
|
|
2272
|
+
console.error(`Cannot read translation package ${file}: ${String(error?.message ?? error).replace(/\.?$/, ".")} Nothing was sent.`);
|
|
2273
|
+
process.exit(1);
|
|
2274
|
+
}
|
|
2275
|
+
const { chunks, skippedEmpty, splitGroups } = chunked;
|
|
2276
|
+
let tally = emptyImportTally();
|
|
2277
|
+
tally.counts.skipped_empty += skippedEmpty;
|
|
2278
|
+
const caches = [];
|
|
2279
|
+
let done = 0;
|
|
2280
|
+
const shape = { dryRun, publish, locale: pkg.target_locale, chunks: chunks.length, caches, splitGroups };
|
|
2281
|
+
if (chunks.length === 0) {
|
|
2282
|
+
if (flags.json) console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: 0, stopped: false })));
|
|
2283
|
+
else console.log("Every translation in the file is empty; nothing to import. Nothing was sent.");
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
const mode = `${dryRun ? "dry run" : publish ? "write + publish" : "write"} · ${pkg.target_locale}`;
|
|
2287
|
+
const { apiUrl, apiKey } = (await prepareTarget({ command: "translations import", commandClass: dryRun ? "read" : "remote-mutation", dir: process.cwd(), flags, needs: "api", mode })).api;
|
|
2288
|
+
try {
|
|
2289
|
+
for (const chunk of chunks) {
|
|
2290
|
+
const answer = await importTranslationChunk({ apiUrl, apiKey, chunk, dryRun, publish, onSourceChange, onRetry });
|
|
2291
|
+
tally = addImportReport(tally, answer);
|
|
2292
|
+
if (answer?.cache !== undefined && answer.cache !== null) caches.push(answer.cache);
|
|
2293
|
+
done += 1;
|
|
2294
|
+
if (chunks.length > 1) console.error(`${dryRun ? "Checked" : "Imported"} ${done} of ${chunks.length} chunks`);
|
|
2295
|
+
}
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
// What the chunks before the failure did (published pages, held drafts, a failed cache flush) is reported in
|
|
2298
|
+
// full: a rerun answers `unchanged` for them and could never tell it again.
|
|
2299
|
+
if (done > 0) {
|
|
2300
|
+
if (flags.json) console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: done, stopped: true })));
|
|
2301
|
+
else {
|
|
2302
|
+
console.log(`Report for the ${done} of ${chunks.length} chunks done before the stop:`);
|
|
2303
|
+
for (const line of importSummaryLines(tally, { dryRun, publish, locale: pkg.target_locale, splitGroups })) console.log(line);
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
if (!JSON_MODE) {
|
|
2307
|
+
const invalid = error instanceof CliRefusal && Array.isArray(error.error?.details?.units) ? error.error.details.units : [];
|
|
2308
|
+
if (invalid.length > 0) {
|
|
2309
|
+
console.error("These texts were refused; fix the file and import again:");
|
|
2310
|
+
for (const u of invalid.slice(0, 20)) console.error(` ${u.id}: ${u.message ?? u.reason ?? "refused"}`);
|
|
2311
|
+
if (invalid.length > 20) console.error(` …and ${invalid.length - 20} more.`);
|
|
2312
|
+
}
|
|
2313
|
+
if (!dryRun && done > 0) {
|
|
2314
|
+
console.error(`Import stopped after ${done} of ${chunks.length} chunks. The chunks before it were saved; running the same command again is safe (units already applied report as unchanged).`);
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
const details = { chunk: done + 1, chunks: chunks.length, reason: error?.reason };
|
|
2318
|
+
if (error instanceof ImportOutcomeUnknown) {
|
|
2319
|
+
throw Object.assign(
|
|
2320
|
+
new Error(
|
|
2321
|
+
`The import of chunk ${done + 1} of ${chunks.length} got no definite answer (${error.reason}) and was not resent, because it publishes: that chunk may or may not have been applied and published. Running the same command again is safe (units already applied report as unchanged).`,
|
|
2322
|
+
),
|
|
2323
|
+
{ code: "TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN", details },
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
if (error instanceof ImportNotApplied) {
|
|
2327
|
+
throw Object.assign(
|
|
2328
|
+
new Error(
|
|
2329
|
+
`The import of chunk ${done + 1} of ${chunks.length} was refused (${error.reason}, too many requests) and was not applied; it was not resent, because it publishes. Wait a moment and run the same command again (units already applied report as unchanged).`,
|
|
2330
|
+
),
|
|
2331
|
+
{ code: "TRANSLATIONS_IMPORT_NOT_APPLIED", details },
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
throw error;
|
|
2335
|
+
}
|
|
2336
|
+
if (flags.json) {
|
|
2337
|
+
console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: done, stopped: false })));
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
for (const line of importSummaryLines(tally, { dryRun, publish, locale: pkg.target_locale, splitGroups })) console.log(line);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
1964
2343
|
const [first, ...rest] = args;
|
|
1965
2344
|
|
|
1966
2345
|
function commandKey(a, b) {
|
|
@@ -1995,6 +2374,8 @@ const COMMANDS = {
|
|
|
1995
2374
|
"site plan": sitePlan,
|
|
1996
2375
|
"site apply": siteApply,
|
|
1997
2376
|
"site publish": sitePublish,
|
|
2377
|
+
"translations export": translationsExport,
|
|
2378
|
+
"translations import": translationsImport,
|
|
1998
2379
|
};
|
|
1999
2380
|
|
|
2000
2381
|
if (first === "--version" || first === "-v") {
|