@blocofy/cli 0.11.0 → 0.13.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 CHANGED
@@ -63,13 +63,15 @@ Show which site a command in `[dir]` would hit (verified), without writing anyth
63
63
  ```
64
64
  blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
65
65
  ```
66
- Start a dev server and print 3 auto-reloading views — Local, live-domain Preview, and the theme
67
- Editor. Press `l` / `p` / `e` to open each, `q` to quit. Edit a file and save → every open view
68
- reloads. Saves sync to a **draft** theme only (never the live site). `dir` defaults to cwd.
66
+ Start a local dev server that renders your local theme files with the site's live content.
67
+ Press `l` to open it, `q` to quit. Edit a file and save → the view reloads. Saves sync to a
68
+ **draft** theme only (never the live site). `dir` defaults to cwd. The platform's remote preview
69
+ and editor views for this command are retired; to share a draft page, create a preview link for
70
+ it (v1 API `POST /themes/{id}/preview-links` or MCP `create_preview_link`; one page per link).
69
71
  The target site is verified once at start; a long session keeps that target (restart it after
70
72
  changing credentials, context or the project binding).
71
73
  - `--port <n>` — local port (default 3030).
72
- - `--no-sync` — local preview only (skip draft sync + remote views).
74
+ - `--no-sync` — local preview only (skip draft sync).
73
75
  - `--name <name>` — name the draft when it is first created (ignored if it already exists).
74
76
 
75
77
  ```
@@ -94,13 +96,20 @@ the admin panel, never touching the live site. Publish it with `blocofy theme pu
94
96
  - `--yes` — confirm a `--live` push without prompting (CI/agents).
95
97
  - `--instance <handle>` — push to a specific theme by its handle (safe targeted write — no
96
98
  live-confirmation prompt).
97
- - `--name <name>` — name the new draft (draft mode only; ignored on `--live`/`--instance`).
99
+ - `--name <name>` — name the new draft (draft mode only; ignored on `--live`/`--instance`). A draft
100
+ push reuses the existing CLI draft whatever its name; then the push says `--name` was not applied
101
+ and prints the `blocofy theme rename` command for it.
98
102
  - `--dry-run` / `--validate` — check the push on the server without writing; the two flags are
99
103
  aliases. The dry run covers exactly the files the push would send (remote-only files it keeps
100
104
  included; with `--prune`, without the ones it would remove). A current platform plans the whole
101
105
  deploy and prints the target, its pointer version and what happens to each file
102
106
  (`+` created, `~` updated, `-` removed; unchanged files are counted).
103
- - `--diff` — show what a push would change vs the target (read-only), then stop.
107
+ - `--diff` — show what a push would change vs the target (read-only), then stop. A draft push
108
+ compares with the CLI draft it writes to (named by handle); with no CLI draft yet, with the live
109
+ theme (the push would create the draft). `--live` compares with live, `--instance` with that theme.
110
+
111
+ After a draft push the CLI prints the draft it wrote to (`Draft: <handle> "<name>" (new)` or
112
+ `(existing CLI draft, updated)`) and the `blocofy theme publish --instance <handle>` command.
104
113
  - `--idempotency-key <k>` — attach a key so a retried push is not double-applied.
105
114
  - `--prune` — also remove target files that no longer exist locally (`locales/` included);
106
115
  lists them first, and on the live theme asks to confirm (non-interactive shells add `--yes`).
@@ -226,6 +235,43 @@ get a random UUID. Transient failures (429/502/503/504, network) are retried aga
226
235
  idempotency key, so a retry replays the same batch. Exit 0 on success (or "No changes" when
227
236
  every item was already recorded).
228
237
 
