@blocofy/cli 0.8.0 → 0.10.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/target.mjs ADDED
@@ -0,0 +1,427 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ import { fetchWithRetry } from "./http.mjs";
6
+
7
+ /**
8
+ * CF-T1/T2 (contract C2) — project binding + target resolution + remote identity verification.
9
+ *
10
+ * A project is bound to ONE site by `<root>/.blocofy/project.json` (shared, secret-free). The user's context
11
+ * choice for that project is `<root>/.blocofy/local.json` (git-ignored). No global "last login" is ever the
12
+ * authority for a mutation: with a binding present `current_context` is ignored, and a remote-mutation without a
13
+ * binding is refused before any network call.
14
+ */
15
+
16
+ /** A target/binding refusal: exit 3, nothing read or written. `exitCode` 1 for LOGIN_REQUIRED (usage). */
17
+ export class TargetError extends Error {
18
+ constructor(code, message, details = {}, exitCode = 3) {
19
+ super(message);
20
+ this.name = "TargetError";
21
+ this.code = code;
22
+ this.details = details;
23
+ this.exitCode = exitCode;
24
+ }
25
+ }
26
+
27
+ /** The ephemeral environment-variable context (same name as credentials.mjs ENV_CONTEXT). */
28
+ const ENV_CONTEXT = "env";
29
+
30
+ export const COMMAND_CLASSES = new Set(["remote-mutation", "local-write", "read"]);
31
+ const PROJECT_DIR = ".blocofy";
32
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
33
+ const sameId = (a, b) => a !== null && a !== undefined && b !== null && b !== undefined && String(a) === String(b);
34
+ const origin = (v) => (typeof v === "string" && v ? v : null);
35
+
36
+ /**
37
+ * Platform-origin rule (orchestrator decision, CF-T2 follow-up). Servers deployed before contract C3 do not send
38
+ * `platform_origin`, so records created against them hold null.
39
+ * recorded == remote (both non-null) → "match"
40
+ * recorded null, remote non-null → "upgrade" (proceeds with one stderr warning; nothing is rewritten)
41
+ * recorded non-null, remote null → "unproven" (TARGET_UNVERIFIED: the server does not prove its platform)
42
+ * both null → "match"
43
+ * two different non-null values → "mismatch"
44
+ * The site id must match separately in every case.
45
+ */
46
+ export function compareOrigin(recorded, remote) {
47
+ const a = origin(recorded);
48
+ const b = origin(remote);
49
+ if (a === b) return "match";
50
+ if (a === null) return "upgrade";
51
+ if (b === null) return "unproven";
52
+ return "mismatch";
53
+ }
54
+
55
+ export const CONTEXT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
56
+
57
+ // ── binding ─────────────────────────────────────────────────────────────────────────────────────────────────
58
+
59
+ /** First `.blocofy/project.json` at `startDir` or an ancestor → `{ root, projectPath, project, localContext }` | null. */
60
+ export function findBinding(startDir) {
61
+ let current = resolve(startDir);
62
+ for (;;) {
63
+ const projectPath = join(current, PROJECT_DIR, "project.json");
64
+ if (existsSync(projectPath)) return readBinding(current);
65
+ const parent = dirname(current);
66
+ if (parent === current) return null;
67
+ current = parent;
68
+ }
69
+ }
70
+
71
+ function readBinding(root) {
72
+ const projectPath = join(root, PROJECT_DIR, "project.json");
73
+ let project;
74
+ try {
75
+ project = JSON.parse(readFileSync(projectPath, "utf8"));
76
+ } catch {
77
+ project = null;
78
+ }
79
+ if (!isObj(project) || project.schema_version !== 1 || !(typeof project.site_id === "string" || Number.isFinite(project.site_id))) {
80
+ throw new TargetError("TARGET_BINDING_INVALID", `The project binding is not valid: ${projectPath}. Nothing was read or written. Re-create it with \`blocofy link --context <name> --adopt\`.`, { binding: projectPath });
81
+ }
82
+ let localContext = null;
83
+ const localPath = join(root, PROJECT_DIR, "local.json");
84
+ if (existsSync(localPath)) {
85
+ try {
86
+ const local = JSON.parse(readFileSync(localPath, "utf8"));
87
+ if (isObj(local) && typeof local.context === "string" && local.context) localContext = local.context;
88
+ } catch {
89
+ throw new TargetError("TARGET_BINDING_INVALID", `The local context file is not valid JSON: ${localPath}. Nothing was read or written.`, { binding: localPath });
90
+ }
91
+ }
92
+ return { root, projectPath, project, localContext };
93
+ }
94
+
95
+ function lstatOrNull(p) {
96
+ try {
97
+ return lstatSync(p);
98
+ } catch (error) {
99
+ if (error?.code === "ENOENT") return null;
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Write `.blocofy/project.json` (+ `local.json` for a named context, + `.gitignore`). Review I2: never follows a
106
+ * symlink — `.blocofy` must be a real directory and none of the three files may be a symlink (checked for all
107
+ * before the first write); each file is written to a fresh temp file in the same directory (`wx`, 0644) and
108
+ * renamed into place. `staleLocal: true` (link) removes a `local.json` that an env-context binding does not own.
109
+ */
110
+ export function writeBinding(root, { site, platformOrigin, contextName, staleLocal = false }) {
111
+ const d = join(root, PROJECT_DIR);
112
+ const refuse = (path, what) => {
113
+ throw new TargetError("TARGET_BINDING_INVALID", `${path} ${what}; the project binding was not written.`, { binding: path });
114
+ };
115
+ let st = lstatOrNull(d);
116
+ if (st && (st.isSymbolicLink() || !st.isDirectory())) refuse(d, "is not a real directory (symlink or file)");
117
+ if (!st) {
118
+ mkdirSync(root, { recursive: true });
119
+ mkdirSync(d);
120
+ st = lstatSync(d);
121
+ if (st.isSymbolicLink() || !st.isDirectory()) refuse(d, "is not a real directory");
122
+ }
123
+ const project = { schema_version: 1, site_id: site.id, site_slug: site.slug ?? null, platform_origin: origin(platformOrigin) };
124
+ const writes = [["project.json", JSON.stringify(project, null, 2) + "\n"]];
125
+ const ownsLocal = contextName && contextName !== ENV_CONTEXT;
126
+ if (ownsLocal) writes.push(["local.json", JSON.stringify({ context: contextName }, null, 2) + "\n"]);
127
+ writes.push([".gitignore", "local.json\n"]);
128
+ for (const name of ["project.json", "local.json", ".gitignore"]) {
129
+ const f = lstatOrNull(join(d, name));
130
+ if (f && (f.isSymbolicLink() || !f.isFile())) refuse(join(d, name), "is a symlink or not a regular file");
131
+ }
132
+ for (const [name, content] of writes) {
133
+ const target = join(d, name);
134
+ const tmp = join(d, `.${name}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
135
+ try {
136
+ writeFileSync(tmp, content, { flag: "wx", mode: 0o644 });
137
+ renameSync(tmp, target);
138
+ } catch (error) {
139
+ rmSync(tmp, { force: true });
140
+ throw error;
141
+ }
142
+ }
143
+ if (!ownsLocal && staleLocal) rmSync(join(d, "local.json"), { force: true });
144
+ return join(d, "project.json");
145
+ }
146
+
147
+ /**
148
+ * Review M4 — a pull into a new directory claims `.blocofy/project.json` EXCLUSIVELY before writing content, so two
149
+ * concurrent pulls into the same empty directory cannot both write. The complete file is hard-linked into place
150
+ * (fails atomically with EEXIST; a reader never sees a half-written file). If another process claimed it first,
151
+ * its binding must name the same site (else TARGET_SITE_MISMATCH, nothing written by us). Returns `{ release }`:
152
+ * undoes our claim (and the directories it created, if still empty) when the pull fails.
153
+ */
154
+ export function claimNewBinding(root, { site, platformOrigin }) {
155
+ const createdRoot = !existsSync(root);
156
+ mkdirSync(root, { recursive: true });
157
+ const d = join(root, PROJECT_DIR);
158
+ const st = lstatOrNull(d);
159
+ if (st && (st.isSymbolicLink() || !st.isDirectory())) {
160
+ throw new TargetError("TARGET_BINDING_INVALID", `${d} is not a real directory; nothing was written.`, { binding: d });
161
+ }
162
+ let createdDir = false;
163
+ if (!st) {
164
+ try {
165
+ mkdirSync(d);
166
+ createdDir = true;
167
+ } catch (error) {
168
+ if (error?.code !== "EEXIST") throw error;
169
+ }
170
+ }
171
+ const projectPath = join(d, "project.json");
172
+ const project = { schema_version: 1, site_id: site.id, site_slug: site.slug ?? null, platform_origin: origin(platformOrigin) };
173
+ const tmp = join(d, `.project.json.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
174
+ writeFileSync(tmp, JSON.stringify(project, null, 2) + "\n", { flag: "wx", mode: 0o644 });
175
+ try {
176
+ linkSync(tmp, projectPath);
177
+ } catch (error) {
178
+ if (error?.code !== "EEXIST") throw error;
179
+ const other = readBinding(root);
180
+ const o = compareOrigin(other.project.platform_origin, platformOrigin);
181
+ if (!sameId(other.project.site_id, site.id) || o === "mismatch" || o === "unproven") {
182
+ throw new TargetError(
183
+ "TARGET_SITE_MISMATCH",
184
+ `${root} was just bound to site ${other.project.site_slug ?? other.project.site_id} by another command; this pull is for ${site.slug ?? site.id}. Nothing was written.`,
185
+ { dir: root, binding_site_id: other.project.site_id, remote_site_id: site.id },
186
+ );
187
+ }
188
+ return { claimed: false, release() {} };
189
+ } finally {
190
+ rmSync(tmp, { force: true });
191
+ }
192
+ const tryRmdir = (p) => {
193
+ try {
194
+ rmdirSync(p);
195
+ } catch {
196
+ /* not empty (someone else wrote) — leave it */
197
+ }
198
+ };
199
+ return {
200
+ claimed: true,
201
+ release() {
202
+ rmSync(projectPath, { force: true });
203
+ if (createdDir) tryRmdir(d);
204
+ if (createdRoot) tryRmdir(root);
205
+ },
206
+ };
207
+ }
208
+
209
+ function isEmptyOrMissing(dir) {
210
+ if (!existsSync(dir)) return true;
211
+ if (!statSync(dir).isDirectory()) return false;
212
+ return readdirSync(dir).length === 0;
213
+ }
214
+
215
+ /**
216
+ * Command-class policy (before any credential or network use). Returns `{ newBinding }`: true when a local-write
217
+ * into a missing/empty unbound dir will record provenance after it succeeds.
218
+ */
219
+ export function enforceBindingPolicy({ commandClass, binding, dir, command }) {
220
+ if (!COMMAND_CLASSES.has(commandClass)) throw new Error(`internal: unknown command class ${commandClass}`);
221
+ if (binding) return { newBinding: false };
222
+ if (commandClass === "remote-mutation") {
223
+ throw new TargetError(
224
+ "TARGET_BINDING_REQUIRED",
225
+ `\`${command}\` changes a site, but ${dir} is not bound to one. Nothing was sent. Bind it first: blocofy link ${dir} --context <name>`,
226
+ { dir },
227
+ );
228
+ }
229
+ if (commandClass === "local-write") {
230
+ if (isEmptyOrMissing(dir)) return { newBinding: true };
231
+ throw new TargetError(
232
+ "TARGET_BINDING_REQUIRED",
233
+ `${dir} is not empty and not bound to a site. Nothing was written. Bind it first (blocofy link ${dir} --context <name>) or pull into an empty directory.`,
234
+ { dir },
235
+ );
236
+ }
237
+ return { newBinding: false };
238
+ }
239
+
240
+ // ── context resolution (pure) ───────────────────────────────────────────────────────────────────────────────
241
+
242
+ /** Offline: both sides are records, so a null on either side is not a mismatch (the remote check decides). */
243
+ function contextMatchesBinding(ctx, project) {
244
+ return isObj(ctx?.site) && sameId(ctx.site.id, project.site_id) && compareOrigin(ctx.platform_origin, project.platform_origin) !== "mismatch";
245
+ }
246
+
247
+ /**
248
+ * Contract C2 order: --context → BLOCOFY_CONTEXT → full env pair → .blocofy/local.json → the unique context
249
+ * matching the binding (site.id + platform_origin) → TTY pick among matching candidates → TARGET_CONTEXT_REQUIRED.
250
+ * With a binding present `current_context` is ignored; without one it is used only by `read` commands.
251
+ *
252
+ * `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? }`.
254
+ */
255
+ export async function resolveContext({ flagContext = null, envContextName = null, envCtx = null, getStore, binding = null, commandClass, isTTY = false, prompt = null }) {
256
+ const named = (name, source) => {
257
+ if (name === ENV_CONTEXT) {
258
+ if (envCtx) return { name: ENV_CONTEXT, source, context: envCtx.context, env: envCtx };
259
+ throw new TargetError("TARGET_CONTEXT_UNKNOWN", `Context "env" needs BLOCOFY_URL+BLOCOFY_TOKEN or BLOCOFY_API_URL+BLOCOFY_API_KEY.`, { context: name });
260
+ }
261
+ const store = getStore();
262
+ const ctx = store.contexts[name];
263
+ if (!ctx) {
264
+ throw new TargetError("TARGET_CONTEXT_UNKNOWN", `No context named "${name}" (from ${source}). List them with \`blocofy contexts\`.`, { context: name, source });
265
+ }
266
+ return { name, source, context: ctx };
267
+ };
268
+
269
+ if (flagContext) return named(flagContext, "--context");
270
+ if (envContextName) return named(envContextName, "BLOCOFY_CONTEXT");
271
+ if (envCtx) return { name: ENV_CONTEXT, source: "env", context: envCtx.context, env: envCtx };
272
+
273
+ const store = getStore();
274
+ const names = Object.keys(store.contexts);
275
+ if (names.length === 0) {
276
+ throw new TargetError("LOGIN_REQUIRED", "Login required: run `blocofy login` (or set BLOCOFY_URL + BLOCOFY_TOKEN).", {}, 1);
277
+ }
278
+
279
+ if (binding) {
280
+ if (binding.localContext) return named(binding.localContext, ".blocofy/local.json");
281
+ 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]] };
283
+ if (candidates.length > 1 && isTTY && prompt) {
284
+ const picked = await prompt(candidates);
285
+ if (candidates.includes(picked)) return { name: picked, source: "prompt", context: store.contexts[picked] };
286
+ }
287
+ throw new TargetError(
288
+ "TARGET_CONTEXT_REQUIRED",
289
+ candidates.length > 1
290
+ ? `Several contexts match this project's site (${candidates.join(", ")}). Choose one with --context <name> or \`blocofy link --context <name>\`.`
291
+ : `No saved context matches this project's site (${binding.project.site_slug ?? binding.project.site_id}). Log in to it (blocofy login) or pass --context <name>.`,
292
+ { site_id: binding.project.site_id, candidates },
293
+ );
294
+ }
295
+
296
+ if (commandClass === "read" && store.current_context && store.contexts[store.current_context]) {
297
+ return { name: store.current_context, source: "current_context", context: store.contexts[store.current_context] };
298
+ }
299
+ throw new TargetError("TARGET_CONTEXT_REQUIRED", "Choose the site: pass --context <name> (see `blocofy contexts`) or set BLOCOFY_CONTEXT.", { contexts: names });
300
+ }
301
+
302
+ /** Offline check before any network: a context already recorded for another site cannot serve this binding. */
303
+ export function precheckContext({ binding, resolved }) {
304
+ const ctx = resolved.context;
305
+ if (!binding || !isObj(ctx?.site)) return;
306
+ if (!contextMatchesBinding(ctx, binding.project)) {
307
+ throw new TargetError(
308
+ "TARGET_SITE_MISMATCH",
309
+ `Context "${resolved.name}" is for site ${ctx.site.slug ?? ctx.site.id}, but this project is bound to site ${binding.project.site_slug ?? binding.project.site_id}. Nothing was read or written.`,
310
+ { context: resolved.name, context_site_id: ctx.site.id, binding_site_id: binding.project.site_id },
311
+ );
312
+ }
313
+ }
314
+
315
+ // ── remote identity ─────────────────────────────────────────────────────────────────────────────────────────
316
+
317
+ const IDENTITY_TIMEOUT_MS = 15000;
318
+
319
+ /** Identity reads use the shared retry contract (lib/http.mjs); `retry` = `{ onRetry, sleep, now }` overrides. */
320
+ async function getJson(fetchImpl, url, secret, retry = {}) {
321
+ let res;
322
+ try {
323
+ res = await fetchWithRetry(url, { headers: { authorization: `Bearer ${secret}`, accept: "application/json" } }, { ...retry, fetchImpl, timeoutMs: IDENTITY_TIMEOUT_MS });
324
+ } catch (error) {
325
+ return { error: `unreachable (${error?.name === "TimeoutError" ? "timeout" : "network error"})` };
326
+ }
327
+ const text = await res.text().catch(() => "");
328
+ if (!res.ok) return { error: `HTTP ${res.status}` };
329
+ try {
330
+ return { body: JSON.parse(text) };
331
+ } catch {
332
+ return { error: "malformed response (not JSON)" };
333
+ }
334
+ }
335
+
336
+ function unverified(what, url, reason) {
337
+ return new TargetError("TARGET_UNVERIFIED", `Could not verify the target site via ${what} (${reason}). Nothing was sent or written.`, { endpoint: url, reason });
338
+ }
339
+
340
+ /** `GET {url}/api/dev/whoami` → `{ site, platformOrigin, liveThemeId }` or TARGET_UNVERIFIED. */
341
+ export async function verifyDev({ url, token, fetchImpl = globalThis.fetch, retry = {} }) {
342
+ const endpoint = `${url.replace(/\/+$/, "")}/api/dev/whoami`;
343
+ const r = await getJson(fetchImpl, endpoint, token, retry);
344
+ if (r.error) throw unverified("/api/dev/whoami", endpoint, r.error);
345
+ const site = r.body?.site;
346
+ if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id)) || typeof site.slug !== "string") {
347
+ throw unverified("/api/dev/whoami", endpoint, "malformed response (no site id/slug)");
348
+ }
349
+ return { site: { id: site.id, slug: site.slug, name: typeof site.name === "string" ? site.name : null, domain: typeof site.domain === "string" ? site.domain : null }, platformOrigin: origin(r.body.platform_origin), liveThemeId: r.body.liveThemeId ?? null };
350
+ }
351
+
352
+ /** `GET {apiUrl}/api/v1/ping` → `{ site, platformOrigin }` or TARGET_UNVERIFIED. */
353
+ export async function verifyApi({ url, apiKey, fetchImpl = globalThis.fetch, retry = {} }) {
354
+ const endpoint = `${url.replace(/\/+$/, "")}/api/v1/ping`;
355
+ const r = await getJson(fetchImpl, endpoint, apiKey, retry);
356
+ if (r.error) throw unverified("/api/v1/ping", endpoint, r.error);
357
+ const site = r.body?.site;
358
+ if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id))) {
359
+ throw unverified("/api/v1/ping", endpoint, "malformed response (no site id)");
360
+ }
361
+ return { site: { id: site.id, slug: typeof site.slug === "string" ? site.slug : null, name: typeof site.name === "string" ? site.name : null, domain: typeof site.domain === "string" ? site.domain : null }, platformOrigin: origin(r.body.platform_origin), liveThemeId: null };
362
+ }
363
+
364
+ function originUnproven(what, recorded) {
365
+ return new TargetError(
366
+ "TARGET_UNVERIFIED",
367
+ `The server does not report its platform origin, so it cannot be proven to be ${recorded} (recorded for ${what}). Nothing was sent or written.`,
368
+ { reason: "platform_origin_missing", recorded_platform_origin: recorded },
369
+ );
370
+ }
371
+
372
+ /** Both pairs of one context must name the same site (and platform). */
373
+ export function assertSameSite(dev, api, contextName) {
374
+ if (!sameId(dev.site.id, api.site.id) || dev.platformOrigin !== api.platformOrigin) {
375
+ throw new TargetError(
376
+ "TARGET_CREDENTIAL_MISMATCH",
377
+ `Context "${contextName}" mixes credentials of two sites: the dev token is for ${dev.site.slug ?? dev.site.id}, the API key is for ${api.site.slug ?? api.site.id}. Nothing was sent or written.`,
378
+ { context: contextName, dev_site_id: dev.site.id, api_site_id: api.site.id },
379
+ );
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Verify every usable pair of the resolved context, then compare with the context's recorded site and with the
385
+ * binding. Returns the verified identity (`site`, `platformOrigin`, `liveThemeId`, and `warnings` when a record lacks
386
+ * its platform origin — see `compareOrigin`).
387
+ */
388
+ export async function verifyTarget({ resolved, secrets, binding, fetchImpl = globalThis.fetch, retry = {} }) {
389
+ const ctx = resolved.context;
390
+ const dev = ctx.dev && secrets.devToken ? await verifyDev({ url: ctx.dev.url, token: secrets.devToken, fetchImpl, retry }) : null;
391
+ const api = ctx.api && secrets.apiKey ? await verifyApi({ url: ctx.api.url, apiKey: secrets.apiKey, fetchImpl, retry }) : null;
392
+ if (dev && api) assertSameSite(dev, api, resolved.name);
393
+ const identity = dev ? { ...dev, site: { ...dev.site, domain: dev.site.domain ?? api?.site.domain ?? null } } : api;
394
+ if (!identity) throw new TargetError("LOGIN_REQUIRED", `Context "${resolved.name}" has no usable credentials. Run \`blocofy login\`.`, { context: resolved.name }, 1);
395
+
396
+ const warnings = [];
397
+ if (isObj(ctx.site)) {
398
+ const o = compareOrigin(ctx.platform_origin, identity.platformOrigin);
399
+ if (!sameId(ctx.site.id, identity.site.id) || o === "mismatch") {
400
+ throw new TargetError(
401
+ "TARGET_SITE_MISMATCH",
402
+ `Context "${resolved.name}" is recorded for site ${ctx.site.slug ?? ctx.site.id}, but its credentials now resolve to ${identity.site.slug ?? identity.site.id}. Nothing was sent or written. Log in again: blocofy login --context ${resolved.name}`,
403
+ { context: resolved.name, context_site_id: ctx.site.id, remote_site_id: identity.site.id },
404
+ );
405
+ }
406
+ if (o === "unproven") throw originUnproven(`context "${resolved.name}"`, ctx.platform_origin);
407
+ if (o === "upgrade") {
408
+ warnings.push({ code: "TARGET_CONTEXT_ORIGIN_MISSING", message: `Context "${resolved.name}" is missing its platform origin; run \`blocofy login --context ${resolved.name}\` to record ${identity.platformOrigin}.` });
409
+ }
410
+ }
411
+ if (binding) {
412
+ const o = compareOrigin(binding.project.platform_origin, identity.platformOrigin);
413
+ if (!sameId(binding.project.site_id, identity.site.id) || o === "mismatch") {
414
+ throw new TargetError(
415
+ "TARGET_SITE_MISMATCH",
416
+ `This project is bound to site ${binding.project.site_slug ?? binding.project.site_id}, but context "${resolved.name}" resolves to ${identity.site.slug ?? identity.site.id}. Nothing was sent or written.`,
417
+ { context: resolved.name, binding_site_id: binding.project.site_id, remote_site_id: identity.site.id },
418
+ );
419
+ }
420
+ if (o === "unproven") throw originUnproven("this project's binding", binding.project.platform_origin);
421
+ if (o === "upgrade") {
422
+ warnings.push({ code: "TARGET_BINDING_ORIGIN_MISSING", message: `The project binding is missing its platform origin; run \`blocofy link --adopt\` to record ${identity.platformOrigin}.` });
423
+ }
424
+ }
425
+ if (warnings.length) identity.warnings = warnings;
426
+ return identity;
427
+ }