@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/lib/args.mjs CHANGED
@@ -8,9 +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
+ * #925: `--publish` (translations import) da değersizdir. `--require-fresh` (theme work resume) da; `--open` ve `--wait` (theme work request-approval) da.
12
12
  */
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
+ 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", "require-fresh", "open", "wait", "insecure-storage", "no-browser"]);
14
14
 
15
15
  /**
16
16
  * `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
@@ -2,7 +2,7 @@ import { closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeF
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
- import { SecretStoreError, secretStoreFor, writeJsonAtomic } from "./secret-store.mjs";
5
+ import { STORE_NAMES, SecretStoreError, secretStoreFor, writeJsonAtomic } from "./secret-store.mjs";
6
6
 
7
7
  /**
8
8
  * CF-T1 (contract C1) — Blocofy CLI credential store v2.
@@ -53,14 +53,24 @@ function corrupt(reason) {
53
53
  }
54
54
 
55
55
  function validPair(pair) {
56
- return pair === undefined || (isObj(pair) && isStr(pair.url) && isObj(pair.secret) && (pair.secret.store === "file" || pair.secret.store === "keychain"));
56
+ return pair === undefined || (isObj(pair) && isStr(pair.url) && isObj(pair.secret) && STORE_NAMES.includes(pair.secret.store));
57
+ }
58
+
59
+ /**
60
+ * ADR-0014 §5 (wave P4) — a browser-login context holds an `oauth` record instead of the pasted pairs:
61
+ * `{ url (platform origin), issuer, client_id, token_endpoint, revocation_endpoint|null, dev_url|null,
62
+ * profile: {id, version, label}|null, secret: { store }, state?: "reauth_required" }`. Its token set is ONE secret
63
+ * (kind `oauth`) in the OS store. An older CLI ignores the unknown key and finds no usable pair (fail-closed).
64
+ */
65
+ function validOAuth(o) {
66
+ return o === undefined || (validPair(o) && isStr(o.token_endpoint) && isStr(o.client_id));
57
67
  }
58
68
 