238
+ ### Translations
239
+
240
+ ```
241
+ blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff] [--only all|missing|stale|pending] [--json]
242
+ blocofy translations import <file.json|file.xlf> [--dry-run] [--publish] [--on-source-change skip|apply] [--json]
243
+ ```
244
+ `export` writes every text of one language that needs translating (pages, image texts, records,
245
+ menus, site settings, theme texts) to ONE file. The platform answers in windows; the command
246
+ follows every window until the last and merges them, then lists what was left out and why.
247
+ `--format xliff` writes XLIFF 1.2 for translation tools (the platform's own dialect, byte for
248
+ byte); `--only pending` limits the file to texts that are missing or need an update. The export
249
+ asks for every kind, so the API key needs the read scopes of all of them: `pages:read`,
250
+ `content:read`, `navigation:write` (menus have no read scope), `settings:read`, `themes:read` and
251
+ `models:read`; a key without one of them is refused for the whole export. An existing `--out` file
252
+ is never replaced silently: the command refuses before any request unless `--force` is given, and
253
+ the file is written through a temporary file and a rename.
254
+
255
+ `import` reads a JSON or XLIFF package, drops empty translations, and sends the rest as JSON in
256
+ chunks of at most 500 units (a page or record is never split; one group of up to 5,000 units is
257
+ sent whole). Every chunk is checked locally before the first request. It prints the count per
258
+ state (written, unchanged, empty, source changed, target changed, invalid, blocked). New pages and
259
+ records are created as drafts and page content goes to the draft; menus, site settings, theme texts,
260
+ a live record's text and a live page's title and SEO texts change at once (a live page's URL only
261
+ with `--publish`, once that page's publish succeeded). `--publish` also publishes the pages and records this
262
+ import wrote (needs `pages:write` and `content:write`) and reports what could not be published;
263
+ `--dry-run` writes nothing and reports what would change. The API key needs the write scopes of
264
+ the kinds in the package. A refusal (for example an invalid text) exits 2 with the server's error
265
+ JSON on stderr; if a later chunk fails, the chunks already written stay written, the report of
266
+ those chunks (counts, what was published or held back, a failed cache refresh) is printed before the
267
+ error (`--json`: one object with `"stopped": true` on stdout), and running the command again is safe
268
+ (written texts answer "unchanged"). Transient failures (429/502/503/504, network) are retried, except
269
+ for a chunk imported with `--publish`: a resend could not see what the first attempt already
270
+ published, so such a chunk is sent once. A 5xx or a lost connection stops the command with
271
+ `TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN` (exit 1), naming the chunk that may or may not have been
272
+ applied and published; a 429 stops it with `TRANSLATIONS_IMPORT_NOT_APPLIED` (exit 1), since that
273
+ chunk was not applied.
274
+
229
275
  ### Settings
230
276
 
231
277
  ```
@@ -408,10 +454,37 @@ Retries: network errors and HTTP 429/502/503/504 are retried up to 3 times (`Ret
408
454
  honoured, max 30s per wait; else 0.3s/0.9s/2s), resending the identical request (`pages push`
409
455
  carries one `x-idempotency-key` per push). HTTP 500 is never retried, nor is a `theme push` dry
410
456
  run answered 502 `readback_unverified` (a verdict on a rolled-back plan, not a transient failure).
457
+ `translations import --publish` is never retried either: a resend could not see what the first
458
+ attempt already published, so a failed chunk stops the command with
459
+ `TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN` (5xx, network) or `TRANSLATIONS_IMPORT_NOT_APPLIED` (429).
411
460
  Each retry prints a notice on stderr.
412
461
 
413
462
  ## Changelog
414
463
 
