@blocofy/cli 0.14.0 → 0.16.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 +229 -15
- package/bin/blocofy.mjs +993 -53
- package/lib/args.mjs +2 -2
- package/lib/credentials.mjs +150 -6
- package/lib/dev-server.mjs +6 -3
- package/lib/init.mjs +382 -0
- package/lib/loopback.mjs +111 -0
- package/lib/media-uses.mjs +4 -2
- package/lib/oauth.mjs +295 -0
- package/lib/refresh.mjs +244 -0
- package/lib/secret-store.mjs +177 -10
- package/lib/target.mjs +25 -11
- package/lib/theme-sync.mjs +160 -5
- package/lib/theme-work.mjs +369 -0
- package/lib/validate.mjs +4 -1
- package/package.json +1 -1
|
@@ -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/lib/validate.mjs
CHANGED
|
@@ -13,6 +13,9 @@ export function isValidUrl(u) {
|
|
|
13
13
|
return /^https?:\/\/.+/i.test(u);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** A theme dev token: `bcf_` (legacy) or `bcf2_` (profiled, ADR-0014 §3.2: needs CLI >= 0.16.0). */
|
|
17
|
+
export const DEV_TOKEN_PREFIXES = ["bcf_", "bcf2_"];
|
|
18
|
+
|
|
16
19
|
export function isValidToken(t) {
|
|
17
|
-
return typeof t === "string" && t.startsWith(
|
|
20
|
+
return typeof t === "string" && DEV_TOKEN_PREFIXES.some((p) => t.startsWith(p)) && t.length >= 24;
|
|
18
21
|
}
|
package/package.json
CHANGED