59
69
  function validV2(data) {
60
70
  if (data.schema_version !== SCHEMA_VERSION || !isObj(data.contexts)) return false;
61
71
  if (data.current_context !== null && data.current_context !== undefined && typeof data.current_context !== "string") return false;
62
72
  for (const ctx of Object.values(data.contexts)) {
63
- if (!isObj(ctx) || !validPair(ctx.dev) || !validPair(ctx.api)) return false;
73
+ if (!isObj(ctx) || !validPair(ctx.dev) || !validPair(ctx.api) || !validOAuth(ctx.oauth)) return false;
64
74
  if (ctx.site !== null && ctx.site !== undefined && !(isObj(ctx.site) && (isStr(ctx.site.id) || Number.isFinite(ctx.site.id)))) return false;
65
75
  }
66
76
  return true;
@@ -175,8 +185,8 @@ export function defaultSecretStoreName({ keychain = false, env = process.env } =
175
185
  if (keychain) return "keychain";
176
186
  const v = env.BLOCOFY_SECRET_STORE;
177
187
  if (v === undefined || v === "" || v === "file") return "file";
178
- if (v === "keychain") return "keychain";
179
- throw new CredentialsError("SECRET_STORE_UNAVAILABLE", `BLOCOFY_SECRET_STORE must be "file" or "keychain".`);
188
+ if (STORE_NAMES.includes(v)) return v;
189
+ throw new CredentialsError("SECRET_STORE_UNAVAILABLE", `BLOCOFY_SECRET_STORE must be one of ${STORE_NAMES.map((n) => `"${n}"`).join(", ")}.`);
180
190
  }
181
191
 
182
192
  export function openSecretStore(name, options = {}) {
@@ -210,8 +220,46 @@ export function writeSecret(name, kind, storeName, value, options = {}) {
210
220
  }
211
221
  }
212
222
 
223
+ /**
224
+ * The browser login's token set `{ access_token, refresh_token, expires_at }` (null when absent). A value that is not
225
+ * that JSON is CREDENTIALS_CORRUPT — never "logged out", never sent.
226
+ */
227
+ export function readOAuthTokens(name, ctx, options = {}) {
228
+ if (!ctx?.oauth) return null;
229
+ let raw;
230
+ try {
231
+ raw = openSecretStore(ctx.oauth.secret.store, options).get(name, "oauth");
232
+ } catch (error) {
233
+ throw asCredentialsError(error);
234
+ }
235
+ if (raw === null) return null;
236
+ try {
237
+ const t = JSON.parse(raw);
238
+ if (isObj(t) && isStr(t.access_token) && Number.isFinite(t.expires_at)) return { access_token: t.access_token, refresh_token: isStr(t.refresh_token) ? t.refresh_token : null, expires_at: t.expires_at };
239
+ } catch {
240
+ /* fall through */
241
+ }
242
+ throw new CredentialsError("CREDENTIALS_CORRUPT", `The saved CLI login of context "${name}" cannot be read. Nothing was sent. Log in again: blocofy login --context ${name}`, { context: name });
243
+ }
244
+
245
+ export function writeOAuthTokens(name, storeName, tokens, options = {}) {
246
+ const value = JSON.stringify({ access_token: tokens.access_token, refresh_token: tokens.refresh_token ?? null, expires_at: tokens.expires_at });
247
+ writeSecret(name, "oauth", storeName, value, options);
248
+ }
249
+
250
+ /** ADR §5.4 — a refresh the platform refused for good: the context needs a new login and sends nothing until then. */
251
+ export function markReauthRequired(name) {
252
+ withStoreLock(() => {
253
+ const store = loadStore();
254
+ const ctx = store.contexts[name];
255
+ if (!ctx?.oauth || ctx.oauth.state === "reauth_required") return;
256
+ ctx.oauth = { ...ctx.oauth, state: "reauth_required" };
257
+ saveStore(store);
258
+ });
259
+ }
260
+
213
261
  export function removeSecrets(name, ctx, options = {}) {
214
- for (const kind of ["dev", "api"]) {
262
+ for (const kind of ["dev", "api", "oauth"]) {
215
263
  if (!ctx?.[kind]) continue;
216
264
  try {
217
265
  openSecretStore(ctx[kind].secret.store, options).remove(name, kind);
@@ -246,3 +294,99 @@ export function envContext(env = process.env) {
246
294
  secrets: { devToken: dev?.secret ?? null, apiKey: api?.secret ?? null },
247
295
  };
248
296
  }
297
+
298
+ // ── ADR-0014 refusals in plain words (wave P4) ─────────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * The credential refusals of the profile gate (v1 `{error:{code,details}}`, renderer `{error, code, details}`) plus the
302
+ * CLI's own REAUTH_REQUIRED. None of them is transient: the CLI never retries them; each says what to do instead.
303
+ */
304
+ export const CREDENTIAL_REFUSAL_CODES = new Set([
305
+ "live_effect_not_permitted",
306
+ "credential_reapproval_required",
307
+ "profile_unsupported",
308
+ "audience_mismatch",
309
+ "ai_draft",
310
+ "work_copy_not_writable",
311
+ "REAUTH_REQUIRED",
312
+ ]);
313
+
314
+ const httpsLink = (v) => {
315
+ if (typeof v !== "string") return null;
316
+ try {
317
+ const u = new URL(v);
318
+ return u.protocol === "https:" && !u.username && !u.password ? u.href : null;
319
+ } catch {
320
+ return null;
321
+ }
322
+ };
323
+ const day = (v) => (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : null);
324
+
325
+ /**
326
+ * `{ lines }` — plain Turkish lines (stderr, before the `error [code]` line) for a credential refusal, or null for any
327
+ * other code. `details` = the server's details; `ctx.context` names the CLI context for the re-login hint.
328
+ */
329
+ export function credentialRefusal(code, details = {}, ctx = {}) {
330
+ if (!CREDENTIAL_REFUSAL_CODES.has(code)) return null;
331
+ const d = isObj(details) ? details : {};
332
+ const login = ctx.context ? `blocofy login --context ${ctx.context}` : "blocofy login";
333
+ switch (code) {
334
+ case "live_effect_not_permitted":
335
+ return {
336
+ lines: [
337
+ "Bu kimlik bilgisi yalnız taslak üzerinde çalışır (\"Tema geliştirme\" profili); bu işlem ziyaretçilerin gördüğü siteyi değiştirir ya da yayınlar. Hiçbir şey yazılmadı.",
338
+ " Yayını ve canlı değişiklikleri Blocofy panelinden yap ya da tema çalışmanı insan onayına gönder: blocofy theme work request-approval <wk_…>",
339
+ " Taslağa yazmak için kendi tema çalışmanı hedefle: blocofy theme push --draft --work <wk_…>",
340
+ ],
341
+ };
342
+ case "credential_reapproval_required": {
343
+ const due = day(d.due_at);
344
+ const link = httpsLink(d.reapprove_url);
345
+ return {
346
+ lines: [
347
+ `Bu eski anahtarın/token'ın yayın ve canlı değişiklik yetkisi${due ? ` ${due} tarihinden beri` : ""} site sahibinin yeniden onayını bekliyor; bu işlem yapılmadı, hiçbir şey yazılmadı.`,
348
+ ` Site sahibi panelden onaylayabilir: Ayarlar → Bağlantılar ve anahtarlar${link ? ` (${link})` : ""}`,
349
+ " Taslak işler sürüyor: blocofy theme pull, blocofy theme push --draft, blocofy theme dev, blocofy site apply.",
350
+ ],
351
+ };
352
+ }
353
+ case "profile_unsupported":
354
+ return {
355
+ lines: [
356
+ "Bu kimlik bilgisinin profili bu platform sürümünde tanınmıyor; yazım yapılmadı.",
357
+ " Panelden yeni bir kimlik bilgisi oluştur ya da yeniden giriş yap: blocofy login",
358
+ " CLI'yı güncel tut: npm i -g @blocofy/cli@latest",
359
+ ],
360
+ };
361
+ case "audience_mismatch":
362
+ return {
363
+ lines: [
364
+ "Bu kimlik bilgisi bu uç için değil (ör. bir yapay zekâ bağlantısının token'ı REST API'de ya da CLI'da kullanılamaz). Hiçbir şey gönderilmedi.",
365
+ ` CLI için kendi girişini kullan: ${login}`,
366
+ ],
367
+ };
368
+ case "ai_draft":
369
+ return {
370
+ lines: [
371
+ "Hedef tema bir yapay zekâ taslağı; CLI ona yazamaz, adını değiştiremez. Hiçbir şey yazılmadı.",
372
+ " Taslağı panelden ya da onu oluşturan bağlantı üzerinden ilerlet; kendi işin için: blocofy theme work start",
373
+ ],
374
+ };
375
+ case "work_copy_not_writable":
376
+ return {
377
+ lines: [
378
+ "Hedef tema başka bir bağlantının tema çalışması kopyası; bu kimlik bilgisiyle ona yazılamaz. Hiçbir şey yazılmadı.",
379
+ " Kendi çalışmanı başlat: blocofy theme work start (ya da çalışmayı onu başlatan bağlantıdan sürdür)",
380
+ ],
381
+ };
382
+ case "REAUTH_REQUIRED":
383
+ return {
384
+ lines: [
385
+ "CLI girişin artık yenilenemiyor (süresi doldu, panelden kesildi ya da yenileme anahtarı ikinci kez kullanıldı). Hiçbir şey gönderilmedi.",
386
+ ` Yeniden giriş yap: ${login}`,
387
+ ],
388
+ };
389
+ default:
390
+ return null;
391
+ }
392
+ }
@@ -22,6 +22,8 @@ import { DRAFT_TARGET_AMBIGUOUS, draftSyncErrorLine, pushTheme } from "./theme-s
22
22
  */
23
23
  export function startDevServer({ dir, url, token, port, syncDraft = false, instance = null, onError, onReload, onRetry, onWarn, onSyncRefused }) {
24
24
  const base = url.replace(/\/+$/, "");
25
+ // `token` is the credential or (a CLI login, ADR-0014 §5.4) an async getter that renews it; asked per request.
26
+ const bearer = async () => (typeof token === "function" ? await token() : token);
25
27
  const clients = new Set();
26
28
 
27
29
  // Taslak instance handle'ı: `pushTheme({draft:true})` yanıtından gelir (ek istek YOK). Render
@@ -61,7 +63,8 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
61
63
  return true;
62
64
  };
63
65
  const draftReady = syncDraft
64
- ? pushTheme({ dir, url, token, ...syncTarget, onRetry })
66
+ ? bearer()
67
+ .then((t) => pushTheme({ dir, url, token: t, ...syncTarget, onRetry }))
65
68
  .then(noteHandle)
66
69
  .catch((err) => {
67
70
  // #989 re-review P3: a startup sync failure is said, in one line — never swallowed.
@@ -75,7 +78,7 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
75
78
  const templates = readLocalTemplates(dir);
76
79
  const res = await fetch(`${base}/api/dev/render`, {
77
80
  method: "POST",
78
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
81
+ headers: { "content-type": "application/json", authorization: `Bearer ${await bearer()}` },
79
82
  // `preview` BİLİNÇLE gönderilmez: sunucuda preview yalnız tazelik değil — önizleme bandı,
80
83
  // editör bridge'i ve blok işaretleyicileri de ona bağlı. `theme dev`'in vaadi prod paritesi.
81
84
  body: JSON.stringify({
@@ -153,7 +156,7 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
153
156
  // BİLİNÇLE key'siz (0.5.0): dev sync her kayıtta koşar — kanonik mühür (key'li dal) kayıt
154
157
  // başına revizyon+deployment üretirdi (retention yok) ve taslak önizlemeye katkısı sıfır.
155
158
  // Key'siz istek sunucuda legacy draft yazımıdır; kanonik mühür bilinçli `theme push` işidir.
156
- noteHandle(await pushTheme({ dir, url, token, ...syncTarget, onRetry }));
159
+ noteHandle(await pushTheme({ dir, url, token: await bearer(), ...syncTarget, onRetry }));
157
160
  } catch (err) {
158
161
  if (refusedBy(err)) return;
159
162
  error = draftSyncErrorLine(err); // senkron hatası reload'u engellemesin
package/lib/init.mjs ADDED
@@ -0,0 +1,382 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
3
+ import { hostname as osHostname } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { fetchWithRetry } from "./http.mjs";
7
+ import { CliRefusal } from "./media-uses.mjs";
8
+ import { writeBinding } from "./target.mjs";
9
+ import { pullTheme } from "./theme-sync.mjs";
10
+ import { saveWork } from "./theme-work.mjs";
11
+
12
+ /**
13
+ * ADR-0014 §5.6 (wave P4) — `blocofy init [dir] [--site <handle|slug>]`: a new project directory with its own theme
14
+ * work, a preview, and proof that the live site did not change.
15
+ *
16
+ * Pre-checks (no remote write): a missing/empty directory starts; one holding this CLI's own `init.json` resumes (same
17
+ * site + platform only); a non-empty directory that is not ours is refused (INIT_DIR_NOT_EMPTY, nothing written); a
18
+ * directory bound to another site/platform is refused (INIT_SITE_MISMATCH); a project bound by `link`/`pull` (no
19
+ * `init.json`) is not taken over (INIT_ALREADY_BOUND).
20
+ *
21
+ * `.blocofy/init.json` (git-ignored, no secret, no preview link) records the steps:
22
+ * started → work_created → files_pulled → pinned → previewed
23
+ * `init_key` is on disk BEFORE the first remote call and is the theme-work start's idempotency key (`init:<key>`), so
24
+ * a timeout, a crash or a second run converges on the SAME work (057 replay). Each resume reads the work back from the
25
+ * platform first: local state is never authority. `.blocofy/.init.lock` (O_EXCL, `{pid, hostname, acquired_at}`) lets
26
+ * one init run per directory. Success = a preview link exists AND the live theme read back after it equals the one read
27
+ * before the work started (`live_before`, kept across resumes). Nothing here deletes a live theme or another draft.
28
+ */
29
+
30
+ export const INIT_STATES = ["started", "work_created", "files_pulled", "pinned", "previewed"];
31
+ export const INIT_INTENT = "blocofy init";
32
+ const PROJECT_DIR = ".blocofy";
33
+ const OUR_FILES = /^(init\.json|\.init\.lock|\.gitignore|\.init\.json\.[^/]+\.tmp)$/;
34
+ const INIT_LOCK_STALE_MS = 10 * 60_000;
35
+ const at = (s) => INIT_STATES.indexOf(s);
36
+
37
+ export class InitError extends Error {
38
+ constructor(code, message, { details = {}, exitCode = 1, lines = [] } = {}) {
39
+ super(message);
40
+ this.name = "InitError";
41
+ this.code = code;
42
+ this.details = details;
43
+ this.exitCode = exitCode;
44
+ this.lines = lines;
45
+ }
46
+ }
47
+
48
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
49
+ const initPath = (dir) => join(dir, PROJECT_DIR, "init.json");
50
+ const lockPath = (dir) => join(dir, PROJECT_DIR, ".init.lock");
51
+
52
+ function readJson(p) {
53
+ try {
54
+ return JSON.parse(readFileSync(p, "utf8"));
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /** The saved init state, or null. A file that is not a valid state is INIT_STATE_INVALID (exit 3, nothing changed). */
61
+ export function readInitState(dir) {
62
+ const p = initPath(dir);
63
+ if (!existsSync(p)) return null;
64
+ const s = readJson(p);
65
+ if (!isObj(s) || s.schema_version !== 1 || typeof s.init_key !== "string" || !INIT_STATES.includes(s.state)) {
66
+ throw new InitError("INIT_STATE_INVALID", `${p} is not a valid init state; nothing was changed. Fix it or start in a new, empty directory.`, { exitCode: 3, details: { path: p } });
67
+ }
68
+ return s;
69
+ }
70
+
71
+ function writeInitState(dir, state) {
72
+ const d = join(dir, PROJECT_DIR);
73
+ const st = lstatSync(d);
74
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new InitError("INIT_DIR_INVALID", `${d} is not a real directory; nothing was written.`, { exitCode: 3 });
75
+ const { schema_version: _v, updated_at: _u, ...rest } = state;
76
+ const value = { schema_version: 1, ...rest, updated_at: new Date().toISOString() };
77
+ const tmp = join(d, `.init.json.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
78
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o644 });
79
+ renameSync(tmp, initPath(dir));
80
+ return value;
81
+ }
82
+
83
+ /** `{ kind: "fresh" | "resume" | "dirty" | "legacy_project", init?, project? }` — read only. */
84
+ export function inspectInitDir(dir) {
85
+ if (!existsSync(dir)) return { kind: "fresh" };
86
+ if (!statSync(dir).isDirectory()) return { kind: "dirty" };
87
+ const entries = readdirSync(dir);
88
+ const project = existsSync(join(dir, PROJECT_DIR, "project.json")) ? readJson(join(dir, PROJECT_DIR, "project.json")) ?? null : null;
89
+ if (existsSync(initPath(dir))) return { kind: "resume", init: readInitState(dir), project };
90
+ if (project !== null || existsSync(join(dir, PROJECT_DIR, "project.json"))) return { kind: "legacy_project", project };
91
+ const others = entries.filter((e) => e !== PROJECT_DIR);
92
+ if (others.length > 0) return { kind: "dirty" };
93
+ if (!entries.includes(PROJECT_DIR)) return { kind: "fresh" };
94
+ const st = lstatSync(join(dir, PROJECT_DIR));
95
+ if (st.isSymbolicLink() || !st.isDirectory()) return { kind: "dirty" };
96
+ return readdirSync(join(dir, PROJECT_DIR)).every((f) => OUR_FILES.test(f)) ? { kind: "fresh" } : { kind: "dirty" };
97
+ }
98
+
99
+ function alive(pid) {
100
+ try {
101
+ process.kill(pid, 0);
102
+ return true;
103
+ } catch (error) {
104
+ return error?.code !== "ESRCH";
105
+ }
106
+ }
107
+
108
+ /** One init per directory. A dead holder on this host, or a lock older than 10 minutes, is stale. */
109
+ function acquireInitLock(dir) {
110
+ const p = lockPath(dir);
111
+ const host = osHostname();
112
+ for (let attempt = 0; attempt < 2; attempt += 1) {
113
+ const mine = JSON.stringify({ pid: process.pid, hostname: host, acquired_at: new Date().toISOString() });
114
+ try {
115
+ const fd = openSync(p, "wx", 0o600);
116
+ writeSync(fd, mine);
117
+ closeSync(fd);
118
+ return () => {
119
+ try {
120
+ if (readFileSync(p, "utf8") === mine) rmSync(p, { force: true });
121
+ } catch {
122
+ /* gone */
123
+ }
124
+ };
125
+ } catch (error) {
126
+ if (error?.code !== "EEXIST") throw error;
127
+ }
128
+ const raw = (() => {
129
+ try {
130
+ return readFileSync(p, "utf8");
131
+ } catch {
132
+ return null;
133
+ }
134
+ })();
135
+ const holder = raw ? readJson(p) : null;
136
+ const age = holder && Number.isFinite(Date.parse(holder.acquired_at)) ? Date.now() - Date.parse(holder.acquired_at) : 0;
137
+ const stale = raw !== null && (age > INIT_LOCK_STALE_MS || (holder?.hostname === host && Number.isInteger(holder?.pid) && !alive(holder.pid)));
138
+ if (!stale) break;
139
+ try {
140
+ if (readFileSync(p, "utf8") === raw) rmSync(p, { force: true });
141
+ } catch {
142
+ /* gone */
143
+ }
144
+ }
145
+ throw new InitError("INIT_IN_PROGRESS", `Another \`blocofy init\` is running in ${dir}. Nothing was sent by this one; wait for it to finish, then run the command again to see where it stands.`, { exitCode: 3, details: { lock: p } });
146
+ }
147
+
148
+ // ── the platform calls (v1 + the dev theme read) ─────────────────────────────────────────────────────────────
149
+
150
+ async function asJson(res) {
151
+ const text = await res.text();
152
+ let json = null;
153
+ try {
154
+ json = text ? JSON.parse(text) : null;
155
+ } catch {
156
+ json = null;
157
+ }
158
+ if (res.ok && isObj(json)) return json;
159
+ if (res.status >= 400 && res.status < 500) {
160
+ throw new CliRefusal(res.status, isObj(json?.error) ? json.error : { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` });
161
+ }
162
+ throw Object.assign(new Error(`HTTP ${res.status}${json?.error?.message ? `: ${json.error.message}` : ""}`), { status: res.status, code: typeof json?.error?.code === "string" ? json.error.code : undefined });
163
+ }
164
+
165
+ /**
166
+ * `token` (v1) and `devToken` (the theme read; default `token`) are credential strings or async getters (a CLI login
167
+ * renews itself and uses one token for both). `timeoutMs` per attempt;
168
+ * `retryBackoff` overrides the shared retry waits (tests).
169
+ */
170
+ export function initApi({ apiUrl, devUrl, token, devToken = token, fetchImpl, timeoutMs = 30_000, retryBackoff, onRetry = null }) {
171
+ const base = String(apiUrl).replace(/\/+$/, "");
172
+ const resolveToken = async (t) => (typeof t === "function" ? await t() : t);
173
+ const bearer = () => resolveToken(token);
174
+ const opts = { fetchImpl, timeoutMs, onRetry, ...(retryBackoff ? { backoff: retryBackoff } : {}) };
175
+ const call = async (method, path, { body, headers = {} } = {}) => {
176
+ const h = { authorization: `Bearer ${await bearer()}`, accept: "application/json", ...headers };
177
+ if (body !== undefined) h["content-type"] = "application/json";
178
+ return asJson(await fetchWithRetry(`${base}${path}`, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) }, opts));
179
+ };
180
+ return {
181
+ siteStatus: () => call("GET", "/api/v1/site/status"),
182
+ startWork: (key, intent) => call("POST", "/api/v1/theme-work", { body: { intent }, headers: { "idempotency-key": key } }),
183
+ getWork: (handle) => call("GET", `/api/v1/theme-work/${encodeURIComponent(handle)}`),
184
+ async listPages() {
185
+ const pages = [];
186
+ for (let page = 1; page <= 20; page += 1) {
187
+ const r = await call("GET", `/api/v1/pages?limit=100&page=${page}`);
188
+ const batch = Array.isArray(r.pages) ? r.pages : [];
189
+ pages.push(...batch);
190
+ if (batch.length < 100) break;
191
+ }
192
+ return pages;
193
+ },
194
+ createPreviewLink: (theme, page) => call("POST", `/api/v1/themes/${encodeURIComponent(theme)}/preview-links`, { body: { page } }),
195
+ /** Newer platforms: the work's own preview link (the work theme's home page by default). */
196
+ createWorkPreviewLink: (handle) => call("POST", `/api/v1/theme-work/${encodeURIComponent(handle)}/preview-links`, { body: {} }),
197
+ async pullFiles(dir, instance) {
198
+ if (!devUrl) throw new InitError("INIT_NO_DEV_ENDPOINT", "The site has no address for theme files yet (no domain), so init cannot download the work's theme. Nothing was written.");
199
+ return pullTheme({ dir, url: devUrl, token: await resolveToken(devToken), instance, onRetry });
200
+ },
201
+ };
202
+ }
203
+
204
+ const liveId = (status) => (isObj(status?.live_theme_instance) ? status.live_theme_instance.id ?? null : null);
205
+
206
+ /** The preview page: the work theme's home page when it has one, else its first page with a plain address. */
207
+ function previewPage(pages, theme) {
208
+ const own = pages.filter((p) => isObj(p) && String(p.theme_instance) === String(theme) && typeof p.slug === "string" && p.slug && !p.slug.includes("["));
209
+ return own.find((p) => p.slug === "/" || p.slug === "index") ?? own[0] ?? null;
210
+ }
211
+
212
+ const sameSite = (a, siteId, origin) => String(a.site_id) === String(siteId) && (a.platform_origin ?? null) === (origin ?? null);
213
+
214
+ function definiteOrUnknown(error, what) {
215
+ if (error instanceof CliRefusal) return error;
216
+ return new InitError(
217
+ "INIT_OUTCOME_UNKNOWN",
218
+ `No definite answer from the platform while ${what} (${error?.message ?? "network error"}). Run \`blocofy init\` again in the same directory: it continues with the same init key, so a work that was started is found again, never started twice.`,
219
+ { details: { status: error?.status ?? null } },
220
+ );
221
+ }
222
+
223
+ /**
224
+ * The state machine. `identity` = the credential's verified `{ site, platformOrigin }`; `api` = `initApi(...)` (or a
225
+ * fake). Returns `{ state, work, previewUrl, preview, previewCreatedEarlier, liveBefore, liveAfter, liveUnchanged }`.
226
+ * Throws InitError / CliRefusal; a refusal before the lock writes nothing.
227
+ */
228
+ export async function runInit({ dir, identity, contextName, profile = null, api, log = () => {} }) {
229
+ const siteId = identity.site.id;
230
+ const origin = identity.platformOrigin ?? null;
231
+ const pre = inspectInitDir(dir);
232
+ const mismatch = (what) =>
233
+ new InitError("INIT_SITE_MISMATCH", `${dir} belongs to ${what}; this login is for ${identity.site.slug ?? siteId}. Nothing was sent or written. Use another directory.`, { exitCode: 3, details: { dir } });
234
+ if (pre.kind === "dirty") {
235
+ throw new InitError("INIT_DIR_NOT_EMPTY", `${dir} is not empty and is not a Blocofy project. Nothing was written. Run \`blocofy init\` in a new, empty directory (init never overwrites files).`, { exitCode: 3, details: { dir } });
236
+ }
237
+ if (pre.kind === "legacy_project") {
238
+ if (!isObj(pre.project) || !sameSite(pre.project, siteId, origin)) throw mismatch(`another site (${pre.project?.site_slug ?? pre.project?.site_id ?? "unreadable binding"})`);
239
+ throw new InitError(
240
+ "INIT_ALREADY_BOUND",
241
+ `${dir} is already a project of this site (made by \`link\` or \`theme pull\`); init does not take it over. Nothing was written. Start a theme work there instead: blocofy theme work start ${dir}`,
242
+ { exitCode: 3, details: { dir } },
243
+ );
244
+ }
245
+ if (pre.kind === "resume") {
246
+ if (!sameSite(pre.init, siteId, origin)) throw mismatch(`site ${pre.init.site_id} on ${pre.init.platform_origin ?? "an unknown platform"}`);
247
+ if (pre.project !== null && (!isObj(pre.project) || !sameSite(pre.project, siteId, origin))) throw mismatch(`site ${pre.project?.site_slug ?? pre.project?.site_id ?? "?"}`);
248
+ }
249
+
250
+ mkdirSync(join(dir, PROJECT_DIR), { recursive: true });
251
+ const release = acquireInitLock(dir);
252
+ try {
253
+ let s = readInitState(dir);
254
+ if (s && !sameSite(s, siteId, origin)) throw mismatch(`site ${s.site_id}`);
255
+ if (!s) {
256
+ // The key is on disk before the first remote call; the git-ignore before anything else lands in the directory.
257
+ writeFileSync(join(dir, PROJECT_DIR, ".gitignore"), "local.json\ninit.json\n", { mode: 0o644 });
258
+ s = writeInitState(dir, { init_key: randomUUID(), site_id: siteId, platform_origin: origin, state: "started", work_handle: null, work_theme: null, live_before: null });
259
+ }
260
+ const save = (patch) => (s = writeInitState(dir, { ...s, ...patch }));
261
+
262
+ if (!Object.prototype.hasOwnProperty.call(s, "live_before") || (s.live_before === null && s.state === "started")) {
263
+ let status;
264
+ try {
265
+ status = await api.siteStatus();
266
+ } catch (error) {
267
+ throw definiteOrUnknown(error, "reading the live theme");
268
+ }
269
+ save({ live_before: liveId(status) });
270
+ }
271
+
272
+ let work = null;
273
+ if (s.state === "started") {
274
+ log("Tema çalışması başlatılıyor (canlı siten değişmez)…");
275
+ let answer;
276
+ try {
277
+ answer = await api.startWork(`init:${s.init_key}`, INIT_INTENT);
278
+ } catch (error) {
279
+ const e = definiteOrUnknown(error, "starting the theme work");
280
+ if (e instanceof CliRefusal && e.error?.code === "idempotency_key_reuse") {
281
+ throw new InitError("idempotency_key_reuse", "The platform already used this init's key for a different request (idempotency_key_reuse); nothing new was started.", {
282
+ exitCode: 2,
283
+ lines: [
284
+ "Bu init anahtarı platformda başka bir istekle kullanılmış; yeni bir çalışma başlatılmadı.",
285
+ " .blocofy/init.json dosyasını silip yeniden başlatma: yarım kalan çalışma bulunamaz ve ikinci bir çalışma açılır.",
286
+ " Bu dizini olduğu gibi bırak ve boş, yeni bir dizinde `blocofy init` çalıştır; eski çalışma panelde görünür.",
287
+ ],
288
+ });
289
+ }
290
+ throw e;
291
+ }
292
+ work = answer.work;
293
+ if (!isObj(work) || typeof work.id !== "string" || typeof work.theme !== "string") throw new InitError("INIT_OUTCOME_UNKNOWN", "The platform's answer to the theme-work start has no work handle. Run `blocofy init` again in the same directory.");
294
+ save({ state: "work_created", work_handle: work.id, work_theme: work.theme });
295
+ } else {
296
+ // Remote readback: the saved handle must still be this site's work.
297
+ try {
298
+ ({ work } = await api.getWork(s.work_handle));
299
+ } catch (error) {
300
+ if (error instanceof CliRefusal && (error.status === 404 || error.error?.code === "not_found")) {
301
+ throw new InitError("INIT_WORK_MISSING", `The theme work this directory was set up with (${s.work_handle}) is not on the platform any more. Nothing was changed. Run \`blocofy init\` in a new, empty directory.`, { exitCode: 2, details: { work: s.work_handle } });
302
+ }
303
+ throw definiteOrUnknown(error, "reading the theme work back");
304
+ }
305
+ if (!isObj(work) || work.id !== s.work_handle) throw new InitError("INIT_WORK_MISSING", `The platform did not confirm the theme work ${s.work_handle}. Nothing was changed.`, { exitCode: 2 });
306
+ }
307
+
308
+ if (at(s.state) < at("files_pulled")) {
309
+ log(`Çalışmanın tema dosyaları indiriliyor (${work.theme})…`);
310
+ try {
311
+ await api.pullFiles(dir, work.theme);
312
+ } catch (error) {
313
+ throw error instanceof InitError ? error : definiteOrUnknown(error, "downloading the work's theme files");
314
+ }
315
+ save({ state: "files_pulled" });
316
+ }
317
+
318
+ if (at(s.state) < at("pinned")) {
319
+ writeBinding(dir, { site: identity.site, platformOrigin: origin, contextName, profile, ignore: ["local.json", "init.json"] });
320
+ saveWork({ root: dir, project: { site_id: siteId } }, { ...work, intent: work.intent ?? INIT_INTENT });
321
+ save({ state: "pinned" });
322
+ }
323
+
324
+ let previewUrl = null;
325
+ let preview = at(s.state) >= at("previewed") ? "created_earlier" : null;
326
+ if (at(s.state) < at("previewed")) {
327
+ // Preferred: the work-level endpoint (the platform picks the work theme's home page). A platform without it
328
+ // answers 404/405 (no write); then the page scan below.
329
+ let link = null;
330
+ let scan = true;
331
+ if (typeof api.createWorkPreviewLink === "function") {
332
+ try {
333
+ link = await api.createWorkPreviewLink(work.id);
334
+ scan = false;
335
+ } catch (error) {
336
+ if (!(error instanceof CliRefusal && (error.status === 404 || error.status === 405))) throw definiteOrUnknown(error, "creating the preview link");
337
+ }
338
+ }
339
+ if (scan) {
340
+ let pages;
341
+ try {
342
+ pages = await api.listPages();
343
+ } catch (error) {
344
+ throw definiteOrUnknown(error, "listing the work's pages");
345
+ }
346
+ const page = previewPage(pages, work.theme);
347
+ if (page) {
348
+ try {
349
+ link = await api.createPreviewLink(work.theme, page.id);
350
+ } catch (error) {
351
+ throw definiteOrUnknown(error, "creating the preview link");
352
+ }
353
+ }
354
+ }
355
+ previewUrl = typeof link?.preview_link?.url === "string" ? link.preview_link.url : null;
356
+ if (previewUrl) {
357
+ preview = "created";
358
+ save({ state: "previewed" });
359
+ } else preview = link ? "no_link" : "no_page";
360
+ }
361
+
362
+ let after;
363
+ try {
364
+ after = await api.siteStatus();
365
+ } catch (error) {
366
+ throw definiteOrUnknown(error, "reading the live theme back");
367
+ }
368
+ const liveAfter = liveId(after);
369
+ return {
370
+ state: s.state,
371
+ work,
372
+ previewUrl,
373
+ preview,
374
+ previewCreatedEarlier: preview === "created_earlier",
375
+ liveBefore: s.live_before ?? null,
376
+ liveAfter,
377
+ liveUnchanged: String(s.live_before ?? null) === String(liveAfter ?? null),
378
+ };
379
+ } finally {
380
+ release();
381
+ }
382
+ }