464
+ - **0.13.0** — Draft push names its target (customer items PS-22/PS-23/PS-26).
465
+ - `theme push` (draft) prints the draft it wrote to — handle, name, new or existing — and
466
+ `blocofy theme publish --instance <handle>`. When it updated the existing CLI draft, it says
467
+ `--name` was not applied and prints the rename command.
468
+ - **Behaviour change:** `theme push --diff` without `--live`/`--instance` now compares with the CLI
469
+ draft the push writes to (found read-only via the site status, never provisioned), not with the
470
+ live theme. With no CLI draft yet it says so and compares with live. Release it after the
471
+ platform's PS-22 fix (uncached theme reads): before that fix a draft read could return its
472
+ pre-push files for up to five minutes.
473
+ - `theme dev` help no longer lists the retired live-domain and editor views.
474
+ - `TARGET_BINDING_REQUIRED` also names `blocofy link <dir>` for env credentials.
475
+ - **0.12.0** — Translation packages (#925).
476
+ - `blocofy translations export --locale <tag> --out <file> [--format json|xliff] [--only …]`
477
+ writes every text of one language that needs translating to one JSON or XLIFF 1.2 file. It
478
+ follows every export window and lists what the platform left out, by reason.
479
+ - `blocofy translations import <file> [--dry-run] [--publish] [--on-source-change skip|apply]`
480
+ reads a JSON or XLIFF package and imports it in chunks of at most 500 units (a page or record
481
+ is never split, up to 5,000 units). It prints the count per state, what was published, and
482
+ whether the site cache could not be refreshed. A chunk imported with `--publish` is never
483
+ resent automatically: when it gets no definite answer the command stops with
484
+ `TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN` (a 429: `TRANSLATIONS_IMPORT_NOT_APPLIED`), exit 1, names
485
+ the chunk, and running it again is safe. A run that stops part-way still prints the report of
486
+ the chunks done before it.
487
+ - `export` refuses an existing `--out` file unless `--force`.
415
488
  - **0.11.0** — One named context per site, one explicit target (customer item 1.8).
416
489
  The dev token and the v1 API key remain two separate credentials; a context references both.
417
490
  - **Breaking:** conflicting context choices now fail closed. When `BLOCOFY_CONTEXT`, the env
@@ -566,11 +639,9 @@ files and sends them to the platform's dev-render endpoint (`/api/dev/render`).
566
639
  renders them with the site's **live data** and returns HTML — so the CLI ships no rendering
567
640
  engine and you see exactly the production output.
568
641
 
569
- It also continuously syncs your local files to a **draft theme** so you can view the same work
570
- three ways — the local preview, a shareable live-domain preview link, and the admin theme
571
- editor — without affecting your published theme. Save a file and **every open view reloads**
572
- (the platform-rendered pages connect back to the local dev server's reload channel). Publish
573
- the draft from the theme editor when you're ready.
642
+ It also continuously syncs your local files to a **draft theme**, so the same work can be opened
643
+ in the admin theme editor without affecting your published theme. Save a file and the local view
644
+ reloads. Publish the draft from the theme editor, or with `blocofy theme publish`, when you're ready.
574
645
 
575
646
  Credentials live in `~/.blocofy/credentials.json` (contexts, no secrets) + either
576
647
  `~/.blocofy/secrets.json` (0600) or the macOS keychain — written by `blocofy login`, or the
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";
@@ -49,13 +49,29 @@ import {
49
49
  import { startDevServer } from "../lib/dev-server.mjs";
50
50
  import { readLocalTemplates } from "../lib/local-theme.mjs";
51
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";
52
68
  import { githubNote, healthAdvice, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
53
69
  import { promptSecret } from "../lib/secret-prompt.mjs";
54
70
  import { MANIFEST_PATH, buildManifest, validateSiteStateTree, verifyManifest } from "../lib/site-state.mjs";
55
71
  import { SiteStateFsError, hashBuffer, readSiteStateTree, stagedWriteTree } from "../lib/site-state-fs.mjs";
56
72
  import { migrateSiteState } from "../lib/site-migrate.mjs";
57
73
  import { applySiteState, downloadAssetBytes, fetchSiteStateExport, planSiteState, publishSiteState, uploadMediaAsset } from "../lib/site-state-client.mjs";
58
- import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
74
+ import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
59
75
  import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
60
76
  import { hyperlink, openUrl } from "../lib/term.mjs";
61
77
  import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
@@ -89,6 +105,8 @@ const KNOWN = {
89
105
  sitePlan: ["target", "mode", "accept-live-effects"],
90
106
  siteApply: ["target", "mode", "accept-live-effects"],
91
107
  sitePublish: ["target", "mode", "yes"],
108
+ translationsExport: ["locale", "out", "format", "only", "force"],
109
+ translationsImport: ["dry-run", "publish", "on-source-change"],
92
110
  };
93
111
  // CF-T1/T2: every command accepts the global `--context <name>` and `--json` (machine-readable refusals).
94
112
  function parseArgsOrExit(rest, known) {
@@ -160,14 +178,15 @@ Usage
160
178
  Show which site a command in [dir] would hit (verified), without writing anything.
161
179
 
162
180
  blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
163
- Start a dev server and print 3 auto-reloading views — Local, live-domain
164
- Preview, and the theme Editor. Press l / p / e to open each, q to quit.
165
- Edit a file and save → every open view reloads. Saves sync to a DRAFT theme
166
- only (never the live site). (dir defaults to cwd)
181
+ Start a local dev server that renders your local theme files with the site's
182
+ live content. Press l to open it, q to quit. Edit a file and save → the view
183
+ reloads. Saves sync to a DRAFT theme only (never the live site). (dir defaults to cwd)
184
+ The platform's remote preview and editor views for this command are retired; to
185
+ share a draft page, create a preview link for it (v1 API / MCP create_preview_link).
167
186
  The target site is verified ONCE at start; a long session keeps that target (restart
168
187
  it after changing credentials, context or the project binding).
169
188
  --port <n> local port (default 3030)
170
- --no-sync local preview only (skip draft sync + remote views)
189
+ --no-sync local preview only (skip draft sync)
171
190
  --name <name> name the draft when it is first created (ignored if it already exists)
172
191
 
173
192
  blocofy theme pull [dir] [--draft] [--instance <handle>]
@@ -270,6 +289,36 @@ Usage
270
289
  1 usage/auth/network/5xx; 2 the server refused (4xx) — the {error} JSON
271
290
  is printed to stderr.
272
291
 
292
+ blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff]
293
+ [--only all|missing|stale|pending] [--json]
294
+ Write every text of one language that needs translating (pages, image texts, records,
295
+ menus, site settings, theme texts) to ONE file (v1 API). The export asks for every kind,
296
+ so the API key needs the read scopes of all of them: pages:read, content:read,
297
+ navigation:write (menus have no read scope), settings:read, themes:read and models:read.
298
+ The platform answers in windows; the command follows every window and merges them, then
299
+ lists what was left out and why. --format xliff writes XLIFF 1.2 for translation tools.
300
+ --only pending only texts that are missing or need an update
301
+ --force replace --out if it already exists (refused otherwise, before any request)
302
+
303
+ blocofy translations import <file.json|file.xlf> [--dry-run] [--publish]
304
+ [--on-source-change skip|apply] [--json]
305
+ Import a translated package (v1 API; the write scopes of the kinds, and with --publish
306
+ also pages:write and content:write). Empty translations are skipped; the rest is sent in
307
+ chunks of at most 500 units (a page or record is never split, up to 5,000 units). Prints
308
+ the count per state. New pages and records are created as drafts and page content goes to
309
+ the draft; menus, site settings, theme texts, a live record's text and a live page's title
310
+ and SEO texts change at once.
311
+ --dry-run write nothing; report what would change
312
+ --publish also publish the pages and records this import wrote. A publishing
313
+ chunk is never resent automatically: if it gets no definite answer (or a
314
+ 429) the command stops and says so; running it again is safe.
315
+ --on-source-change apply also write texts whose source changed since the export
316
+ (default: skip)
317
+ If a chunk fails, the report of the chunks done before it is printed first (--json: one
318
+ object with "stopped": true on stdout).
319
+ Exit codes: 0 done; 1 usage/file/network/5xx (or a publishing chunk that was stopped);
320
+ 2 the server refused (4xx) — the {error} JSON is printed to stderr.
321
+
273
322
  blocofy settings pull [dir]
274
323
  blocofy settings push [dir] (--instance <handle> | --live [--yes])
275
324
  Download / upload config/settings.json (theme tokens/settings + color schemes).
@@ -904,7 +953,7 @@ async function themePush(rest) {
904
953
  // CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
905
954
  // target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
906
955
  const readOnly = Boolean(flags.diff) || dryRun;
907
- const opMode = flags.diff ? `read · diff vs ${instanceFlag ? `instance ${instanceFlag}` : "live"}` : dryRun ? `read · dry run (${mode === "instance" ? `instance ${instance}` : mode})` : mode === "instance" ? `instance ${instance}` : mode;
956
+ const opMode = flags.diff ? `read · diff vs ${instanceFlag ? `instance ${instanceFlag}` : mode === "draft" ? "CLI draft" : "live"}` : dryRun ? `read · dry run (${mode === "instance" ? `instance ${instance}` : mode})` : mode === "instance" ? `instance ${instance}` : mode;
908
957
  const target = await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: `${opMode}${flags.prune && !readOnly ? " · prune" : ""}` });
909
958
  const creds = target.dev;
910
959
  // 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
@@ -915,12 +964,18 @@ async function themePush(rest) {
915
964
  // ad-alanlar, önek EKLENMEZ.
916
965
  const idempotencyKey = typeof flags["idempotency-key"] === "string" ? flags["idempotency-key"] : `cli-${randomUUID()}`;
917
966
 
918
- // `--diff`: read-only preview vs the LIVE theme (or --instance). No write; the draft target is not
919
- // diffable (a draft GET would PROVISION the draft server-side — a read-only command must not mutate).
967
+ // `--diff`: read-only preview vs what the push would write to. PS-26: a draft push writes to the CLI draft, so
968
+ // that draft is found via `/api/dev/site` and diffed by handle — never via `?draft=1`, which PROVISIONS a draft
969
+ // server-side (a read-only command must not mutate). With no CLI draft yet the push would create one: vs live.
920
970
  if (flags.diff) {
921
971
  const diffTarget = ` of ${siteLabel(target.identity.site)}`;
922
- const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: instanceFlag, onRetry });
923
- console.log(instanceFlag ? `Diff vs theme ${instanceFlag}${diffTarget}:` : `Diff vs the LIVE theme${diffTarget} (push default writes to a DRAFT):`);
972
+ const cliDraft = mode === "draft" ? findCliDraft(await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry })) : null;
973
+ const diffInstance = instanceFlag ?? cliDraft?.id ?? null;
974
+ const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: diffInstance, onRetry });
975
+ if (instanceFlag) console.log(`Diff vs theme ${instanceFlag}${diffTarget}:`);
976
+ else if (cliDraft) console.log(`Diff vs draft ${cliDraft.id}${cliDraft.name ? ` "${cliDraft.name}"` : ""}${diffTarget} (the CLI draft this push writes to):`);
977
+ else if (mode === "draft") console.log(`No CLI draft yet — this push would create one. Diff vs the LIVE theme${diffTarget}:`);
978
+ else console.log(`Diff vs the LIVE theme${diffTarget}:`);
924
979
  const total = d.added.length + d.changed.length + d.removed.length;
