@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/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 read-only commands outside a project
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
- Inside a bound project \`use\` is ignored. Commands that change a site (theme push/publish/
331
- rename/dev sync, pages push, settings push, pages media-decide) need a bound project;
332
- pulls into a new empty directory bind it. A wrong project/site pairing changes nothing.
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("Invalid --token — must start with bcf_.");
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 read-only commands outside a project: ${name}`);
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, commandClass: "read" });
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 = resolve(positionals[0] ?? process.cwd());
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". Returns `{ name, dev, api, identity, binding, newBinding, display }`.
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, resolveClass = commandClass }) {
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
- commandClass: resolveClass,
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, bindingLabel, operation: `${command} · ${mode}` });
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
- const prune = Boolean(flags.prune) && !dryRun;
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
- console.log(`✓ Deployed atomically: deployment #${result.deploymentId}, revision #${result.sourceRevisionId}, pointer v${result.pointerVersion}.`);
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: a command that writes local files (migrate-layout --write) never borrows the global default
1647
- // context — outside a binding it needs an explicit --context/env, else it runs offline.
1648
- return await prepareTarget({ command, commandClass: "read", resolveClass: localWrite ? "local-write" : "read", dir, flags, needs: "dev", mode: localWrite ? "read · local write" : "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("Usage: blocofy pages media-uses <page-handle> [--json]");
1886
+ console.error(`Usage: ${usage}`);
1676
1887
  process.exit(1);
1677
1888
  }
1678
- const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-uses", commandClass: "read", dir: process.cwd(), flags, needs: "api", mode: `read · page ${page}` })).api;
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("Usage: blocofy pages media-decide <page-handle> --decisions <file.json> [--expected-revision-id <n> --expected-version <n>] [--json]");
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: process.cwd(), flags, needs: "api", mode: `draft · page ${page}` })).api;
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 target = await prepareTarget({ command: "theme publish", commandClass: "remote-mutation", dir: process.cwd(), flags, needs: "dev", mode: `live${typeof flags.instance === "string" ? ` · instance ${flags.instance}` : " · CLI draft"}` });
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: process.cwd(), flags, needs: "dev", mode: `instance ${handle}` })).dev;
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: process.cwd(), flags, needs: "dev", mode: "read" })).dev;
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") {