@blocofy/cli 0.14.0 → 0.15.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 +118 -2
- package/bin/blocofy.mjs +399 -14
- package/lib/args.mjs +2 -2
- package/lib/theme-sync.mjs +158 -5
- package/lib/theme-work.mjs +369 -0
- package/package.json +1 -1
package/lib/theme-sync.mjs
CHANGED
|
@@ -50,9 +50,9 @@ async function errorText(res) {
|
|
|
50
50
|
|
|
51
51
|
/** `errorText` as an Error that keeps the HTTP status (exit codes: 4xx → 2, 5xx → 1). */
|
|
52
52
|
async function responseError(res) {
|
|
53
|
+
// #989: keep `reason`/`candidates` (errorText keeps only `error`). Read from a clone so the fallback below still can.
|
|
54
|
+
const body = await res.clone().json().catch(() => null);
|
|
53
55
|
if (res.status === 409) {
|
|
54
|
-
// #989: keep `reason`/`candidates` (errorText keeps only `error`). Read from a clone so the fallback below still can.
|
|
55
|
-
const body = await res.clone().json().catch(() => null);
|
|
56
56
|
if (body?.error === DRAFT_TARGET_AMBIGUOUS) return withDraftTargetDetails(Object.assign(new Error(DRAFT_TARGET_AMBIGUOUS), { status: 409, code: DRAFT_TARGET_AMBIGUOUS }), body);
|
|
57
57
|
if (body?.error === PUBLISH_TARGET_UNCONFIRMED) {
|
|
58
58
|
return Object.assign(new Error(PUBLISH_TARGET_UNCONFIRMED), {
|
|
@@ -67,7 +67,11 @@ async function responseError(res) {
|
|
|
67
67
|
const detail = await errorText(res);
|
|
68
68
|
const err = new Error(typeof detail === "string" ? detail : detail?.message ?? JSON.stringify(detail));
|
|
69
69
|
err.status = res.status;
|
|
70
|
+
const retryAfter = res.headers.get("retry-after");
|
|
71
|
+
if (retryAfter) err.retryAfter = retryAfter;
|
|
70
72
|
if (typeof detail === "object" && detail && typeof detail.code === "string") err.code = detail.code;
|
|
73
|
+
// `{ error: "<sentence>", code }` (e.g. dev/publish `work_not_publishable`): the code sits beside the sentence.
|
|
74
|
+
else if (typeof body?.code === "string" && /^[a-z][a-z0-9_]*$/.test(body.code)) err.code = body.code;
|
|
71
75
|
else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
|
|
72
76
|
return err;
|
|
73
77
|
}
|
|
@@ -89,6 +93,8 @@ async function httpError(res) {
|
|
|
89
93
|
err.status = res.status;
|
|
90
94
|
err.code = typeof body?.error === "string" ? body.error : null;
|
|
91
95
|
err.body = body;
|
|
96
|
+
const retryAfter = res.headers.get("retry-after");
|
|
97
|
+
if (retryAfter) err.retryAfter = retryAfter;
|
|
92
98
|
return withDraftTargetDetails(err, body);
|
|
93
99
|
}
|
|
94
100
|
|
|
@@ -160,7 +166,7 @@ const isRestore = (c) => c.restore === true || SITE_STATE_DRAFT_NAME.test(c.name
|
|
|
160
166
|
/**
|
|
161
167
|
* #989 re-review P3 — one line for a `theme dev` draft-sync failure, in the push/pull wording: 503
|
|
162
168
|
* `draft_target_unverifiable` (transient; the next save tries again) and 422 `draft_target_is_live` get their own
|
|
163
|
-
* sentence, anything else its message. Never swallowed.
|
|
169
|
+
* sentence, the theme-capacity refusals (`themeCapacityRefusal`) theirs, anything else its message. Never swallowed.
|
|
164
170
|
*/
|
|
165
171
|
export function draftSyncErrorLine(err) {
|
|
166
172
|
if (err?.code === "draft_target_unverifiable") {
|
|
@@ -169,9 +175,151 @@ export function draftSyncErrorLine(err) {
|
|
|
169
175
|
if (err?.code === "draft_target_is_live") {
|
|
170
176
|
return "draft sync refused: that is the LIVE theme, and a draft sync never writes live. Nothing was written; pass a draft's handle with --instance (`blocofy status`).";
|
|
171
177
|
}
|
|
178
|
+
const capacity = themeCapacityRefusal(err);
|
|
179
|
+
if (capacity) return `draft sync: ${capacity.short}`;
|
|
172
180
|
return `draft sync failed: ${err?.message ?? String(err)}`;
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
const trNumber = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 });
|
|
184
|
+
const isAmount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
185
|
+
const bytesLabel = (b) => (b >= 1024 ** 3 ? `${trNumber.format(b / 1024 ** 3)} GB` : `${trNumber.format(b / 1024 ** 2)} MB`);
|
|
186
|
+
|
|
187
|
+
/** The admission's typed meter in words (V2 storage bytes; V1 / other `used / limit`), or null. */
|
|
188
|
+
function quotaMeter(d) {
|
|
189
|
+
if (!d || typeof d !== "object") return null;
|
|
190
|
+
if (isAmount(d.usedBytes) && isAmount(d.limitBytes)) {
|
|
191
|
+
const need = isAmount(d.estimateBytes) ? `; yeni taslak yaklaşık ${bytesLabel(d.estimateBytes)} gerektiriyor` : "";
|
|
192
|
+
return `Kullanılan depolama: ${bytesLabel(d.usedBytes)} / ${bytesLabel(d.limitBytes)}${need}.`;
|
|
193
|
+
}
|
|
194
|
+
if (isAmount(d.used) && isAmount(d.limit)) {
|
|
195
|
+
const what = d.resource === "theme_drafts" ? "Taslak tema sayısı" : typeof d.resource === "string" ? d.resource : "Kullanım";
|
|
196
|
+
return `${what}: ${d.used} / ${d.limit}.`;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const CAPACITY_CODES = new Set(["target_missing", "quota_exceeded", "capacity_unavailable", "resource_busy", "source_stale"]);
|
|
202
|
+
/** Transient admission refusals: every attempt answering one of these wrote nothing (a definite answer). */
|
|
203
|
+
export const CAPACITY_RETRYABLE = new Set(["capacity_unavailable", "resource_busy"]);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Theme-capacity program (ADR-0013) — the dev theme endpoint's refusals around the CLI draft, in words:
|
|
207
|
+
* - 404 `target_missing`: `pull --draft` found no CLI draft. A read never creates a theme; `push --draft` does.
|
|
208
|
+
* - 422 `quota_exceeded`, by `details.resource`:
|
|
209
|
+
* `theme_bytes` — the site's theme space (a technical allowance, not the plan) is full. Any theme write can meet
|
|
210
|
+
* it, a push to an existing draft included: no "new draft" wording, no plan upgrade; free space by deleting
|
|
211
|
+
* unused draft themes (cancelled works' themes included; cancel releases only a work's reserved publish space,
|
|
212
|
+
* archive frees no theme bytes), or contact support.
|
|
213
|
+
* `theme_drafts` (the legacy draft count of a site not on theme space) — not tied to the plan: remove a draft
|
|
214
|
+
* you no longer need; never a plan upgrade.
|
|
215
|
+
* `storage_mb` (the plan's storage) — the NEW draft was refused for the plan: the plan message.
|
|
216
|
+
* - 503 `capacity_unavailable` / `resource_busy`: transient (Retry-After), never the plan. lib/http.mjs has already
|
|
217
|
+
* resent the request within its retry policy; this is the last answer.
|
|
218
|
+
* - 409 `source_stale`: the live theme the new draft copies changed meanwhile; running the command again copies it anew.
|
|
219
|
+
* The platform wrote nothing for that answer. Returns `{ lines, short, message, details }`: `lines` the plain Turkish
|
|
220
|
+
* explanation (stderr, human mode; first line non-technical), `short` one line for `theme dev`, `message`/`details` the
|
|
221
|
+
* technical `error [code]` line and `--json` envelope. `null` for any other code (draft_target_ambiguous keeps its own).
|
|
222
|
+
* `pushCommand` is the user's push command line (its directory argument kept). A refusal of a RESENT push apply
|
|
223
|
+
* (`earlierAttempt`) says nothing about that earlier attempt, so it never claims "nothing was written" then.
|
|
224
|
+
*/
|
|
225
|
+
export function themeCapacityRefusal(error, { pushCommand = "blocofy theme push" } = {}) {
|
|
226
|
+
const code = error?.code;
|
|
227
|
+
if (!CAPACITY_CODES.has(code)) return null;
|
|
228
|
+
const earlier = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
|
|
229
|
+
const done =
|
|
230
|
+
earlier === "committed"
|
|
231
|
+
? "Bu deneme hiçbir şey yazmadı; ancak bu push'un önceki bir denemesi işlendi (yanıtı öyle söyledi)."
|
|
232
|
+
: earlier === "unknown"
|
|
233
|
+
? "Bu deneme hiçbir şey yazmadı; ancak bu push'un önceki bir denemesi kesin yanıt alamadı, yazıp yazmadığı bilinmiyor."
|
|
234
|
+
: "Hiçbir şey yazılmadı.";
|
|
235
|
+
const doneEn =
|
|
236
|
+
earlier === "committed"
|
|
237
|
+
? "This attempt wrote nothing, but an earlier attempt of this push was committed (its answer said so)."
|
|
238
|
+
: earlier === "unknown"
|
|
239
|
+
? "This attempt wrote nothing, but an earlier attempt of this push got no definite answer, so whether it wrote is unknown."
|
|
240
|
+
: "Nothing was written.";
|
|
241
|
+
const check = earlier ? [" Tekrar çalıştırmadan önce sitenin taslaklarını `blocofy status` ile kontrol et."] : [];
|
|
242
|
+
const checkEn = earlier ? " Check the site's drafts with `blocofy status` before running the push again." : "";
|
|
243
|
+
const extra = earlier ? { earlierAttempt: earlier } : {};
|
|
244
|
+
|
|
245
|
+
if (code === "target_missing") {
|
|
246
|
+
return {
|
|
247
|
+
lines: [
|
|
248
|
+
"Bu sitede henüz bir CLI taslağı yok; indirilecek taslak bulunamadı. Hiçbir dosya yazılmadı.",
|
|
249
|
+
` Önce taslağı oluştur: ${pushCommand} --draft`,
|
|
250
|
+
" (Canlı temayı indirmek istiyorsan --draft olmadan çalıştır.)",
|
|
251
|
+
],
|
|
252
|
+
short: "Bu sitede henüz bir CLI taslağı yok.",
|
|
253
|
+
message: `No CLI draft on this site yet (a read never creates one). Nothing was written. Create it first: ${pushCommand} --draft`,
|
|
254
|
+
details: {},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (code === "quota_exceeded") {
|
|
258
|
+
const d = error?.body?.details && typeof error.body.details === "object" ? error.body.details : null;
|
|
259
|
+
if (d?.resource === "theme_bytes") {
|
|
260
|
+
// The site's theme space (a technical allowance, not the plan): any theme write can meet it — a push to an
|
|
261
|
+
// existing draft included — so no "new draft" wording and no plan upgrade; space is freed by removing drafts.
|
|
262
|
+
const head = "Sitenin tema alanı dolu; bu değişiklik için yer kalmadı.";
|
|
263
|
+
const meter = isAmount(d.usedBytes) && isAmount(d.allowanceBytes)
|
|
264
|
+
? `Kullanılan tema alanı: ${bytesLabel(d.usedBytes)} / ${bytesLabel(d.allowanceBytes)}${isAmount(d.estimateBytes) ? `; bu değişiklik yaklaşık ${bytesLabel(d.estimateBytes)} gerektiriyor` : ""}.`
|
|
265
|
+
: null;
|
|
266
|
+
return {
|
|
267
|
+
lines: [
|
|
268
|
+
`${head} ${done}`,
|
|
269
|
+
...(meter ? [` ${meter}`] : []),
|
|
270
|
+
" Yer açmak için kullanmadığın taslak temaları (iptal edilmiş çalışmaların temaları dahil) tema kütüphanesinden sil (admin paneli → Tasarım); açık bir çalışmayı iptal etmek ayrıca onun için ayrılan yayın alanını bırakır.",
|
|
271
|
+
" Sorun sürerse destek ekibine yaz.",
|
|
272
|
+
...check,
|
|
273
|
+
],
|
|
274
|
+
short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı; yer açmak için kullanılmayan bir taslağı sil.`,
|
|
275
|
+
message: `The site's theme space is full; this change does not fit (quota_exceeded, theme_bytes). ${doneEn} Free space by deleting unused draft themes from the theme library (cancelled works' themes included); cancelling an open work also releases its reserved publish space. Or contact support.${checkEn}`,
|
|
276
|
+
details: { ...d, ...extra },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const meter = quotaMeter(d);
|
|
280
|
+
if (d?.resource === "theme_drafts") {
|
|
281
|
+
// The legacy draft count of a site not (yet) on theme space: not tied to the plan, so no upgrade advice.
|
|
282
|
+
const head = "Sitenin taslak tema sınırına ulaşıldı; yeni taslak oluşturulamadı.";
|
|
283
|
+
return {
|
|
284
|
+
lines: [`${head} ${done}`, ...(meter ? [` ${meter}`] : []), " Artık kullanmadığın bir taslak temayı sil (admin paneli → Tasarım).", ...check],
|
|
285
|
+
short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı; kullanmadığın bir taslağı sil.`,
|
|
286
|
+
message: `The site's draft limit refused the new draft this push needs (quota_exceeded, theme_drafts). ${doneEn} Remove a draft you no longer need.${checkEn}`,
|
|
287
|
+
details: { ...d, ...extra },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const head = `${d?.resource === "storage_mb" || isAmount(d?.usedBytes) ? "Depolama alanın dolu" : "Planının sınırına ulaşıldı"}; yeni taslak oluşturulamadı.`;
|
|
291
|
+
return {
|
|
292
|
+
lines: [`${head} ${done}`, ...(meter ? [` ${meter}`] : []), " Yeni taslak için planını yükseltebilirsin: admin paneli → Ayarlar → Plan & faturalandırma.", ...check],
|
|
293
|
+
short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı.`,
|
|
294
|
+
message: `The plan's capacity refused the new draft this push needs (quota_exceeded). ${doneEn}${checkEn}`,
|
|
295
|
+
details: { ...(d ?? {}), ...extra },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (code === "source_stale") {
|
|
299
|
+
const head = "Canlı tema bu sırada değişti; yeni taslak onun güncel hâlinden oluşturulamadı.";
|
|
300
|
+
return {
|
|
301
|
+
lines: [`${head} ${done}`, ` Komutu yeniden çalıştır; taslak canlının güncel hâlinden oluşturulur: ${pushCommand}`, ...check],
|
|
302
|
+
short: `${head} Hiçbir şey yazılmadı; bir sonraki kayıt yeniden dener.`,
|
|
303
|
+
message: `The live theme a new draft copies changed while it was prepared (source_stale). ${doneEn} Run the command again.${checkEn}`,
|
|
304
|
+
details: extra,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// capacity_unavailable / resource_busy: transient, already resent within the CLI's retry policy.
|
|
308
|
+
// Neither is about the plan or a full space, and either can answer a push to an existing draft: no "new draft" wording.
|
|
309
|
+
const head =
|
|
310
|
+
code === "resource_busy"
|
|
311
|
+
? "Site şu anda meşgul (başka bir yazım sürüyor); işlem şimdilik yapılamadı."
|
|
312
|
+
: "Platform tema alanını şu an doğrulayamadı; bu geçici bir durum, işlem şimdilik yapılamadı.";
|
|
313
|
+
const raw = String(error?.retryAfter ?? "").trim();
|
|
314
|
+
const seconds = /^\d+$/.test(raw) ? Number(raw) : null;
|
|
315
|
+
return {
|
|
316
|
+
lines: [`${head} ${done}`, ` Biraz sonra tekrar dene${seconds ? ` (en az ${seconds} sn sonra)` : ""}: ${pushCommand}`, ...check],
|
|
317
|
+
short: `${head} Hiçbir şey yazılmadı. Biraz sonra tekrar dene; bir sonraki kayıt yeniden dener.`,
|
|
318
|
+
message: `${code === "resource_busy" ? "The site is busy with another write" : "The platform could not check the theme space right now (temporary)"}; the change was not applied (${code}). ${doneEn} Try again later.${checkEn}`,
|
|
319
|
+
details: { ...(seconds != null ? { retryAfterSeconds: seconds } : {}), ...extra },
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
175
323
|
/** A local #989 refusal with the server's shape (used where the CLI selects the draft itself, e.g. `push --diff`). */
|
|
176
324
|
function draftTargetAmbiguous(reason, candidates) {
|
|
177
325
|
for (const c of candidates) c.restore = SITE_STATE_DRAFT_NAME.test(c.name ?? "");
|
|
@@ -527,6 +675,8 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
527
675
|
// An apply attempt without a certain answer (network error, 429/502/503/504) is resent under the same key. A
|
|
528
676
|
// refusal of the resend says nothing about that earlier attempt: "unknown", or "committed" when its answer said so.
|
|
529
677
|
let earlierAttempt = null;
|
|
678
|
+
// ADR-0013: a 503 capacity_unavailable / resource_busy is a definite "nothing was written" answer, not an unknown one.
|
|
679
|
+
let definiteNothing = false;
|
|
530
680
|
const applyError = (err) => {
|
|
531
681
|
Object.assign(err, { phase: "apply" });
|
|
532
682
|
// target_changed names the current target; the one this push planned against is known only here.
|
|
@@ -538,11 +688,14 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
538
688
|
try {
|
|
539
689
|
res = await post({ ...target, ...bindings }, idempotencyKey, {
|
|
540
690
|
onRetry: (info) => {
|
|
541
|
-
earlierAttempt ??= "unknown";
|
|
691
|
+
if (!definiteNothing) earlierAttempt ??= "unknown";
|
|
692
|
+
definiteNothing = false;
|
|
542
693
|
onRetry?.(info);
|
|
543
694
|
},
|
|
544
695
|
isFinal: async (attempt) => {
|
|
545
|
-
|
|
696
|
+
const answer = await attempt.json().catch(() => null);
|
|
697
|
+
if (answer?.committed === true) earlierAttempt = "committed";
|
|
698
|
+
definiteNothing = CAPACITY_RETRYABLE.has(answer?.error);
|
|
546
699
|
return false;
|
|
547
700
|
},
|
|
548
701
|
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { fetchWithRetry } from "./http.mjs";
|
|
6
|
+
import { CliRefusal } from "./media-uses.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Theme work sessions ("çalışma") — the v1 client and the project's saved work handle.
|
|
10
|
+
*
|
|
11
|
+
* POST /api/v1/theme-work (themes:write, Idempotency-Key required) → 201 { work } | 200 { work, replayed }
|
|
12
|
+
* GET /api/v1/theme-work/{handle} (themes:read) → { work }
|
|
13
|
+
* POST /api/v1/theme-work/{handle}/resume (themes:read, { require_fresh? }) → { work }
|
|
14
|
+
* POST /api/v1/theme-work/{handle}/cancel (themes:write, { expected_state_version }) → { work }
|
|
15
|
+
*
|
|
16
|
+
* Authentication is the v1 API key (`blcf_live_…`, Bearer) — the same pair `site plan` and `pages media-uses` use.
|
|
17
|
+
* The dev token (`bcf_…`) has no theme-work endpoint: the work is bound to the credential that started it (here the
|
|
18
|
+
* API key), and only that credential (or the site owner, from the panel) can continue it.
|
|
19
|
+
*
|
|
20
|
+
* A work is always named by its exact handle (`wk_…`), never "the latest". The project remembers the handle it
|
|
21
|
+
* started or resumed last in `.blocofy/local.json` (git-ignored, per user, no secret: the handle alone grants nothing)
|
|
22
|
+
* so the CLI can name it in hints; every command still takes the handle explicitly.
|
|
23
|
+
*
|
|
24
|
+
* Retries (lib/http.mjs: network errors, 429/502/503/504): the start carries the Idempotency-Key, so a resend returns
|
|
25
|
+
* the SAME work (`replayed`), never a second one; get and resume are reads; cancel is a compare-and-set on
|
|
26
|
+
* `state_version` (a resend after a lost answer meets `work_state_conflict` with the work already cancelled).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const WORK_HANDLE_RE = /^wk_[a-z2-7]{26}$/;
|
|
30
|
+
export const WORK_KEY_RE = /^[A-Za-z0-9._:-]{8,120}$/;
|
|
31
|
+
export const WORK_INTENT_MAX = 120;
|
|
32
|
+
export const DEFAULT_WORK_INTENT = "CLI ile tema çalışması";
|
|
33
|
+
|
|
34
|
+
export const isWorkHandle = (v) => typeof v === "string" && WORK_HANDLE_RE.test(v);
|
|
35
|
+
export const newWorkKey = () => `cli-work-${randomUUID()}`;
|
|
36
|
+
|
|
37
|
+
const base = (apiUrl) => String(apiUrl).replace(/\/+$/, "");
|
|
38
|
+
|
|
39
|
+
async function readJsonOrThrow(res) {
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let json;
|
|
42
|
+
try {
|
|
43
|
+
json = text ? JSON.parse(text) : null;
|
|
44
|
+
} catch {
|
|
45
|
+
json = null;
|
|
46
|
+
}
|
|
47
|
+
if (res.ok) {
|
|
48
|
+
if (json === null || typeof json !== "object") throw new Error(`HTTP ${res.status}: empty or non-JSON response`);
|
|
49
|
+
return json;
|
|
50
|
+
}
|
|
51
|
+
if (res.status >= 400 && res.status < 500) {
|
|
52
|
+
const error = json?.error && typeof json.error === "object" ? json.error : { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` };
|
|
53
|
+
throw new CliRefusal(res.status, error);
|
|
54
|
+
}
|
|
55
|
+
const detail = json?.error?.message ?? (text ? text.slice(0, 200) : "");
|
|
56
|
+
const code = typeof json?.error?.code === "string" ? json.error.code : undefined;
|
|
57
|
+
throw Object.assign(new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`), {
|
|
58
|
+
status: res.status,
|
|
59
|
+
...(code ? { code, error: json.error } : {}),
|
|
60
|
+
retryAfter: res.headers.get("retry-after"),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function request(method, url, { apiKey, body, headers = {}, onRetry = null }) {
|
|
65
|
+
const h = { authorization: `Bearer ${apiKey}`, accept: "application/json", ...headers };
|
|
66
|
+
if (body !== undefined) h["content-type"] = "application/json";
|
|
67
|
+
const res = await fetchWithRetry(url, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) }, { onRetry });
|
|
68
|
+
return readJsonOrThrow(res);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const workUrl = (apiUrl, handle, tail = "") => `${base(apiUrl)}/api/v1/theme-work/${encodeURIComponent(handle)}${tail}`;
|
|
72
|
+
|
|
73
|
+
/** Start a work from the site's CURRENT live theme. The same key again returns the same work (`replayed: true`). */
|
|
74
|
+
export function startWork({ apiUrl, apiKey, intent, idempotencyKey, onRetry = null }) {
|
|
75
|
+
return request("POST", `${base(apiUrl)}/api/v1/theme-work`, { apiKey, body: { intent }, headers: { "idempotency-key": idempotencyKey }, onRetry });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getWork({ apiUrl, apiKey, handle, onRetry = null }) {
|
|
79
|
+
return request("GET", workUrl(apiUrl, handle), { apiKey, onRetry });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resumeWork({ apiUrl, apiKey, handle, requireFresh = false, onRetry = null }) {
|
|
83
|
+
return request("POST", workUrl(apiUrl, handle, "/resume"), { apiKey, body: requireFresh ? { require_fresh: true } : {}, onRetry });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function cancelWork({ apiUrl, apiKey, handle, expectedStateVersion, onRetry = null }) {
|
|
87
|
+
return request("POST", workUrl(apiUrl, handle, "/cancel"), { apiKey, body: { expected_state_version: expectedStateVersion }, onRetry });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── review + human approval ─────────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
//
|
|
92
|
+
// POST /api/v1/theme-work/{handle}/seal (themes:write) → { status: "ready_for_review", already_sealed, work, package }
|
|
93
|
+
// POST /api/v1/theme-work/{handle}/approvals (themes:write) → { status: "approval_required", approval, work }
|
|
94
|
+
// GET /api/v1/theme-work/{handle}/publish-status (themes:read) → { work, phase, phase_label, package, approval }
|
|
95
|
+
//
|
|
96
|
+
// The CLI never publishes a work: it asks for a person's approval and reads the status. The approval URL carries no
|
|
97
|
+
// token (holding the link is not a permission); a signed-in site member with the theme permission decides on it. A
|
|
98
|
+
// resend after a lost answer is safe: a sealed work is answered as is, and an unused approval request is returned
|
|
99
|
+
// again (the server's idempotency).
|
|
100
|
+
|
|
101
|
+
/** Seal alone ("İncelemeye hazır"): the work's content is frozen for review. Requests no approval. */
|
|
102
|
+
export function sealWork({ apiUrl, apiKey, handle, onRetry = null }) {
|
|
103
|
+
return request("POST", workUrl(apiUrl, handle, "/seal"), { apiKey, body: {}, onRetry });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Ask a person to publish the work (an open work is sealed first). Never publishes. */
|
|
107
|
+
export function requestApproval({ apiUrl, apiKey, handle, onRetry = null }) {
|
|
108
|
+
return request("POST", workUrl(apiUrl, handle, "/approvals"), { apiKey, body: {}, onRetry });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Where the work stands (read-only). */
|
|
112
|
+
export function getPublishStatus({ apiUrl, apiKey, handle, onRetry = null }) {
|
|
113
|
+
return request("GET", workUrl(apiUrl, handle, "/publish-status"), { apiKey, onRetry });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const APPROVAL_HANDLE_RE = /^ap_[a-z2-7]{26}$/;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The approval page URL, only when it is exactly this platform's approval page: same origin as the API, the path
|
|
120
|
+
* `/approve/ap_…`, no query, fragment or credentials. Anything else is not opened (and the caller says so).
|
|
121
|
+
*/
|
|
122
|
+
export function safeApprovalUrl(raw, apiUrl) {
|
|
123
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
124
|
+
let origin;
|
|
125
|
+
let u;
|
|
126
|
+
try {
|
|
127
|
+
origin = new URL(base(apiUrl)).origin;
|
|
128
|
+
u = new URL(raw, `${origin}/`);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
133
|
+
if (u.origin !== origin || u.username || u.password || u.search || u.hash) return null;
|
|
134
|
+
const m = u.pathname.match(/^\/approve\/([^/]+)$/);
|
|
135
|
+
if (!m || !APPROVAL_HANDLE_RE.test(m[1])) return null;
|
|
136
|
+
return u.href;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Where an approval request ended, from a publish-status answer: "published" | "stale" | "expired" | "declined" |
|
|
141
|
+
* "superseded" | "cancelled" | "failed" | "pending". `approvalId` is the request this command made.
|
|
142
|
+
*/
|
|
143
|
+
export function approvalWaitState(status, approvalId) {
|
|
144
|
+
const phase = status?.phase;
|
|
145
|
+
if (phase === "published") return "published";
|
|
146
|
+
if (phase === "cancelled") return "cancelled";
|
|
147
|
+
if (phase === "failed") return "failed";
|
|
148
|
+
const a = status?.approval ?? null;
|
|
149
|
+
if (!a || a.id !== approvalId) return phase === "preparing" ? "superseded" : "pending";
|
|
150
|
+
if (a.state === "consumed") return a.outcome?.status === "stale" ? "stale" : a.outcome?.status === "published" ? "published" : "pending";
|
|
151
|
+
if (a.state === "revoked") return a.revoke_reason === "stale" ? "stale" : a.revoke_reason === "declined" ? "declined" : "superseded";
|
|
152
|
+
if (a.expired === true) return "expired";
|
|
153
|
+
if (phase === "needs_update") return "stale";
|
|
154
|
+
return "pending";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The end of a wait in words: `{ ok, lines, code, message }` — `lines` plain Turkish (stdout on success, stderr
|
|
159
|
+
* otherwise), `code`/`message` the technical `error [code]` line of an end that is not "published".
|
|
160
|
+
*/
|
|
161
|
+
export function approvalOutcome(state, { handle, status = null } = {}) {
|
|
162
|
+
const theme = status?.approval?.outcome?.theme ?? null;
|
|
163
|
+
const again = ` Yeniden onay iste: blocofy theme work request-approval ${handle}`;
|
|
164
|
+
const fresh = " Değişikliği sürdürmek için güncel canlı sürümden yeni bir çalışma başlat: blocofy theme work start";
|
|
165
|
+
switch (state) {
|
|
166
|
+
case "published":
|
|
167
|
+
return { ok: true, lines: [`✓ Yayınlandı: çalışma ${handle} onaylandı ve canlı sitede${theme ? ` (tema ${theme})` : ""}.`] };
|
|
168
|
+
case "stale":
|
|
169
|
+
return { ok: false, code: "approval_stale", message: "The live site changed after the approval was requested; nothing was published (approval_stale).", lines: ["Canlı site bu arada değişti; onay geçersiz oldu ve hiçbir şey yayınlanmadı. Hiçbir şeyin üzerine yazılmadı.", fresh] };
|
|
170
|
+
case "expired":
|
|
171
|
+
return { ok: false, code: "approval_expired", message: "The approval request expired before anyone decided; nothing was published (approval_expired).", lines: ["Onay isteğinin süresi doldu; kimse karar vermedi ve hiçbir şey yayınlanmadı.", again] };
|
|
172
|
+
case "declined":
|
|
173
|
+
return { ok: false, code: "approval_declined", message: "The approval request was declined; nothing was published (approval_declined).", lines: ["Onay isteği reddedildi; hiçbir şey yayınlanmadı."] };
|
|
174
|
+
case "superseded":
|
|
175
|
+
return { ok: false, code: "approval_superseded", message: "The work was reopened for changes; this approval request is no longer valid. Nothing was published (approval_superseded).", lines: ["Çalışma düzenlemeye geri alındı; bu onay isteği artık geçerli değil. Hiçbir şey yayınlanmadı.", again] };
|
|
176
|
+
case "cancelled":
|
|
177
|
+
return { ok: false, code: "work_cancelled", message: "The work was cancelled; nothing was published (work_cancelled).", lines: ["Çalışma iptal edildi; hiçbir şey yayınlanmadı."] };
|
|
178
|
+
case "failed":
|
|
179
|
+
return { ok: false, code: "publish_failed", message: "The work could not be completed (publish_failed).", lines: ["İşlem tamamlanamadı; hiçbir şey yayınlanmadı.", ` Durumu gör: blocofy theme work status ${handle}`] };
|
|
180
|
+
default:
|
|
181
|
+
return { ok: false, code: "wait_timeout", message: "No decision yet; stopped waiting. Nothing is published until the status says so (wait_timeout).", lines: ["Bekleme süresi doldu; henüz karar verilmedi. Onaylanana kadar canlı site değişmez.", ` Durumu gör: blocofy theme work status ${handle}`] };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The publish status in words (for `theme work status`). */
|
|
186
|
+
export function publishStatusLines(status) {
|
|
187
|
+
if (!status || typeof status !== "object") return [];
|
|
188
|
+
const lines = [` Yayın: ${status.phase_label ?? status.phase ?? "?"}`];
|
|
189
|
+
const a = status.approval;
|
|
190
|
+
if (a && a.state === "unused" && a.expired !== true) {
|
|
191
|
+
lines.push(` Onay bekleniyor: ${a.approval_url ?? a.id}${a.expires_at ? ` (son geçerlilik ${a.expires_at})` : ""}`);
|
|
192
|
+
}
|
|
193
|
+
return lines;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── messages ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
const STATE_LABEL = {
|
|
199
|
+
open: "açık (üzerinde çalışılabilir)",
|
|
200
|
+
sealed: "incelemede (şu an değiştirilemez)",
|
|
201
|
+
publishing: "yayınlanıyor",
|
|
202
|
+
published: "yayınlandı",
|
|
203
|
+
cancelled: "iptal edildi",
|
|
204
|
+
archived: "arşivlendi",
|
|
205
|
+
failed: "kapandı",
|
|
206
|
+
};
|
|
207
|
+
export const stateLabel = (state) => STATE_LABEL[state] ?? String(state ?? "?");
|
|
208
|
+
|
|
209
|
+
const STALE_LABEL = {
|
|
210
|
+
live_theme_changed: "canlı tema değişti",
|
|
211
|
+
theme_version_changed: "temanın sürümü değişti",
|
|
212
|
+
live_source_advanced: "canlı temanın dosyaları değişti",
|
|
213
|
+
live_settings_changed: "canlı temanın ayarları değişti",
|
|
214
|
+
};
|
|
215
|
+
export const staleLabel = (reason) => STALE_LABEL[reason] ?? String(reason);
|
|
216
|
+
|
|
217
|
+
/** The stale note (plain Turkish), or null when the work is up to date / unknown. */
|
|
218
|
+
export function staleLines(work) {
|
|
219
|
+
if (work?.stale !== true) return null;
|
|
220
|
+
const reasons = Array.isArray(work.stale_reasons) && work.stale_reasons.length ? ` (${work.stale_reasons.map(staleLabel).join(", ")})` : "";
|
|
221
|
+
return [
|
|
222
|
+
`Not: Site bu çalışma başladıktan sonra değişti${reasons}. Çalışman güncel değil; hiçbir şeyin üzerine yazılmadı.`,
|
|
223
|
+
" Yayına almadan önce çalışmanın güncellenmesi gerekecek; değişiklikleri panelde gözden geçir.",
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Human summary of a work (stdout). */
|
|
228
|
+
export function workLines(work) {
|
|
229
|
+
const lines = [
|
|
230
|
+
`Çalışma: ${work.id}${work.intent ? ` — ${work.intent}` : ""}`,
|
|
231
|
+
` Durum: ${stateLabel(work.state)} (state_version ${work.state_version})`,
|
|
232
|
+
` Tema: ${work.theme ?? "(silinmiş)"}${work.sandbox ? " · yapay zekâ taslağı" : ""}`,
|
|
233
|
+
];
|
|
234
|
+
if (work.base?.theme) lines.push(` Başlangıç: canlı tema ${work.base.theme}${work.base.theme_version ? ` (sürüm ${work.base.theme_version})` : ""}`);
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const NOTHING = "Hiçbir şey yazılmadı.";
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A theme-work refusal in words: `{ lines, message, details }` — `lines` the plain Turkish explanation (stderr, human
|
|
242
|
+
* mode; first line non-technical), `message`/`details` the technical `error [code]` line and `--json` envelope.
|
|
243
|
+
* `op`: "start" | "status" | "resume" | "cancel" | "push" | "seal" | "request-approval" | "wait". Returns null for a code it does not know (the caller prints
|
|
244
|
+
* the server's own envelope then).
|
|
245
|
+
*/
|
|
246
|
+
export function themeWorkRefusal(error, { op = "status", handle = null, startCommand = "blocofy theme work start" } = {}) {
|
|
247
|
+
const e = error instanceof CliRefusal ? error.error ?? {} : error ?? {};
|
|
248
|
+
const code = typeof e.code === "string" ? e.code : error?.code;
|
|
249
|
+
const details = e.details && typeof e.details === "object" ? e.details : {};
|
|
250
|
+
const serverMessage = typeof e.message === "string" ? e.message : "";
|
|
251
|
+
const name = handle ? ` ${handle}` : "";
|
|
252
|
+
const out = (lines, message) => ({ lines, message: serverMessage ? `${message} (${serverMessage})` : message, details });
|
|
253
|
+
switch (code) {
|
|
254
|
+
case "not_found":
|
|
255
|
+
if (op === "start") {
|
|
256
|
+
return out(["Bu platform tema çalışmalarını henüz desteklemiyor. " + NOTHING], "This platform has no theme work endpoint yet (404 on POST /api/v1/theme-work).");
|
|
257
|
+
}
|
|
258
|
+
if (details.reason === "target_deleted") {
|
|
259
|
+
return out([`Çalışmanın${name} teması silinmiş; bu çalışmaya devam edilemez. ${NOTHING}`, ` Yeni bir çalışma başlat: ${startCommand}`], "The work's theme was deleted (not_found, target_deleted).");
|
|
260
|
+
}
|
|
261
|
+
return out([`Çalışma bulunamadı${handle ? `: ${handle}` : ""}. Tanıtıcıyı (wk_…) kontrol et. ${NOTHING}`], "No such work on this site (not_found).");
|
|
262
|
+
case "work_forbidden":
|
|
263
|
+
return details.reason === "revoked"
|
|
264
|
+
? out([`Bu bağlantının çalışmaya erişimi iptal edilmiş. ${NOTHING}`, " Çalışmaya panelden devam edebilir ya da yeni bir çalışma başlatabilirsin."], "This credential's access to the work was revoked (work_forbidden).")
|
|
265
|
+
: out([`Bu çalışma başka bir bağlantıya ait (başka bir API anahtarı ya da yapay zekâ bağlantısı başlatmış). ${NOTHING}`, " Kendi çalışmanı başlat ya da çalışmaya panelden devam et."], "The work belongs to another credential (work_forbidden).");
|
|
266
|
+
case "work_state_conflict": {
|
|
267
|
+
const now = typeof details.state === "string" ? ` Şu anki durumu: ${stateLabel(details.state)}.` : "";
|
|
268
|
+
return out([`Çalışma bu işlem için uygun durumda değil ya da bu sırada değişti.${now} ${NOTHING}`, ` Güncel durumu gör: blocofy theme work status${name}`], "The work is not in a state that allows this, or it changed meanwhile (work_state_conflict).");
|
|
269
|
+
}
|
|
270
|
+
case "work_stale":
|
|
271
|
+
return out([`Site bu çalışma başladıktan sonra değişti; çalışma güncel değil. ${NOTHING}`, " Hiçbir şeyin üzerine yazılmadı. Değişiklikleri panelde gözden geçir ya da yeni bir çalışma başlat."], "The live site changed since the work started (work_stale).");
|
|
272
|
+
case "work_sealed":
|
|
273
|
+
return out([`Çalışma incelemeye gönderildi; içeriği şu an değiştirilemez. ${NOTHING}`], "The work is sealed for review (work_sealed).");
|
|
274
|
+
case "work_revision_limit": {
|
|
275
|
+
const limit = Number.isInteger(details.limit) && details.limit > 0 ? details.limit : 20;
|
|
276
|
+
return out([`Bir çalışma en fazla ${limit} kez incelemeye gönderilebilir; yeni bir çalışma başlat. ${NOTHING}`, " Bu çalışmanın incelemedeki hâli yine onaylanabilir ya da çalışma iptal edilebilir.", ` Yeni bir çalışma: ${startCommand}`], `The work was sent for review its maximum number of times (${limit}); start a new work (work_revision_limit).`);
|
|
277
|
+
}
|
|
278
|
+
case "work_not_publishable":
|
|
279
|
+
return out([`Bu tema bir çalışma kopyası; doğrudan yayınlanamaz. Çalışmayı incelemeye gönder ve onay iste. ${NOTHING}`, ` Yayın için onay iste: blocofy theme work request-approval${name || " <wk_…>"}`], "A theme work's copy is never published directly; ask for its approval (work_not_publishable).");
|
|
280
|
+
case "conflict":
|
|
281
|
+
return out([`Onay isteği bu sırada değişti (süresi dolmuş, kullanılmış ya da artık geçersiz olabilir). ${NOTHING}`, ` Güncel durumu gör: blocofy theme work status${name}`], "The approval record changed meanwhile (conflict).");
|
|
282
|
+
case "live_effect_not_permitted":
|
|
283
|
+
return out([`Bu bağlantı canlı siteyi etkileyen bu işlemi yapamaz. ${NOTHING}`], "This connection may not touch the live site (live_effect_not_permitted).");
|
|
284
|
+
case "work_base_unavailable":
|
|
285
|
+
return out([`Sitenin yayında bir teması yok; çalışma başlatılamadı. ${NOTHING}`, " Önce panelden bir temayı yayına al."], "The site has no live theme to start from (work_base_unavailable).");
|
|
286
|
+
case "quota_exceeded":
|
|
287
|
+
// `theme_bytes` is the site's technical theme space (not the plan): free space, never "upgrade". The legacy draft
|
|
288
|
+
// count (`theme_drafts`, a site not on theme space) is not tied to the plan either: remove a draft, never "upgrade".
|
|
289
|
+
return details.resource === "theme_drafts"
|
|
290
|
+
? out([`Sitenin taslak tema sınırına ulaşıldı; çalışma başlatılamadı. ${NOTHING}`, " Artık kullanmadığın bir taslak temayı sil (admin paneli → Tasarım)."], "The site's draft limit is reached (quota_exceeded, theme_drafts); remove a draft you no longer need.")
|
|
291
|
+
: out([`Tema alanı bu çalışma için yetmiyor (sitenin tema alanı dolu); çalışma başlatılamadı. ${NOTHING}`, " Yer açmak için kullanmadığın taslak temaları (iptal edilmiş çalışmaların temaları dahil) tema kütüphanesinden sil (admin paneli → Tasarım); açık bir çalışmayı iptal etmek ayrıca onun için ayrılan yayın alanını bırakır. Sorun sürerse destek ekibine yaz."], "The site's theme space cannot hold this work (quota_exceeded, theme_bytes).");
|
|
292
|
+
case "capacity_unavailable":
|
|
293
|
+
case "resource_busy":
|
|
294
|
+
return out([`${code === "resource_busy" ? "Site şu anda meşgul (başka bir yazım sürüyor)" : "Platform şu an alanı doğrulayamadı"}; işlem yapılamadı. ${NOTHING}`, " Biraz sonra aynı komutu tekrar çalıştır."], `Transient refusal (${code}); try again later.`);
|
|
295
|
+
case "idempotency_key_reuse":
|
|
296
|
+
return out([`Bu işlem anahtarı daha önce başka bir istekle kullanılmış. ${NOTHING}`, " Yeni bir anahtarla dene ya da --idempotency-key vermeden çalıştır (her başlatma yeni anahtar üretir)."], "The idempotency key was used with another request (idempotency_key_reuse).");
|
|
297
|
+
case "idempotency_key_invalid":
|
|
298
|
+
case "idempotency_key_required":
|
|
299
|
+
return out([`İşlem anahtarı geçersiz (8-120 karakter: harf, rakam, . _ : -). ${NOTHING}`], `The idempotency key is ${code === "idempotency_key_required" ? "missing" : "malformed"} (${code}).`);
|
|
300
|
+
case "validation_failed":
|
|
301
|
+
return out([`İstek geçersiz${op === "start" ? " (çalışma açıklaması 1-120 karakter olmalı)" : ""}. ${NOTHING}`], "The request was refused as invalid (validation_failed).");
|
|
302
|
+
case "forbidden_scope":
|
|
303
|
+
return out([`API anahtarının bu işlem için yetkisi yok (${op === "status" || op === "resume" || op === "wait" ? "themes:read" : "themes:write"} gerekir). ${NOTHING}`, " Panelde Ayarlar → API anahtarları'ndan uygun yetkili bir anahtar oluştur."], "The API key lacks the scope for this operation (forbidden_scope).");
|
|
304
|
+
case "invalid_key":
|
|
305
|
+
case "key_expired":
|
|
306
|
+
return out([`API anahtarı geçersiz ya da süresi dolmuş. ${NOTHING}`, " Yeniden giriş yap: blocofy login --api-key"], `The API key was refused (${code}).`);
|
|
307
|
+
default:
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── the project's saved work (.blocofy/local.json, git-ignored) ─────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
const localPath = (root) => join(root, ".blocofy", "local.json");
|
|
315
|
+
|
|
316
|
+
function readLocal(root) {
|
|
317
|
+
const p = localPath(root);
|
|
318
|
+
if (!existsSync(p)) return {};
|
|
319
|
+
try {
|
|
320
|
+
const v = JSON.parse(readFileSync(p, "utf8"));
|
|
321
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
322
|
+
} catch {
|
|
323
|
+
return {};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The work this project saved, when it belongs to the project's bound site: `{ handle, theme, intent }` | null. */
|
|
328
|
+
export function readSavedWork(binding) {
|
|
329
|
+
if (!binding?.root) return null;
|
|
330
|
+
const w = readLocal(binding.root).theme_work;
|
|
331
|
+
if (!w || typeof w !== "object" || !isWorkHandle(w.handle)) return null;
|
|
332
|
+
if (w.site_id != null && String(w.site_id) !== String(binding.project?.site_id)) return null;
|
|
333
|
+
return { handle: w.handle, theme: typeof w.theme === "string" ? w.theme : null, intent: typeof w.intent === "string" ? w.intent : null };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Save (or, with `work: null`, forget) the project's work in `.blocofy/local.json`, keeping every other key (the
|
|
338
|
+
* context choice). Same rules as the binding writer: never through a symlink, a temp file renamed into place.
|
|
339
|
+
*/
|
|
340
|
+
export function saveWork(binding, work) {
|
|
341
|
+
if (!binding?.root) return false;
|
|
342
|
+
const dir = join(binding.root, ".blocofy");
|
|
343
|
+
const p = localPath(binding.root);
|
|
344
|
+
for (const path of [dir, p]) {
|
|
345
|
+
let st = null;
|
|
346
|
+
try {
|
|
347
|
+
st = lstatSync(path);
|
|
348
|
+
} catch {
|
|
349
|
+
st = null;
|
|
350
|
+
}
|
|
351
|
+
if (st?.isSymbolicLink()) return false;
|
|
352
|
+
}
|
|
353
|
+
if (!existsSync(dir)) return false;
|
|
354
|
+
const local = readLocal(binding.root);
|
|
355
|
+
if (work) {
|
|
356
|
+
local.theme_work = { handle: work.id, theme: work.theme ?? null, intent: work.intent ?? null, site_id: binding.project?.site_id ?? null, saved_at: new Date().toISOString() };
|
|
357
|
+
} else {
|
|
358
|
+
delete local.theme_work;
|
|
359
|
+
}
|
|
360
|
+
const tmp = join(dir, `.local.json.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
361
|
+
try {
|
|
362
|
+
writeFileSync(tmp, JSON.stringify(local, null, 2) + "\n", { flag: "wx", mode: 0o644 });
|
|
363
|
+
renameSync(tmp, p);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
rmSync(tmp, { force: true });
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
return true;
|
|
369
|
+
}
|
package/package.json
CHANGED