925
980
  if (total === 0) {
926
981
  console.log("No differences — local theme matches the target.");
@@ -1092,8 +1147,15 @@ async function themePush(rest) {
1092
1147
  }
1093
1148
  if (result.convergence === "converged") console.log(" (already applied by an earlier push with the same idempotency key)");
1094
1149
  if (mode === "draft") {
1150
+ const h = typeof result.targetInstance === "string" ? result.targetInstance : null;
1151
+ const label = result.targetName ? ` "${result.targetName}"` : "";
1152
+ if (h) console.log(result.newDraft ? `Draft: ${h}${label} (new)` : `Draft: ${h}${label} (existing CLI draft, updated)`);
1153
+ // PS-22: the server reuses the CLI draft whatever its name; --name only names a draft this push creates.
1154
+ if (name && result.newDraft !== true) {
1155
+ console.log(`Note: --name was not applied — this push updated the existing CLI draft${h ? ` ${h}` : ""}. Rename it with: blocofy theme rename ${h ?? "<handle>"} "${name}"`);
1156
+ }
1095
1157
  console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
1096
- console.log("Publish it live with: blocofy theme publish");
1158
+ console.log(`Publish it live with: blocofy theme publish${h ? ` --instance ${h}` : ""}`);
1097
1159
  }
1098
1160
  return;
1099
1161
  }
