@blocofy/cli 0.10.0 → 0.11.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 +155 -20
- package/bin/blocofy.mjs +210 -42
- package/lib/http.mjs +4 -1
- package/lib/output.mjs +23 -4
- package/lib/site-state.mjs +33 -10
- package/lib/target.mjs +71 -13
- package/lib/theme-sync.mjs +163 -63
- package/package.json +1 -1
package/lib/site-state.mjs
CHANGED
|
@@ -196,7 +196,17 @@ export function checkSiteStateTreePaths(paths) {
|
|
|
196
196
|
|
|
197
197
|
export const ASSET_REF_PREFIX = "asset:sha256:";
|
|
198
198
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
199
|
-
|
|
199
|
+
/** Mirror of the platform's `SVG_ASSET_ID_RE` (media/media-ref.ts): a platform SVG asset id, stored lowercase only. */
|
|
200
|
+
const SVG_ASSET_ID_RE = /^bsvg_[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
|
|
201
|
+
// Mirror of the platform's asset-ref.ts grammar (W1 C1 + a8e8666c), piece for piece: FULL-VALUE anchored — only a
|
|
202
|
+
// value that IS one platform asset URL matches; HTML, CSS url(...) or a sentence containing one is carried as is.
|
|
203
|
+
// Whitespace, ", <, > and \ appear nowhere; the host also excludes ' ( ); the path, query and fragment admit ( ) '
|
|
204
|
+
// (encodeURIComponent leaves them unescaped in a media-picker URL such as ".../logo%20(1).png").
|
|
205
|
+
const ASSET_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
206
|
+
const ASSET_HOST = String.raw`(?:(?:https?:)?\/\/[^\s\/?#"'<>()\\]+)?`;
|
|
207
|
+
const ASSET_TAIL = String.raw`(?:\/[^\s?#"<>\\]*)?(?:\?[^\s#"<>\\]*)?(?:#[^\s"<>\\]*)?`;
|
|
208
|
+
export const PLATFORM_ASSET_RE = new RegExp(String.raw`^${ASSET_HOST}\/(?:cdn|assets)\/(${ASSET_UUID})${ASSET_TAIL}$`, "i");
|
|
209
|
+
export const PLATFORM_SVG_RE = new RegExp(String.raw`^${ASSET_HOST}\/cdn\/svg\/(bsvg_[0-9abcdefghjkmnpqrstvwxyz]{26})${ASSET_TAIL}$`);
|
|
200
210
|
export const PLATFORM_METADATA_KEY = "platform";
|
|
201
211
|
export const MEDIA_DECISION_AUDIT_FIELDS = ["decided_by", "decided_at", "policy_version"];
|
|
202
212
|
|
|
@@ -208,14 +218,26 @@ export function parseAssetRef(value) {
|
|
|
208
218
|
return SHA256_HEX.test(sha) ? sha : null;
|
|
209
219
|
}
|
|
210
220
|
|
|
211
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Mirror of the platform's `parseMediaRef` (media/media-ref.ts): a bare stored reference → its canonical id (a
|
|
223
|
+
* Directus uuid, lowercased; a platform SVG id exactly as stored), or null.
|
|
224
|
+
*/
|
|
225
|
+
function mediaRefId(value) {
|
|
226
|
+
if (typeof value !== "string") return null;
|
|
227
|
+
if (SVG_ASSET_ID_RE.test(value)) return value;
|
|
228
|
+
if (UUID_RE.test(value)) return value.toLowerCase();
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Mirror of the platform's `normalizeAssetRef` (asset-ref.ts): a value that IS one platform asset reference → its id. */
|
|
212
233
|
export function normalizeAssetRef(value) {
|
|
213
234
|
if (typeof value !== "string") return value;
|
|
214
235
|
const v = value.trim();
|
|
215
236
|
if (!v) return value;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
237
|
+
const bare = mediaRefId(v);
|
|
238
|
+
if (bare) return bare;
|
|
239
|
+
const m = v.match(PLATFORM_ASSET_RE) ?? v.match(PLATFORM_SVG_RE);
|
|
240
|
+
if (m) return mediaRefId(m[1]) ?? value;
|
|
219
241
|
return value;
|
|
220
242
|
}
|
|
221
243
|
|
|
@@ -236,12 +258,13 @@ function mapStrings(value, fn) {
|
|
|
236
258
|
|
|
237
259
|
export function encodeAssetRefs(value, shaOf, report) {
|
|
238
260
|
return mapStrings(value, (s) => {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
261
|
+
// Full-value anchored (W1 C1): only a string that IS one reference is rewritten. A platform SVG id goes through
|
|
262
|
+
// the same lookup as a Directus uuid.
|
|
263
|
+
const ref = mediaRefId(normalizeAssetRef(s));
|
|
264
|
+
if (ref === null) return s;
|
|
265
|
+
const sha = shaOf(ref);
|
|
243
266
|
if (sha === null) {
|
|
244
|
-
report.foreign.add(
|
|
267
|
+
report.foreign.add(ref);
|
|
245
268
|
return s;
|
|
246
269
|
}
|
|
247
270
|
report.used.add(sha);
|
package/lib/target.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync,
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { fetchWithRetry } from "./http.mjs";
|
|
6
|
+
import { API_KEY_PREFIX } from "./media-uses.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* CF-T1/T2 (contract C2) — project binding + target resolution + remote identity verification.
|
|
@@ -244,18 +245,32 @@ function contextMatchesBinding(ctx, project) {
|
|
|
244
245
|
return isObj(ctx?.site) && sameId(ctx.site.id, project.site_id) && compareOrigin(ctx.platform_origin, project.platform_origin) !== "mismatch";
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
const authorityLabel = (a) => (a.source === "env" ? "env credentials (BLOCOFY_URL/BLOCOFY_TOKEN, BLOCOFY_API_URL/BLOCOFY_API_KEY)" : `${a.source}=${a.name}`);
|
|
249
|
+
|
|
247
250
|
/**
|
|
248
251
|
* Contract C2 order: --context → BLOCOFY_CONTEXT → full env pair → .blocofy/local.json → the unique context
|
|
249
252
|
* matching the binding (site.id + platform_origin) → TTY pick among matching candidates → TARGET_CONTEXT_REQUIRED.
|
|
250
|
-
*
|
|
253
|
+
*
|
|
254
|
+
* 1.8 closure (zero silent fallback): BLOCOFY_CONTEXT, the env pair and `.blocofy/local.json` are each a stated
|
|
255
|
+
* choice. When two of them name DIFFERENT contexts the command is refused before anything is read
|
|
256
|
+
* (TARGET_CONTEXT_CONFLICT, exit 3) — only an explicit `--context` settles it, and the result still lists what it
|
|
257
|
+
* overrode (`ignored`). With a binding present `current_context` is ignored; without one it is used only by the
|
|
258
|
+
* commands that opt in (`allowCurrentContext`: status, target, pages check — contract C2).
|
|
251
259
|
*
|
|
252
260
|
* `getStore()` is called only when a named context is needed (a corrupt file does not block the env context).
|
|
253
|
-
* `prompt(candidates)` → name (TTY only). Returns `{ name, source, context, env? }
|
|
261
|
+
* `prompt(candidates)` → name (TTY only). Returns `{ name, source, context, ignored, env? }`; `ignored` lists the
|
|
262
|
+
* stated authorities (`{ source, name }`) that named another context.
|
|
254
263
|
*/
|
|
255
|
-
export async function resolveContext({ flagContext = null, envContextName = null, envCtx = null, getStore, binding = null,
|
|
264
|
+
export async function resolveContext({ flagContext = null, envContextName = null, envCtx = null, getStore, binding = null, allowCurrentContext = false, isTTY = false, prompt = null }) {
|
|
265
|
+
const stated = [];
|
|
266
|
+
if (envContextName) stated.push({ source: "BLOCOFY_CONTEXT", name: envContextName });
|
|
267
|
+
if (envCtx) stated.push({ source: "env", name: ENV_CONTEXT });
|
|
268
|
+
if (binding?.localContext) stated.push({ source: ".blocofy/local.json", name: binding.localContext });
|
|
269
|
+
|
|
256
270
|
const named = (name, source) => {
|
|
271
|
+
const ignored = stated.filter((a) => a.name !== name);
|
|
257
272
|
if (name === ENV_CONTEXT) {
|
|
258
|
-
if (envCtx) return { name: ENV_CONTEXT, source, context: envCtx.context, env: envCtx };
|
|
273
|
+
if (envCtx) return { name: ENV_CONTEXT, source, context: envCtx.context, env: envCtx, ignored };
|
|
259
274
|
throw new TargetError("TARGET_CONTEXT_UNKNOWN", `Context "env" needs BLOCOFY_URL+BLOCOFY_TOKEN or BLOCOFY_API_URL+BLOCOFY_API_KEY.`, { context: name });
|
|
260
275
|
}
|
|
261
276
|
const store = getStore();
|
|
@@ -263,12 +278,18 @@ export async function resolveContext({ flagContext = null, envContextName = null
|
|
|
263
278
|
if (!ctx) {
|
|
264
279
|
throw new TargetError("TARGET_CONTEXT_UNKNOWN", `No context named "${name}" (from ${source}). List them with \`blocofy contexts\`.`, { context: name, source });
|
|
265
280
|
}
|
|
266
|
-
return { name, source, context: ctx };
|
|
281
|
+
return { name, source, context: ctx, ignored };
|
|
267
282
|
};
|
|
268
283
|
|
|
269
284
|
if (flagContext) return named(flagContext, "--context");
|
|
270
|
-
if (
|
|
271
|
-
|
|
285
|
+
if (new Set(stated.map((a) => a.name)).size > 1) {
|
|
286
|
+
throw new TargetError(
|
|
287
|
+
"TARGET_CONTEXT_CONFLICT",
|
|
288
|
+
`Conflicting context choices: ${stated.map(authorityLabel).join(" vs ")}. Nothing was read or written. Choose one explicitly with --context <name>, or unset the others.`,
|
|
289
|
+
{ authorities: stated },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (stated.length) return named(stated[0].name, stated[0].source);
|
|
272
293
|
|
|
273
294
|
const store = getStore();
|
|
274
295
|
const names = Object.keys(store.contexts);
|
|
@@ -277,12 +298,11 @@ export async function resolveContext({ flagContext = null, envContextName = null
|
|
|
277
298
|
}
|
|
278
299
|
|
|
279
300
|
if (binding) {
|
|
280
|
-
if (binding.localContext) return named(binding.localContext, ".blocofy/local.json");
|
|
281
301
|
const candidates = names.filter((n) => contextMatchesBinding(store.contexts[n], binding.project));
|
|
282
|
-
if (candidates.length === 1) return { name: candidates[0], source: "binding", context: store.contexts[candidates[0]] };
|
|
302
|
+
if (candidates.length === 1) return { name: candidates[0], source: "binding", context: store.contexts[candidates[0]], ignored: [] };
|
|
283
303
|
if (candidates.length > 1 && isTTY && prompt) {
|
|
284
304
|
const picked = await prompt(candidates);
|
|
285
|
-
if (candidates.includes(picked)) return { name: picked, source: "prompt", context: store.contexts[picked] };
|
|
305
|
+
if (candidates.includes(picked)) return { name: picked, source: "prompt", context: store.contexts[picked], ignored: [] };
|
|
286
306
|
}
|
|
287
307
|
throw new TargetError(
|
|
288
308
|
"TARGET_CONTEXT_REQUIRED",
|
|
@@ -293,10 +313,14 @@ export async function resolveContext({ flagContext = null, envContextName = null
|
|
|
293
313
|
);
|
|
294
314
|
}
|
|
295
315
|
|
|
296
|
-
if (
|
|
297
|
-
return { name: store.current_context, source: "current_context", context: store.contexts[store.current_context] };
|
|
316
|
+
if (allowCurrentContext && store.current_context && store.contexts[store.current_context]) {
|
|
317
|
+
return { name: store.current_context, source: "current_context", context: store.contexts[store.current_context], ignored: [] };
|
|
298
318
|
}
|
|
299
|
-
throw new TargetError(
|
|
319
|
+
throw new TargetError(
|
|
320
|
+
"TARGET_CONTEXT_REQUIRED",
|
|
321
|
+
"This directory is not bound to a site, so the site must be chosen explicitly: pass --context <name> (see `blocofy contexts`), set BLOCOFY_CONTEXT, or bind the directory (blocofy link <dir> --context <name>).",
|
|
322
|
+
{ contexts: names },
|
|
323
|
+
);
|
|
300
324
|
}
|
|
301
325
|
|
|
302
326
|
/** Offline check before any network: a context already recorded for another site cannot serve this binding. */
|
|
@@ -312,6 +336,40 @@ export function precheckContext({ binding, resolved }) {
|
|
|
312
336
|
}
|
|
313
337
|
}
|
|
314
338
|
|
|
339
|
+
/** The theme dev token's prefix (the v1 API key's is API_KEY_PREFIX, `blcf_live_`). */
|
|
340
|
+
const DEV_TOKEN_PREFIX = "bcf_";
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 1.8: the dev token and the v1 API key are two separate credentials and stay two. A pair that holds the other
|
|
344
|
+
* type's secret is refused before any request (TARGET_CREDENTIAL_WRONG_TYPE, exit 1) — it is never sent to the
|
|
345
|
+
* other endpoint, and never silently used as the other type. The message names where the secret came from, never
|
|
346
|
+
* the secret.
|
|
347
|
+
*/
|
|
348
|
+
export function assertCredentialTypes({ resolved, secrets }) {
|
|
349
|
+
const refuse = (kind, secret) => {
|
|
350
|
+
const env = Boolean(resolved.env);
|
|
351
|
+
const where = kind === "dev" ? (env ? "BLOCOFY_TOKEN" : `The dev token of context "${resolved.name}"`) : env ? "BLOCOFY_API_KEY" : `The API key of context "${resolved.name}"`;
|
|
352
|
+
const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX);
|
|
353
|
+
const holds = kind === "dev" ? (other ? "holds a v1 API key (blcf_…)" : "is not a dev token") : other ? "holds a theme dev token (bcf_…)" : "is not a v1 API key";
|
|
354
|
+
const expected = kind === "dev" ? `a theme dev token starts with ${DEV_TOKEN_PREFIX}` : `a v1 API key starts with ${API_KEY_PREFIX}`;
|
|
355
|
+
const fix = env
|
|
356
|
+
? kind === "dev"
|
|
357
|
+
? "put the dev token in BLOCOFY_TOKEN and the API key in BLOCOFY_API_KEY (+ BLOCOFY_API_URL)"
|
|
358
|
+
: "put the API key in BLOCOFY_API_KEY and the dev token in BLOCOFY_TOKEN (+ BLOCOFY_URL)"
|
|
359
|
+
: kind === "dev"
|
|
360
|
+
? `run \`blocofy login --context ${resolved.name}\``
|
|
361
|
+
: `run \`blocofy login --api-key --context ${resolved.name}\``;
|
|
362
|
+
throw new TargetError(
|
|
363
|
+
"TARGET_CREDENTIAL_WRONG_TYPE",
|
|
364
|
+
`${where} ${holds}; ${expected}. The dev token and the v1 API key are separate credentials: ${fix}. Nothing was sent.`,
|
|
365
|
+
{ context: resolved.name, pair: kind },
|
|
366
|
+
1,
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
if (resolved.context.dev && typeof secrets.devToken === "string" && !secrets.devToken.startsWith(DEV_TOKEN_PREFIX)) refuse("dev", secrets.devToken);
|
|
370
|
+
if (resolved.context.api && typeof secrets.apiKey === "string" && !(secrets.apiKey.startsWith(API_KEY_PREFIX) && secrets.apiKey.length > API_KEY_PREFIX.length)) refuse("api", secrets.apiKey);
|
|
371
|
+
}
|
|
372
|
+
|
|
315
373
|
// ── remote identity ─────────────────────────────────────────────────────────────────────────────────────────
|
|
316
374
|
|
|
317
375
|
const IDENTITY_TIMEOUT_MS = 15000;
|
package/lib/theme-sync.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
|
2
3
|
|
|
3
4
|
import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs";
|
|
@@ -210,6 +211,74 @@ export async function fetchDevSession({ url, token, name = null, onRetry = null
|
|
|
210
211
|
return res.json();
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* TPUSH-5 (6.5) — what a dry run binds the apply to. A server with the 6.5 contract answers a keyed dry run with the
|
|
216
|
+
* `manifestHash` of the files it checked and the `pointerVersion` its plan ran from (null: no deployment yet); the
|
|
217
|
+
* apply sends both back (`manifestHash`, `expectedPointerVersion`) so it is refused unless it writes exactly those
|
|
218
|
+
* bytes over exactly that state. An older server answers neither and gets the 0.10 body unchanged.
|
|
219
|
+
* The pointer version is per instance, so the apply also names the instance the plan ran against: a server that
|
|
220
|
+
* answers `targetInstance` (an opaque theme handle, or null for "a new draft") gets it back as
|
|
221
|
+
* `expectedTargetInstance` and refuses 409 `target_changed` when the apply would write anywhere else.
|
|
222
|
+
*/
|
|
223
|
+
function pushBindings(plan) {
|
|
224
|
+
if (typeof plan?.manifestHash !== "string" || !/^[0-9a-f]{64}$/.test(plan.manifestHash)) return {};
|
|
225
|
+
const v = plan.pointerVersion;
|
|
226
|
+
const t = plan.targetInstance;
|
|
227
|
+
return {
|
|
228
|
+
manifestHash: plan.manifestHash,
|
|
229
|
+
...(v === null || (Number.isInteger(v) && v >= 0) ? { expectedPointerVersion: v } : {}),
|
|
230
|
+
...(t === null || (typeof t === "string" && t !== "") ? { expectedTargetInstance: t } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The paths a dry-run plan says the push would remove. `config/settings.json` is left out: a stored row there is the
|
|
236
|
+
* anomaly every canonical deploy cleans up (the settings themselves live in site_themes), not a theme file.
|
|
237
|
+
*/
|
|
238
|
+
function plannedRemovals(plan) {
|
|
239
|
+
if (!Array.isArray(plan?.files)) return [];
|
|
240
|
+
return plan.files.filter((f) => f?.outcome === "removed" && typeof f.path === "string" && f.path !== "config/settings.json").map((f) => f.path);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function isReadbackUnverified(res) {
|
|
244
|
+
try {
|
|
245
|
+
return (await res.json())?.error === "readback_unverified";
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The paths a push can send: the mirror of the server's gate — multi-segment paths under THEME_DIRS plus the one
|
|
253
|
+
* config exception (a slash-less remote key such as a bare `layout` would be refused 422 by the server).
|
|
254
|
+
*/
|
|
255
|
+
function sendable(key) {
|
|
256
|
+
return (key.includes("/") && THEME_DIRS.has(key.split("/")[0] ?? "")) || key === "config/settings_schema.json";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* THEME_PUSH_TARGET_CHANGED — the plan would remove files this push did not carry, and says only what is true of them.
|
|
261
|
+
* A path the push cannot send (outside the merge mirror, e.g. a bare `layout`) is never kept by a re-run, nor by a
|
|
262
|
+
* local file at that path: only --prune gets past it. That includes every path the merge probe returned, since a
|
|
263
|
+
* sendable one would have been merged. Any other path was either added while the push ran (a re-run reads and keeps
|
|
264
|
+
* it) or is a row the probe cannot list (it lists published rows only), which no re-run keeps either.
|
|
265
|
+
*/
|
|
266
|
+
function targetChangedError(paths, remote) {
|
|
267
|
+
const notCarryable = paths.filter((p) => Object.hasOwn(remote, p) || !sendable(p));
|
|
268
|
+
const unseen = paths.filter((p) => !notCarryable.includes(p));
|
|
269
|
+
const parts = [`The dry run would remove ${paths.length} file(s) this push does not have locally (${paths.join(", ")}). Nothing was written.`];
|
|
270
|
+
if (notCarryable.length) {
|
|
271
|
+
parts.push(`${notCarryable.join(", ")}: the push cannot carry these paths, so running it again will not keep them; it can go ahead only with --prune, which removes them.`);
|
|
272
|
+
}
|
|
273
|
+
if (unseen.length) {
|
|
274
|
+
parts.push(
|
|
275
|
+
`${unseen.join(", ")}: not on the target when this push read it. If they were added while this push was running, run the push again and it keeps them. ` +
|
|
276
|
+
"If the same files stop the push again, the push cannot read them (for example a theme file that is not published) and cannot keep them: pass --prune to remove them, or add a local file at that path to replace it.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return Object.assign(new Error(parts.join(" ")), { code: "THEME_PUSH_TARGET_CHANGED", details: { paths, notCarryable } });
|
|
280
|
+
}
|
|
281
|
+
|
|
213
282
|
/**
|
|
214
283
|
* Write the local theme to the site (create/update; no delete). With `draft`,
|
|
215
284
|
* writes to a draft theme instance instead of the live theme — preview & publish
|
|
@@ -220,78 +289,109 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
220
289
|
const files = readLocalTemplates(dir);
|
|
221
290
|
const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
|
|
222
291
|
const postHeaders = (extra = {}) => headersFor({ "content-type": "application/json", "x-blocofy-optional-capabilities": OPTIONAL_CAPABILITIES, ...extra });
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
// 0.5.0 kanonik push üç aşamadır (denetim düzeltmeleri):
|
|
227
|
-
// 1) PREFLIGHT dry-run POST (yalnız yerel küme): 422/426 sınıfı hatalar SUNUCUDA HİÇBİR ŞEY
|
|
228
|
-
// provizyonlanmadan yakalanır — probe GET'i taslak yaratabildiği için başarısız bir push
|
|
229
|
-
// arkada instance + sayfa klonu bırakıyordu.
|
|
230
|
-
// 2) MERGE PROBE GET: "push silmez" garantisi — uzakta olup yerelde olmayan, kapı-kabul-eden
|
|
231
|
-
// dosyalar payload'a aynen eklenir; kapı-dışı satırları (README.md vb.) sunucunun
|
|
232
|
-
// retained-rows kuralı korur. `--prune` bu dosyaları EKLEMEZ (kanonik deploy klasörleri
|
|
233
|
-
// atomik değiştirir → silinirler) ve yazımdan ÖNCE `confirmPrune(liste)`'ye bildirir.
|
|
234
|
-
// PS-09: taslak hedefte taslak-bayraklı GET KULLANILMAZ — sunucuda taslak provizyonlar (yan
|
|
235
|
-
// etki; yarıda kalırsa boş taslak bırakırdı). Mevcut CLI taslağı `/api/dev/site`'tan
|
|
236
|
-
// (`source === "import"`, sunucunun ensureDraftInstance seçimiyle aynı: id sıralı ilk) bulunup
|
|
237
|
-
// `?instance=` ile yoklanır; yoksa yoklanacak uzak dosya da yoktur → probe atlanır.
|
|
238
|
-
// 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
|
|
239
|
-
const pre = await fetchWithRetry(
|
|
292
|
+
const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
|
|
293
|
+
const post = (body, key, retry = {}) =>
|
|
294
|
+
fetchWithRetry(
|
|
240
295
|
`${base}/api/dev/theme`,
|
|
241
|
-
{ method: "POST", headers: postHeaders({ "x-idempotency-key":
|
|
242
|
-
{ onRetry },
|
|
296
|
+
{ method: "POST", headers: postHeaders(key ? { "x-idempotency-key": key } : {}), body: JSON.stringify(body) },
|
|
297
|
+
{ onRetry, ...retry },
|
|
243
298
|
);
|
|
244
|
-
if (!pre.ok) throw await httpError(pre);
|
|
245
299
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
300
|
+
// Keyless (theme dev's draft sync): one POST to the per-file writer — no probe, no plan (deliberate legacy path).
|
|
301
|
+
if (!idempotencyKey) {
|
|
302
|
+
const res = await post(dryRun ? { ...target, dryRun: true } : target, null);
|
|
303
|
+
if (!res.ok) throw await httpError(res);
|
|
304
|
+
return res.json();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Keyed canonical push (and its --dry-run), TPUSH-5: ONE manifest for the dry run and the apply.
|
|
308
|
+
// 1) MERGE PROBE GET — "push does not delete": remote files missing locally that the server's gate accepts are
|
|
309
|
+
// added to the payload verbatim; rows outside that set (README.md…) are kept by the server's retained-rows
|
|
310
|
+
// rule. `--prune` leaves them out instead (the canonical deploy replaces the folders, so they are removed).
|
|
311
|
+
// PS-09: a draft target is never probed with a draft-flagged GET (that provisions a draft server-side). The
|
|
312
|
+
// existing CLI draft is found via `/api/dev/site` (`source === "import"`, the server's own pick: the first by
|
|
313
|
+
// id) and probed by handle; with no draft there is nothing remote to merge, so there is no probe.
|
|
314
|
+
// 2) DRY RUN of that MERGED payload. On a 6.5 server this is the control plane's rolled-back plan of the very
|
|
315
|
+
// operation the apply runs: refused by what the apply is refused by, with per-file outcomes, the manifest hash
|
|
316
|
+
// and the pointer version it ran from. It carries its OWN throwaway key: the control plane fingerprints the
|
|
317
|
+
// action, so a plan under a key an earlier deploy already spent is refused 409 — a same-key retry of a
|
|
318
|
+
// committed push could never converge. A plan removing a file the probe did not see means the target changed
|
|
319
|
+
// under this push; without --prune that is refused (push does not delete), with --prune it is confirmed.
|
|
320
|
+
// 3) APPLY of the same payload under the push key, bound by `manifestHash` + `expectedPointerVersion`.
|
|
321
|
+
// theme dev's sync sends no key and pays none of this.
|
|
322
|
+
const remoteOnlyKept = [];
|
|
323
|
+
const remoteOnlyRemoved = [];
|
|
324
|
+
let probeInstance = instance;
|
|
325
|
+
if (!instance && draft) {
|
|
326
|
+
const status = await fetchSiteStatus({ url, token, onRetry });
|
|
327
|
+
probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
|
|
328
|
+
}
|
|
329
|
+
let remote = {};
|
|
330
|
+
if (probeInstance || !draft) {
|
|
331
|
+
const query = probeInstance ? `?instance=${encodeURIComponent(probeInstance)}` : "";
|
|
332
|
+
const probe = await fetchWithRetry(`${base}/api/dev/theme${query}`, { headers: headersFor() }, { onRetry });
|
|
333
|
+
if (!probe.ok) throw await httpError(probe);
|
|
334
|
+
remote = (await probe.json()).files ?? {};
|
|
335
|
+
}
|
|
336
|
+
for (const [key, content] of Object.entries(remote)) {
|
|
337
|
+
if (key in files) continue;
|
|
338
|
+
if (sendable(key)) {
|
|
339
|
+
if (prune) {
|
|
340
|
+
remoteOnlyRemoved.push(key);
|
|
341
|
+
} else {
|
|
342
|
+
files[key] = typeof content === "string" ? content : "";
|
|
343
|
+
remoteOnlyKept.push(key);
|
|
270
344
|
}
|
|
271
345
|
}
|
|
272
|
-
remoteOnlyKept.sort();
|
|
273
|
-
remoteOnlyRemoved.sort();
|
|
274
|
-
if (remoteOnlyRemoved.length && confirmPrune && !(await confirmPrune(remoteOnlyRemoved))) {
|
|
275
|
-
return { aborted: true, remoteOnlyRemoved };
|
|
276
|
-
}
|
|
277
346
|
}
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
const json = await res.json();
|
|
290
|
-
return {
|
|
291
|
-
...json,
|
|
347
|
+
|
|
348
|
+
// A plan's 502 readback_unverified is a verdict on a rolled-back plan, not a transient failure: not resent.
|
|
349
|
+
const pre = await post({ ...target, dryRun: true }, `cli-plan-${randomUUID()}`, { isFinal: isReadbackUnverified });
|
|
350
|
+
if (!pre.ok) throw Object.assign(await httpError(pre), { phase: "plan" });
|
|
351
|
+
const plan = await pre.json();
|
|
352
|
+
const unexpected = plannedRemovals(plan).filter((p) => !remoteOnlyRemoved.includes(p)).sort();
|
|
353
|
+
if (unexpected.length && !prune) throw targetChangedError(unexpected, remote);
|
|
354
|
+
remoteOnlyRemoved.push(...unexpected);
|
|
355
|
+
remoteOnlyKept.sort();
|
|
356
|
+
remoteOnlyRemoved.sort();
|
|
357
|
+
const extras = {
|
|
292
358
|
...(remoteOnlyKept.length ? { remoteOnlyKept } : {}),
|
|
293
359
|
...(remoteOnlyRemoved.length ? { remoteOnlyRemoved } : {}),
|
|
294
360
|
};
|
|
361
|
+
if (dryRun) return { ...plan, ...extras };
|
|
362
|
+
if (remoteOnlyRemoved.length && confirmPrune && !(await confirmPrune(remoteOnlyRemoved))) {
|
|
363
|
+
return { aborted: true, remoteOnlyRemoved };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const bindings = pushBindings(plan);
|
|
367
|
+
// An apply attempt without a certain answer (network error, 429/502/503/504) is resent under the same key. A
|
|
368
|
+
// refusal of the resend says nothing about that earlier attempt: "unknown", or "committed" when its answer said so.
|
|
369
|
+
let earlierAttempt = null;
|
|
370
|
+
const applyError = (err) => {
|
|
371
|
+
Object.assign(err, { phase: "apply" });
|
|
372
|
+
// target_changed names the current target; the one this push planned against is known only here.
|
|
373
|
+
if ("expectedTargetInstance" in bindings) err.expectedTargetInstance = bindings.expectedTargetInstance;
|
|
374
|
+
if (earlierAttempt) err.earlierAttempt = earlierAttempt;
|
|
375
|
+
return err;
|
|
376
|
+
};
|
|
377
|
+
let res;
|
|
378
|
+
try {
|
|
379
|
+
res = await post({ ...target, ...bindings }, idempotencyKey, {
|
|
380
|
+
onRetry: (info) => {
|
|
381
|
+
earlierAttempt ??= "unknown";
|
|
382
|
+
onRetry?.(info);
|
|
383
|
+
},
|
|
384
|
+
isFinal: async (attempt) => {
|
|
385
|
+
if ((await attempt.json().catch(() => null))?.committed === true) earlierAttempt = "committed";
|
|
386
|
+
return false;
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
} catch (error) {
|
|
390
|
+
// No answer to the last attempt either (network error, timeout): the write's outcome is unknown.
|
|
391
|
+
throw applyError(error);
|
|
392
|
+
}
|
|
393
|
+
if (!res.ok) throw applyError(await httpError(res));
|
|
394
|
+
return { ...(await res.json()), ...extras };
|
|
295
395
|
}
|
|
296
396
|
|
|
297
397
|
/**
|
package/package.json
CHANGED