@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 +82 -11
- package/bin/blocofy.mjs +240 -13
- package/lib/args.mjs +2 -1
- package/lib/target.mjs +5 -2
- package/lib/theme-sync.mjs +19 -3
- package/lib/translations.mjs +1059 -0
- package/package.json +1 -1
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
|
919
|
-
//
|
|
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
|
|
923
|
-
|
|
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(
|
|
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
|
|
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
|
}
|
package/lib/theme-sync.mjs
CHANGED
|
@@ -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
|
-
|
|
327
|
-
probeInstance =
|
|
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
|
-
|
|
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
|
/**
|