@@ -2129,6 +2191,169 @@ async function status(rest) {
2129
2191
  console.log("");
2130
2192
  }
2131
2193
 
2194
+ // ── translations (#925): one language's texts as a package file, and back ──────────────────────────────────
2195
+
2196
+ const TRANSLATIONS_EXPORT_USAGE = "blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff] [--only all|missing|stale|pending] [--json]";
2197
+ const TRANSLATIONS_IMPORT_USAGE = "blocofy translations import <file.json|file.xlf> [--dry-run] [--publish] [--on-source-change skip|apply] [--json]";
2198
+
2199
+ function usageExit(usage) {
2200
+ console.error(`Usage: ${usage}`);
2201
+ process.exit(1);
2202
+ }
2203
+
2204
+ const outExists = (out) =>
2205
+ 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 } });
2206
+
2207
+ /** Write `text` to `out` through a temp file + rename, so `out` is never left half-written. */
2208
+ function writeOutFile(out, text, force) {
2209
+ const temp = `${out}.${process.pid}.blocofy-tmp`;
2210
+ try {
2211
+ writeFileSync(temp, text, { flag: "wx" });
2212
+ if (!force && existsSync(out)) throw outExists(out);
2213
+ renameSync(temp, out);
2214
+ } catch (error) {
2215
+ rmSync(temp, { force: true });
2216
+ throw error;
2217
+ }
2218
+ }
2219
+
2220
+ async function translationsExport(rest) {
2221
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.translationsExport);
2222
+ const locale = typeof flags.locale === "string" ? flags.locale : null;
2223
+ const out = typeof flags.out === "string" ? resolve(flags.out) : null;
2224
+ const format = flags.format === undefined ? "json" : flags.format;
2225
+ const only = flags.only === undefined ? "all" : flags.only;
2226
+ const force = flags.force === true;
2227
+ if (!locale || !out || positionals.length > 0 || (format !== "json" && format !== "xliff") || !TRANSLATION_ONLY.includes(only)) usageExit(TRANSLATIONS_EXPORT_USAGE);
2228
+ // A file the customer may have filled in is never replaced silently.
2229
+ if (!force && existsSync(out)) throw outExists(out);
2230
+ // A read of the site; the package goes to --out, never into the project tree (so no binding is needed or written).
2231
+ const { apiUrl, apiKey } = (await prepareTarget({ command: "translations export", commandClass: "read", dir: process.cwd(), flags, needs: "api", mode: `read · ${locale}` })).api;
2232
+ const { pkg, skipped } = await exportAllTranslations({ apiUrl, apiKey, locale, only, onRetry, onPage: ({ page, units }) => console.error(`Export: request ${page}, ${units} unit(s) so far`) });
2233
+ // toXliff refuses a text XML cannot carry BEFORE anything is written.
2234
+ const text = format === "xliff" ? toXliff(pkg) : `${JSON.stringify(pkg, null, 2)}\n`;
2235
+ writeOutFile(out, text, force);
2236
+ if (format === "xliff" && text.length > XLIFF_MAX_CHARS) {
2237
+ 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.`);
2238
+ }
2239
+ if (flags.json) {
2240
+ console.log(JSON.stringify({ file: out, format, target_locale: pkg.target_locale, units: pkg.units.length, skipped }));
2241
+ return;
2242
+ }
2243
+ console.log(`Exported ${pkg.units.length} unit(s) for ${pkg.target_locale} to ${out} (${format === "xliff" ? "XLIFF" : "JSON"}).`);
2244
+ const skipLines = exportSkipLines(skipped);
2245
+ if (skipLines.length > 0) {
2246
+ console.log("Left out:");
2247
+ for (const line of skipLines) console.log(` - ${line}`);
2248
+ }
2249
+ }
2250
+
2251
+ /** The one `--json` shape of an import run: complete, stopped part-way, or with nothing to send. */
2252
+ function importJson(tally, { dryRun, publish, locale, chunks, chunksDone, caches, splitGroups, stopped }) {
2253
+ return {
2254
+ dry_run: dryRun,
2255
+ target_locale: locale,
2256
+ chunks,
2257
+ chunks_done: chunksDone,
2258
+ stopped,
2259
+ counts: tally.counts,
2260
+ created: tally.created,
2261
+ stamped: tally.stamped,
2262
+ publication: !dryRun && publish ? { published: tally.published, blocked: tally.publishBlocked } : null,
2263
+ publish_skipped: tally.publishSkipped,
2264
+ notes: tally.notes,
2265
+ hints: tally.hints,
2266
+ cache: caches,
2267
+ split_groups: splitGroups,
2268
+ };
2269
+ }
2270
+
2271
+ async function translationsImport(rest) {
2272
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.translationsImport);
2273
+ const onSourceChange = flags["on-source-change"] === undefined ? "skip" : flags["on-source-change"];
2274
+ if (positionals.length !== 1 || (onSourceChange !== "skip" && onSourceChange !== "apply")) usageExit(TRANSLATIONS_IMPORT_USAGE);
2275
+ const file = resolve(positionals[0]);
2276
+ const dryRun = flags["dry-run"] === true;
2277
+ const publish = flags.publish === true;
2278
+ // Everything local is checked before the first request: the file, its chunks, every chunk's body size.
2279
+ let pkg;
2280
+ let chunked;
2281
+ try {
2282
+ pkg = readPackageFile(readFileSync(file, "utf8"), file);
2283
+ chunked = chunkPackage(pkg);
2284
+ chunked.chunks = fitChunksToBodyCap(chunked.chunks);
2285
+ } catch (error) {
2286
+ console.error(`Cannot read translation package ${file}: ${String(error?.message ?? error).replace(/\.?$/, ".")} Nothing was sent.`);
2287
+ process.exit(1);
2288
+ }
2289
+ const { chunks, skippedEmpty, splitGroups } = chunked;
2290
+ let tally = emptyImportTally();
2291
+ tally.counts.skipped_empty += skippedEmpty;
2292
+ const caches = [];
2293
+ let done = 0;
2294
+ const shape = { dryRun, publish, locale: pkg.target_locale, chunks: chunks.length, caches, splitGroups };
2295
+ if (chunks.length === 0) {
2296
+ if (flags.json) console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: 0, stopped: false })));
2297
+ else console.log("Every translation in the file is empty; nothing to import. Nothing was sent.");
2298
+ return;
2299
+ }
2300
+ const mode = `${dryRun ? "dry run" : publish ? "write + publish" : "write"} · ${pkg.target_locale}`;
2301
+ const { apiUrl, apiKey } = (await prepareTarget({ command: "translations import", commandClass: dryRun ? "read" : "remote-mutation", dir: process.cwd(), flags, needs: "api", mode })).api;
2302
+ try {
2303
+ for (const chunk of chunks) {
2304
+ const answer = await importTranslationChunk({ apiUrl, apiKey, chunk, dryRun, publish, onSourceChange, onRetry });
2305
+ tally = addImportReport(tally, answer);
2306
+ if (answer?.cache !== undefined && answer.cache !== null) caches.push(answer.cache);
2307
+ done += 1;
2308
+ if (chunks.length > 1) console.error(`${dryRun ? "Checked" : "Imported"} ${done} of ${chunks.length} chunks`);
2309
+ }
2310
+ } catch (error) {
2311
+ // What the chunks before the failure did (published pages, held drafts, a failed cache flush) is reported in
2312
+ // full: a rerun answers `unchanged` for them and could never tell it again.
2313
+ if (done > 0) {
2314
+ if (flags.json) console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: done, stopped: true })));
2315
+ else {
2316
+ console.log(`Report for the ${done} of ${chunks.length} chunks done before the stop:`);
2317
+ for (const line of importSummaryLines(tally, { dryRun, publish, locale: pkg.target_locale, splitGroups })) console.log(line);
2318
+ }
2319
+ }
2320
+ if (!JSON_MODE) {
2321
+ const invalid = error instanceof CliRefusal && Array.isArray(error.error?.details?.units) ? error.error.details.units : [];
2322
+ if (invalid.length > 0) {
2323
+ console.error("These texts were refused; fix the file and import again:");
2324
+ for (const u of invalid.slice(0, 20)) console.error(` ${u.id}: ${u.message ?? u.reason ?? "refused"}`);
2325
+ if (invalid.length > 20) console.error(` …and ${invalid.length - 20} more.`);
2326
+ }
2327
+ if (!dryRun && done > 0) {
2328
+ 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).`);
2329
+ }
2330
+ }
2331
+ const details = { chunk: done + 1, chunks: chunks.length, reason: error?.reason };
2332
+ if (error instanceof ImportOutcomeUnknown) {
2333
+ throw Object.assign(
2334
+ new Error(
2335
+ `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).`,
2336
+ ),
2337
+ { code: "TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN", details },
2338
+ );
2339
+ }
2340
+ if (error instanceof ImportNotApplied) {
2341
+ throw Object.assign(
2342
+ new Error(
2343
+ `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).`,
2344
+ ),
2345
+ { code: "TRANSLATIONS_IMPORT_NOT_APPLIED", details },
2346
+ );
2347
+ }
2348
+ throw error;
2349
+ }
2350
+ if (flags.json) {
2351
+ console.log(JSON.stringify(importJson(tally, { ...shape, chunksDone: done, stopped: false })));
2352
+ return;
2353
+ }
2354
+ for (const line of importSummaryLines(tally, { dryRun, publish, locale: pkg.target_locale, splitGroups })) console.log(line);
2355
+ }
2356
+
2132
2357
  const [first, ...rest] = args;
2133
2358
 
2134
2359
  function commandKey(a, b) {
@@ -2163,6 +2388,8 @@ const COMMANDS = {
2163
2388
  "site plan": sitePlan,
2164
2389
  "site apply": siteApply,
2165
2390
  "site publish": sitePublish,
2391
+ "translations export": translationsExport,
2392
+ "translations import": translationsImport,
2166
2393
  };
2167
2394
 
2168
2395
  if (first === "--version" || first === "-v") {
package/lib/args.mjs CHANGED
@@ -8,8 +8,9 @@
8
8
  * Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
9
9
  * gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
10
10
  * CF-T1: `--keychain` (login) ve `--adopt` (link) de değersizdir. CF-T3: `--force` (pages push) da; gerekçesi `--reason <text>`.
11
+ * #925: `--publish` (translations import) da değersizdir.
11
12
  */
12
- const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force"]);
13
+ const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force", "publish"]);
13
14
 
14
15
  /**
15
16
  * `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
package/lib/target.mjs CHANGED
@@ -217,13 +217,16 @@ function isEmptyOrMissing(dir) {
217
217
  * Command-class policy (before any credential or network use). Returns `{ newBinding }`: true when a local-write
218
218
  * into a missing/empty unbound dir will record provenance after it succeeds.
219
219
  */
220
+ /** PS-22 note: with env credentials there is no context to name — `link` binds to the site those credentials reach. */
221
+ const ENV_LINK_HINT = (dir) => `; with BLOCOFY_URL + BLOCOFY_TOKEN set: blocofy link ${dir}`;
222
+
220
223
  export function enforceBindingPolicy({ commandClass, binding, dir, command }) {
221
224
  if (!COMMAND_CLASSES.has(commandClass)) throw new Error(`internal: unknown command class ${commandClass}`);
222
225
  if (binding) return { newBinding: false };
223
226
  if (commandClass === "remote-mutation") {
224
227
  throw new TargetError(
225
228
  "TARGET_BINDING_REQUIRED",
226
- `\`${command}\` changes a site, but ${dir} is not bound to one. Nothing was sent. Bind it first: blocofy link ${dir} --context <name>`,
229
+ `\`${command}\` changes a site, but ${dir} is not bound to one. Nothing was sent. Bind it first: blocofy link ${dir} --context <name>${ENV_LINK_HINT(dir)}`,
227
230
  { dir },
228
231
  );
229
232
  }
@@ -231,7 +234,7 @@ export function enforceBindingPolicy({ commandClass, binding, dir, command }) {
231
234
  if (isEmptyOrMissing(dir)) return { newBinding: true };
232
235
  throw new TargetError(
233
236
  "TARGET_BINDING_REQUIRED",
234
- `${dir} is not empty and not bound to a site. Nothing was written. Bind it first (blocofy link ${dir} --context <name>) or pull into an empty directory.`,
237
+ `${dir} is not empty and not bound to a site. Nothing was written. Bind it first (blocofy link ${dir} --context <name>${ENV_LINK_HINT(dir)}) or pull into an empty directory.`,
235
238
  { dir },
236
239
  );
237
240
  }
@@ -322,9 +322,10 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
322
322
  const remoteOnlyKept = [];
323
323
  const remoteOnlyRemoved = [];
324
324
  let probeInstance = instance;
325
+ let cliDraft = null;
325
326
  if (!instance && draft) {
326
- const status = await fetchSiteStatus({ url, token, onRetry });
327
- probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
327
+ cliDraft = findCliDraft(await fetchSiteStatus({ url, token, onRetry }));
328
+ probeInstance = cliDraft?.id ?? null;
328
329
  }
329
330
  let remote = {};
330
331
  if (probeInstance || !draft) {
@@ -391,7 +392,22 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
391
392
  throw applyError(error);
392
393
  }
393
394
  if (!res.ok) throw applyError(await httpError(res));
394
- return { ...(await res.json()), ...extras };
395
+ const applied = await res.json();
396
+ // PS-22: which theme received the files. A current server names it on the apply; an older one only in the plan
397
+ // (null for "a new draft"); oldest of all, the CLI draft looked up before the push is the one reused.
398
+ // A server whose plan carries no `newDraft` created one exactly when a draft push found no CLI draft to reuse.
399
+ const newDraft = typeof plan.newDraft === "boolean" ? plan.newDraft : Boolean(draft && !instance && cliDraft == null);
400
+ const targetInstance = applied.targetInstance ?? plan.targetInstance ?? (newDraft ? null : probeInstance) ?? null;
401
+ const targetName = newDraft ? name : cliDraft && cliDraft.id === targetInstance ? cliDraft.name ?? null : null;
402
+ return { ...applied, ...extras, targetInstance, newDraft, targetName };
403
+ }
404
+
405
+ /**
406
+ * The CLI draft a draft push writes into: the first `source: "import"` draft — `/api/dev/site` lists drafts by id,
407
+ * the server's `ensureDraftInstance` reuses the first non-live import instance by id. `null` = a push would create one.
408
+ */
409
+ export function findCliDraft(status) {
410
+ return (status?.drafts ?? []).find((d) => d.source === "import") ?? null;
395
411
  }
396
412
 
397
413
  /**